@kody-ade/kody-engine 0.4.116 → 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.
Files changed (2) hide show
  1. package/dist/bin/kody.js +206 -30
  2. 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.116",
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: { [POOL_METADATA_KEY]: POOL_METADATA_VALUE },
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
- /** List the app's pooled (kody_pool) machines, excluding destroyed/destroying. */
4843
- async listPooled() {
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. */
@@ -4913,7 +4920,7 @@ var PoolManager = class {
4913
4920
  * free; anything else is left to finish/auto-destroy. Then refill to `min`.
4914
4921
  */
4915
4922
  async reconcile() {
4916
- const machines = await this.deps.fly.listPooled();
4923
+ const machines = await this.deps.fly.listPooled(this.deps.config.repoTag);
4917
4924
  this.free = [];
4918
4925
  for (const m of machines) {
4919
4926
  if ((m.state === "suspended" || m.state === "suspending") && m.private_ip) {
@@ -4981,7 +4988,7 @@ var PoolManager = class {
4981
4988
  async resync() {
4982
4989
  let machines;
4983
4990
  try {
4984
- machines = await this.deps.fly.listPooled();
4991
+ machines = await this.deps.fly.listPooled(this.deps.config.repoTag);
4985
4992
  } catch (err) {
4986
4993
  this.log(`resync: listPooled failed: ${errMsg2(err)}`);
4987
4994
  return;
@@ -5031,6 +5038,7 @@ var PoolManager = class {
5031
5038
  guest: cfg.guest,
5032
5039
  runnerApiKey: cfg.runnerApiKey,
5033
5040
  litellmUrl: cfg.litellmUrl,
5041
+ repoTag: cfg.repoTag,
5034
5042
  port: cfg.port
5035
5043
  });
5036
5044
  if (!m.private_ip) {
@@ -5078,6 +5086,160 @@ function errMsg2(err) {
5078
5086
  return err instanceof Error ? err.message : String(err);
5079
5087
  }
5080
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
+
5081
5243
  // src/pool/keys.ts
5082
5244
  import { hkdfSync } from "crypto";
5083
5245
  var POOL_API_KEY_INFO = "kody-pool-api:v1";
@@ -5149,7 +5311,7 @@ function readJsonBody3(req) {
5149
5311
  req.on("error", reject);
5150
5312
  });
5151
5313
  }
5152
- function parsePoolJob(body) {
5314
+ function parseClaimRequest(body) {
5153
5315
  if (typeof body !== "object" || body === null) return { error: "body must be an object" };
5154
5316
  const b = body;
5155
5317
  const jobId = typeof b.jobId === "string" ? b.jobId.trim() : "";
@@ -5158,15 +5320,12 @@ function parsePoolJob(body) {
5158
5320
  if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: "repo must be 'owner/name'" };
5159
5321
  const issueNumber = Number(b.issueNumber);
5160
5322
  if (!Number.isInteger(issueNumber) || issueNumber <= 0) return { error: "issueNumber required" };
5161
- const githubToken = typeof b.githubToken === "string" ? b.githubToken.trim() : "";
5162
- if (!githubToken) return { error: "githubToken required" };
5163
- const job = { jobId, repo, issueNumber, githubToken };
5164
- if (typeof b.ref === "string" && b.ref.trim()) job.ref = b.ref.trim();
5165
- if (typeof b.model === "string" && b.model.trim()) job.model = b.model.trim();
5166
- if (typeof b.sessionId === "string" && b.sessionId.trim()) job.sessionId = b.sessionId.trim();
5167
- if (typeof b.dashboardUrl === "string" && b.dashboardUrl.trim()) job.dashboardUrl = b.dashboardUrl.trim();
5168
- if (b.allSecrets && typeof b.allSecrets === "object") job.allSecrets = b.allSecrets;
5169
- 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 };
5170
5329
  }
5171
5330
  function superviseLitellm() {
5172
5331
  if (process.env.POOL_DISABLE_LITELLM === "1") return null;
@@ -5198,8 +5357,8 @@ var poolServe = async (ctx) => {
5198
5357
  ctx.skipAgent = true;
5199
5358
  const masterRaw = process.env.KODY_MASTER_KEY?.trim();
5200
5359
  if (!masterRaw) throw new Error("KODY_MASTER_KEY required for pool-serve");
5201
- const flyToken = process.env.FLY_API_TOKEN?.trim();
5202
- if (!flyToken) throw new Error("FLY_API_TOKEN required for pool-serve");
5360
+ const githubToken = process.env.GITHUB_TOKEN?.trim();
5361
+ if (!githubToken) throw new Error("GITHUB_TOKEN required for pool-serve (reads per-repo vaults)");
5203
5362
  const master = masterKeyBytes(masterRaw);
5204
5363
  const poolApiKey = derivePoolApiKey(master);
5205
5364
  const runnerApiKey = deriveRunnerApiKey(master);
@@ -5213,23 +5372,36 @@ var poolServe = async (ctx) => {
5213
5372
  const apiPort = envInt("POOL_API_PORT", 4100);
5214
5373
  const healthTimeoutMs = envInt("POOL_HEALTH_TIMEOUT_MS", 12e4);
5215
5374
  const litellm = superviseLitellm();
5216
- const fly = new FlyClient({ token: flyToken, app });
5217
- const manager = new PoolManager({
5218
- fly,
5219
- config: { min, image: process.env.FLY_RUNNER_IMAGE ?? "registry.fly.io/kody-runner:latest", region, guest, runnerApiKey, litellmUrl, port: runnerPort, healthTimeoutMs },
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
+ },
5220
5389
  log
5221
5390
  });
5222
- manager.reconcile().catch((err) => log(`reconcile failed: ${err instanceof Error ? err.message : String(err)}`));
5223
5391
  const refillMs = envInt("POOL_REFILL_INTERVAL_MS", 6e4);
5224
5392
  const tick = setInterval(() => {
5225
- manager.resync().catch((err) => log(`resync tick failed: ${err instanceof Error ? err.message : String(err)}`));
5393
+ registry.resyncAll().catch((err) => log(`resync tick failed: ${err instanceof Error ? err.message : String(err)}`));
5226
5394
  }, refillMs);
5227
5395
  const server = createServer3(async (req, res) => {
5228
5396
  try {
5229
5397
  if (!req.method || !req.url) return sendJson3(res, 400, { error: "bad request" });
5230
5398
  const url = new URL(req.url, "http://localhost");
5231
5399
  if (req.method === "GET" && url.pathname === "/healthz") {
5232
- return sendJson3(res, 200, { ok: true, litellm: litellm ? "supervised" : "off", pool: manager.status() });
5400
+ return sendJson3(res, 200, {
5401
+ ok: true,
5402
+ litellm: litellm ? "supervised" : "off",
5403
+ repos: registry.activeRepos()
5404
+ });
5233
5405
  }
5234
5406
  const authed = bearerOk(
5235
5407
  req.headers["authorization"],
@@ -5238,7 +5410,10 @@ var poolServe = async (ctx) => {
5238
5410
  );
5239
5411
  if (!authed) return sendJson3(res, 401, { error: "unauthorized" });
5240
5412
  if (req.method === "GET" && url.pathname === "/pool/status") {
5241
- return sendJson3(res, 200, manager.status());
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) });
5242
5417
  }
5243
5418
  if (req.method === "POST" && url.pathname === "/pool/claim") {
5244
5419
  let body;
@@ -5247,9 +5422,10 @@ var poolServe = async (ctx) => {
5247
5422
  } catch {
5248
5423
  return sendJson3(res, 400, { error: "invalid JSON body" });
5249
5424
  }
5250
- const parsed = parsePoolJob(body);
5425
+ const parsed = parseClaimRequest(body);
5251
5426
  if ("error" in parsed) return sendJson3(res, 400, { error: parsed.error });
5252
- const result = await manager.claim(parsed.job);
5427
+ const [owner, repo] = parsed.req.repo.split("/");
5428
+ const result = await registry.claim(owner, repo, parsed.req);
5253
5429
  if (result.ok) return sendJson3(res, 200, { ok: true, machineId: result.machineId });
5254
5430
  return sendJson3(res, 503, { ok: false, reason: result.reason ?? "pool unavailable" });
5255
5431
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.4.116",
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",