@harness-engineering/orchestrator 0.10.0 → 0.11.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/dist/index.d.mts CHANGED
@@ -1993,6 +1993,15 @@ declare class Orchestrator extends EventEmitter {
1993
1993
  * `store.load()`.
1994
1994
  */
1995
1995
  private initModelPool;
1996
+ /**
1997
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
1998
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
1999
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
2000
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
2001
+ * or no `pool` block is configured, so it is safe to call unconditionally
2002
+ * on the startup path.
2003
+ */
2004
+ private applyConfiguredPoolBounds;
1996
2005
  /**
1997
2006
  * LMLM Phase 6: arm the single background refresh scheduler over the live
1998
2007
  * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
package/dist/index.d.ts CHANGED
@@ -1993,6 +1993,15 @@ declare class Orchestrator extends EventEmitter {
1993
1993
  * `store.load()`.
1994
1994
  */
1995
1995
  private initModelPool;
1996
+ /**
1997
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
1998
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
1999
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
2000
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
2001
+ * or no `pool` block is configured, so it is safe to call unconditionally
2002
+ * on the startup path.
2003
+ */
2004
+ private applyConfiguredPoolBounds;
1996
2005
  /**
1997
2006
  * LMLM Phase 6: arm the single background refresh scheduler over the live
1998
2007
  * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
package/dist/index.js CHANGED
@@ -3109,7 +3109,7 @@ var PromptRenderer = class {
3109
3109
  var import_node_events = require("events");
3110
3110
  var path21 = __toESM(require("path"));
3111
3111
  var import_node_crypto15 = require("crypto");
3112
- var import_core15 = require("@harness-engineering/core");
3112
+ var import_core16 = require("@harness-engineering/core");
3113
3113
 
3114
3114
  // src/core/stall-detector.ts
3115
3115
  function detectStalledIssues(running, nowMs, stallTimeoutMs) {
@@ -3685,7 +3685,7 @@ var CompletionHandler = class {
3685
3685
  };
3686
3686
 
3687
3687
  // src/orchestrator.ts
3688
- var import_core16 = require("@harness-engineering/core");
3688
+ var import_core17 = require("@harness-engineering/core");
3689
3689
 
3690
3690
  // src/tracker/adapters/github-issues-issue-tracker.ts
3691
3691
  var import_types9 = require("@harness-engineering/types");
@@ -4085,7 +4085,7 @@ var LocalModelResolver = class {
4085
4085
 
4086
4086
  // src/orchestrator.ts
4087
4087
  var import_local_models4 = require("@harness-engineering/local-models");
4088
- var import_core17 = require("@harness-engineering/core");
4088
+ var import_core18 = require("@harness-engineering/core");
4089
4089
 
4090
4090
  // src/agent/config-migration.ts
4091
4091
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
@@ -7049,7 +7049,7 @@ function buildRoutingUseCase(issue, backendParam, catalog) {
7049
7049
  // src/server/http.ts
7050
7050
  var http = __toESM(require("http"));
7051
7051
  var path17 = __toESM(require("path"));
7052
- var import_core11 = require("@harness-engineering/core");
7052
+ var import_core12 = require("@harness-engineering/core");
7053
7053
 
7054
7054
  // src/server/websocket.ts
7055
7055
  var import_ws = require("ws");
@@ -9186,17 +9186,187 @@ async function runForceRefresh(res, scheduler, deps) {
9186
9186
  });
9187
9187
  }
9188
9188
 
9189
+ // src/server/routes/v1/local-models-pool-mutation.ts
9190
+ var import_core11 = require("@harness-engineering/core");
9191
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9192
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9193
+ var RESOLVE_TOP = 50;
9194
+ function sendJSON10(res, status, body) {
9195
+ res.writeHead(status, { "Content-Type": "application/json" });
9196
+ res.end(JSON.stringify(body));
9197
+ }
9198
+ function decidedByOf(deps, req) {
9199
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9200
+ const token = req._authToken;
9201
+ return token?.id ?? "operator";
9202
+ }
9203
+ function handlerDeps(deps, req, pool) {
9204
+ return {
9205
+ pool,
9206
+ bus: deps.bus,
9207
+ updateProposal: (id, patch) => (0, import_core11.updateProposal)(deps.projectPath, id, patch),
9208
+ decidedBy: decidedByOf(deps, req),
9209
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9210
+ };
9211
+ }
9212
+ async function readJsonBody(req) {
9213
+ try {
9214
+ const raw = await readBody(req);
9215
+ if (raw.length === 0) return {};
9216
+ const parsed = JSON.parse(raw);
9217
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9218
+ } catch {
9219
+ return void 0;
9220
+ }
9221
+ }
9222
+ async function handleInstall(req, res, deps) {
9223
+ const pool = deps.getModelPool();
9224
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9225
+ const body = await readJsonBody(req);
9226
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9227
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9228
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9229
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9230
+ if (!deps.getRecommendations) {
9231
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9232
+ }
9233
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9234
+ const match = ranked.find(
9235
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9236
+ );
9237
+ if (!match) {
9238
+ return sendJSON10(res, 404, {
9239
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9240
+ });
9241
+ }
9242
+ if (!match.ollamaName) {
9243
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9244
+ }
9245
+ const content = {
9246
+ action: "add",
9247
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9248
+ scoreDelta: match.score,
9249
+ justification: {
9250
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9251
+ benchmarkBasis: [],
9252
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9253
+ evidence: match.evidence,
9254
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9255
+ },
9256
+ // 0 → the installer resolves the true on-disk size via `inspect` before the
9257
+ // budget check, rather than trusting an estimate.
9258
+ diskImpactGb: 0
9259
+ };
9260
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9261
+ proposedBy: decidedByOf(deps, req)
9262
+ });
9263
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9264
+ if (outcome.status === "approved") {
9265
+ const result = {
9266
+ disposition: "installed",
9267
+ proposalId: record.id,
9268
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9269
+ };
9270
+ return sendJSON10(res, 200, result);
9271
+ }
9272
+ if (outcome.status === "failed_target_missing") {
9273
+ return sendJSON10(res, 404, {
9274
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9275
+ proposalId: record.id
9276
+ });
9277
+ }
9278
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9279
+ return sendJSON10(res, status, {
9280
+ error: outcome.message,
9281
+ code: outcome.code,
9282
+ proposalId: record.id
9283
+ });
9284
+ }
9285
+ async function handleRemove(req, res, deps) {
9286
+ const pool = deps.getModelPool();
9287
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9288
+ const body = await readJsonBody(req);
9289
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9290
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9291
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9292
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9293
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9294
+ const content = {
9295
+ action: "evict",
9296
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9297
+ scoreDelta: 0,
9298
+ justification: {
9299
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9300
+ benchmarkBasis: [],
9301
+ hardwareFit: "",
9302
+ evidence: "operator",
9303
+ freshness: ""
9304
+ },
9305
+ diskImpactGb: entry.sizeOnDiskGb
9306
+ };
9307
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9308
+ proposedBy: decidedByOf(deps, req)
9309
+ });
9310
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9311
+ if (outcome.status === "error") {
9312
+ return sendJSON10(res, 502, {
9313
+ error: outcome.message,
9314
+ code: outcome.code,
9315
+ proposalId: record.id
9316
+ });
9317
+ }
9318
+ if (outcome.status === "failed_target_missing") {
9319
+ return sendJSON10(res, 500, {
9320
+ error: "unexpected failed_target_missing on evict",
9321
+ proposalId: record.id
9322
+ });
9323
+ }
9324
+ if (outcome.evicted.length > 0) {
9325
+ const result2 = {
9326
+ disposition: "removed",
9327
+ proposalId: record.id,
9328
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9329
+ };
9330
+ return sendJSON10(res, 200, result2);
9331
+ }
9332
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9333
+ if (stillPresent) {
9334
+ const result2 = {
9335
+ disposition: "deferred",
9336
+ proposalId: record.id,
9337
+ evicted: [],
9338
+ message: "model is in use; it will be removed after the current run"
9339
+ };
9340
+ return sendJSON10(res, 202, result2);
9341
+ }
9342
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9343
+ return sendJSON10(res, 200, result);
9344
+ }
9345
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9346
+ const url = req.url ?? "";
9347
+ if ((req.method ?? "GET") !== "POST") return false;
9348
+ if (INSTALL_RE.test(url)) {
9349
+ void handleInstall(req, res, deps);
9350
+ return true;
9351
+ }
9352
+ if (REMOVE_RE.test(url)) {
9353
+ void handleRemove(req, res, deps);
9354
+ return true;
9355
+ }
9356
+ return false;
9357
+ }
9358
+
9189
9359
  // src/server/routes/v1/routing.ts
9190
9360
  var import_zod14 = require("zod");
9191
9361
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
9192
9362
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
9193
9363
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
9194
- function sendJSON10(res, status, body) {
9364
+ function sendJSON11(res, status, body) {
9195
9365
  res.writeHead(status, { "Content-Type": "application/json" });
9196
9366
  res.end(JSON.stringify(body));
9197
9367
  }
9198
9368
  function unavailable(res) {
9199
- sendJSON10(res, 503, { error: "BackendRouter not available" });
9369
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
9200
9370
  return true;
9201
9371
  }
9202
9372
  function resolveChain(value, backends) {
@@ -9233,7 +9403,7 @@ function buildResolvedChains(routing, backends) {
9233
9403
  }
9234
9404
  function handleConfig(res, deps) {
9235
9405
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
9236
- sendJSON10(res, 200, {
9406
+ sendJSON11(res, 200, {
9237
9407
  routing: deps.routing,
9238
9408
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
9239
9409
  backends: Object.keys(deps.backends)
@@ -9261,7 +9431,7 @@ function parseDecisionsQuery(url) {
9261
9431
  function handleDecisions(req, res, deps) {
9262
9432
  if (!deps.bus) return unavailable(res);
9263
9433
  const filter = parseDecisionsQuery(req.url ?? "");
9264
- sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
9434
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
9265
9435
  return true;
9266
9436
  }
9267
9437
  var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
@@ -9293,19 +9463,19 @@ async function handleTrace(req, res, deps) {
9293
9463
  try {
9294
9464
  raw = await readBody(req);
9295
9465
  } catch {
9296
- sendJSON10(res, 400, { error: "body read failed" });
9466
+ sendJSON11(res, 400, { error: "body read failed" });
9297
9467
  return true;
9298
9468
  }
9299
9469
  let parsed;
9300
9470
  try {
9301
9471
  parsed = JSON.parse(raw);
9302
9472
  } catch {
9303
- sendJSON10(res, 400, { error: "invalid JSON body" });
9473
+ sendJSON11(res, 400, { error: "invalid JSON body" });
9304
9474
  return true;
9305
9475
  }
9306
9476
  const r = TraceBodySchema.safeParse(parsed);
9307
9477
  if (!r.success) {
9308
- sendJSON10(res, 400, { error: r.error.message });
9478
+ sendJSON11(res, 400, { error: r.error.message });
9309
9479
  return true;
9310
9480
  }
9311
9481
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -9318,9 +9488,9 @@ async function handleTrace(req, res, deps) {
9318
9488
  r.data.useCase,
9319
9489
  opts
9320
9490
  );
9321
- sendJSON10(res, 200, { decision, def: { type: def.type } });
9491
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
9322
9492
  } catch (err) {
9323
- sendJSON10(res, 500, { error: String(err) });
9493
+ sendJSON11(res, 500, { error: String(err) });
9324
9494
  }
9325
9495
  return true;
9326
9496
  }
@@ -9557,7 +9727,7 @@ var CreateBodySchema = import_zod16.z.object({
9557
9727
  tenantId: import_zod16.z.string().optional(),
9558
9728
  expiresAt: import_zod16.z.string().datetime().optional()
9559
9729
  });
9560
- function sendJSON11(res, status, body) {
9730
+ function sendJSON12(res, status, body) {
9561
9731
  res.writeHead(status, { "Content-Type": "application/json" });
9562
9732
  res.end(JSON.stringify(body));
9563
9733
  }
@@ -9567,19 +9737,19 @@ async function handlePost(req, res, store) {
9567
9737
  raw = await readBody(req);
9568
9738
  } catch (err) {
9569
9739
  const msg = err instanceof Error ? err.message : "Failed to read body";
9570
- sendJSON11(res, 413, { error: msg });
9740
+ sendJSON12(res, 413, { error: msg });
9571
9741
  return;
9572
9742
  }
9573
9743
  let json;
9574
9744
  try {
9575
9745
  json = JSON.parse(raw);
9576
9746
  } catch {
9577
- sendJSON11(res, 400, { error: "Invalid JSON body" });
9747
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9578
9748
  return;
9579
9749
  }
9580
9750
  const parsed = CreateBodySchema.safeParse(json);
9581
9751
  if (!parsed.success) {
9582
- sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9752
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9583
9753
  return;
9584
9754
  }
9585
9755
  try {
@@ -9592,37 +9762,37 @@ async function handlePost(req, res, store) {
9592
9762
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9593
9763
  const result = await store.create(input);
9594
9764
  const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
9595
- sendJSON11(res, 200, {
9765
+ sendJSON12(res, 200, {
9596
9766
  ...publicRecord,
9597
9767
  token: result.token
9598
9768
  });
9599
9769
  } catch (err) {
9600
9770
  const msg = err instanceof Error ? err.message : "Failed to create token";
9601
9771
  if (msg.includes("already exists")) {
9602
- sendJSON11(res, 409, { error: msg });
9772
+ sendJSON12(res, 409, { error: msg });
9603
9773
  return;
9604
9774
  }
9605
- sendJSON11(res, 500, { error: "Internal error creating token" });
9775
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9606
9776
  }
9607
9777
  }
9608
9778
  async function handleList3(res, store) {
9609
9779
  try {
9610
9780
  const list = await store.list();
9611
- sendJSON11(res, 200, list);
9781
+ sendJSON12(res, 200, list);
9612
9782
  } catch {
9613
- sendJSON11(res, 500, { error: "Internal error listing tokens" });
9783
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9614
9784
  }
9615
9785
  }
9616
9786
  async function handleDelete2(res, store, id) {
9617
9787
  try {
9618
9788
  const ok = await store.revoke(id);
9619
9789
  if (!ok) {
9620
- sendJSON11(res, 404, { error: "Token not found" });
9790
+ sendJSON12(res, 404, { error: "Token not found" });
9621
9791
  return;
9622
9792
  }
9623
- sendJSON11(res, 200, { deleted: true });
9793
+ sendJSON12(res, 200, { deleted: true });
9624
9794
  } catch {
9625
- sendJSON11(res, 500, { error: "Internal error revoking token" });
9795
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9626
9796
  }
9627
9797
  }
9628
9798
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9647,12 +9817,12 @@ function handleAuthRoute(req, res, store) {
9647
9817
  return true;
9648
9818
  }
9649
9819
  }
9650
- sendJSON11(res, 405, { error: "Method not allowed" });
9820
+ sendJSON12(res, 405, { error: "Method not allowed" });
9651
9821
  return true;
9652
9822
  }
9653
9823
 
9654
9824
  // src/server/routes/local-model.ts
9655
- function sendJSON12(res, status, body) {
9825
+ function sendJSON13(res, status, body) {
9656
9826
  res.writeHead(status, { "Content-Type": "application/json" });
9657
9827
  res.end(JSON.stringify(body));
9658
9828
  }
@@ -9660,30 +9830,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9660
9830
  const { method, url } = req;
9661
9831
  if (url !== "/api/v1/local-model/status") return false;
9662
9832
  if (method !== "GET") {
9663
- sendJSON12(res, 405, { error: "Method not allowed" });
9833
+ sendJSON13(res, 405, { error: "Method not allowed" });
9664
9834
  return true;
9665
9835
  }
9666
9836
  if (!getStatus) {
9667
- sendJSON12(res, 503, { error: "Local backend not configured" });
9837
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9668
9838
  return true;
9669
9839
  }
9670
9840
  const status = getStatus();
9671
9841
  if (!status) {
9672
- sendJSON12(res, 503, { error: "Local backend not configured" });
9842
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9673
9843
  return true;
9674
9844
  }
9675
- sendJSON12(res, 200, status);
9845
+ sendJSON13(res, 200, status);
9676
9846
  return true;
9677
9847
  }
9678
9848
  function handleLocalModelsRoute(req, res, getStatuses) {
9679
9849
  const { method, url } = req;
9680
9850
  if (url !== "/api/v1/local-models/status") return false;
9681
9851
  if (method !== "GET") {
9682
- sendJSON12(res, 405, { error: "Method not allowed" });
9852
+ sendJSON13(res, 405, { error: "Method not allowed" });
9683
9853
  return true;
9684
9854
  }
9685
9855
  const statuses = getStatuses ? getStatuses() : [];
9686
- sendJSON12(res, 200, statuses);
9856
+ sendJSON13(res, 200, statuses);
9687
9857
  return true;
9688
9858
  }
9689
9859
 
@@ -10056,6 +10226,21 @@ var V1_BRIDGE_ROUTES = [
10056
10226
  scope: "manage-proposals",
10057
10227
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10058
10228
  },
10229
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10230
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10231
+ // write scope that governs approve/reject governs these too.
10232
+ {
10233
+ method: "POST",
10234
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10235
+ scope: "manage-proposals",
10236
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10237
+ },
10238
+ {
10239
+ method: "POST",
10240
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10241
+ scope: "manage-proposals",
10242
+ description: "Remove a model from the local pool (auto-approved proposal)."
10243
+ },
10059
10244
  // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10060
10245
  // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10061
10246
  // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
@@ -10490,6 +10675,17 @@ var OrchestratorServer = class {
10490
10675
  ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10491
10676
  });
10492
10677
  },
10678
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10679
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10680
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10681
+ // Registered before the read surface so /pool/install|remove match first.
10682
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
10683
+ projectPath: this.projectPath,
10684
+ bus: this.orchestrator,
10685
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10686
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10687
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10688
+ }),
10493
10689
  // LMLM Phase 6/7 — POST /refresh + the GET read surface
10494
10690
  // (hardware/pool/recommendations/proposals). Registered before the
10495
10691
  // chat-proxy fallback so it owns the path. Each accessor is spread in
@@ -10583,7 +10779,7 @@ var OrchestratorServer = class {
10583
10779
  return this.broadcaster.clientCount;
10584
10780
  }
10585
10781
  async start() {
10586
- (0, import_core11.assertPortUsable)(this.port, "orchestrator");
10782
+ (0, import_core12.assertPortUsable)(this.port, "orchestrator");
10587
10783
  if (this.interactionQueue) {
10588
10784
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
10589
10785
  this.planWatcher.start();
@@ -11085,7 +11281,7 @@ function wireWebhookFanout({ bus, store, delivery }) {
11085
11281
 
11086
11282
  // src/gateway/telemetry/fanout.ts
11087
11283
  var import_node_crypto13 = require("crypto");
11088
- var import_core12 = require("@harness-engineering/core");
11284
+ var import_core13 = require("@harness-engineering/core");
11089
11285
  var TOPICS = {
11090
11286
  MAINTENANCE_STARTED: "maintenance:started",
11091
11287
  MAINTENANCE_COMPLETED: "maintenance:completed",
@@ -11217,7 +11413,7 @@ function buildSpan(topic, plan, payload) {
11217
11413
  spanId: plan.spanId,
11218
11414
  ...plan.parentSpanId !== void 0 ? { parentSpanId: plan.parentSpanId } : {},
11219
11415
  name: SPAN_NAME[topic],
11220
- kind: import_core12.SpanKind.INTERNAL,
11416
+ kind: import_core13.SpanKind.INTERNAL,
11221
11417
  startTimeNs: startNs,
11222
11418
  endTimeNs: startNs,
11223
11419
  attributes: buildAttributes(payload, { "harness.topic": topic }),
@@ -11674,7 +11870,7 @@ function wireNotificationSinks({ bus, registry }) {
11674
11870
  }
11675
11871
 
11676
11872
  // src/orchestrator.ts
11677
- var import_core18 = require("@harness-engineering/core");
11873
+ var import_core19 = require("@harness-engineering/core");
11678
11874
 
11679
11875
  // src/logging/logger.ts
11680
11876
  var StructuredLogger = class {
@@ -11716,7 +11912,7 @@ var StructuredLogger = class {
11716
11912
  // src/workspace/config-scanner.ts
11717
11913
  var import_node_fs = require("fs");
11718
11914
  var import_node_path4 = require("path");
11719
- var import_core13 = require("@harness-engineering/core");
11915
+ var import_core14 = require("@harness-engineering/core");
11720
11916
  var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
11721
11917
  var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
11722
11918
  var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
@@ -11738,29 +11934,29 @@ async function scanSingleFile(filePath, targetDir, scanner) {
11738
11934
  } catch {
11739
11935
  return null;
11740
11936
  }
11741
- const injectionFindings = (0, import_core13.scanForInjection)(content);
11742
- const findings = (0, import_core13.mapInjectionFindings)(injectionFindings);
11937
+ const injectionFindings = (0, import_core14.scanForInjection)(content);
11938
+ const findings = (0, import_core14.mapInjectionFindings)(injectionFindings);
11743
11939
  const secFindings = await scanner.scanFile(filePath);
11744
- findings.push(...(0, import_core13.mapSecurityFindings)(secFindings, findings));
11940
+ findings.push(...(0, import_core14.mapSecurityFindings)(secFindings, findings));
11745
11941
  const adjusted = adjustFindingSeverity(findings);
11746
11942
  return {
11747
11943
  file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
11748
11944
  findings: adjusted,
11749
- overallSeverity: (0, import_core13.computeOverallSeverity)(adjusted)
11945
+ overallSeverity: (0, import_core14.computeOverallSeverity)(adjusted)
11750
11946
  };
11751
11947
  }
11752
11948
  async function scanWorkspaceConfig(workspacePath) {
11753
- const scanner = new import_core13.SecurityScanner((0, import_core13.parseSecurityConfig)({}));
11949
+ const scanner = new import_core14.SecurityScanner((0, import_core14.parseSecurityConfig)({}));
11754
11950
  const results = [];
11755
11951
  for (const configFile of CONFIG_FILES) {
11756
11952
  const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
11757
11953
  if (result) results.push(result);
11758
11954
  }
11759
- return { exitCode: (0, import_core13.computeScanExitCode)(results), results };
11955
+ return { exitCode: (0, import_core14.computeScanExitCode)(results), results };
11760
11956
  }
11761
11957
 
11762
11958
  // src/core/lane-persistence.ts
11763
- var import_core14 = require("@harness-engineering/core");
11959
+ var import_core15 = require("@harness-engineering/core");
11764
11960
  var import_types29 = require("@harness-engineering/types");
11765
11961
  var SIGNAL_TO_LANE = {
11766
11962
  claim: "claimed",
@@ -11774,16 +11970,16 @@ function mapOrchestratorLane(signal) {
11774
11970
  }
11775
11971
  async function persistLane(projectPath, issueId, signal) {
11776
11972
  try {
11777
- const reg = await import_core14.eventSourcing.registerTask(projectPath, issueId, []);
11973
+ const reg = await import_core15.eventSourcing.registerTask(projectPath, issueId, []);
11778
11974
  if (!reg.ok) return reg;
11779
- return await import_core14.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11975
+ return await import_core15.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11780
11976
  } catch (err) {
11781
11977
  return (0, import_types29.Err)(err instanceof Error ? err : new Error(String(err)));
11782
11978
  }
11783
11979
  }
11784
11980
  async function readPersistedLanes(projectPath) {
11785
11981
  try {
11786
- const snap = await import_core14.eventSourcing.readSnapshot(projectPath);
11982
+ const snap = await import_core15.eventSourcing.readSnapshot(projectPath);
11787
11983
  return snap.ok ? snap.value.lanes : { tasks: {} };
11788
11984
  } catch {
11789
11985
  return { tasks: {} };
@@ -13904,7 +14100,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13904
14100
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13905
14101
  }
13906
14102
  }
13907
- this.cacheMetrics = new import_core18.CacheMetricsRecorder();
14103
+ this.cacheMetrics = new import_core19.CacheMetricsRecorder();
13908
14104
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
13909
14105
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
13910
14106
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
@@ -14031,7 +14227,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14031
14227
  const { ranked } = await this.modelRecommender(hardware);
14032
14228
  return ranked.slice(0, top);
14033
14229
  },
14034
- listModelProposals: () => (0, import_core17.listProposals)(this.projectRoot, { status: "open", kind: "model" })
14230
+ listModelProposals: () => (0, import_core18.listProposals)(this.projectRoot, { status: "open", kind: "model" })
14035
14231
  });
14036
14232
  this.server.setRecorder(this.recorder);
14037
14233
  this.interactionQueue.onPush((interaction) => {
@@ -14048,7 +14244,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14048
14244
  setupTelemetryExport(config, webhookStore, webhookDelivery) {
14049
14245
  const otlpCfg = config.telemetry?.export?.otlp;
14050
14246
  if (!otlpCfg) return;
14051
- this.otlpExporter = new import_core18.OTLPExporter({
14247
+ this.otlpExporter = new import_core19.OTLPExporter({
14052
14248
  endpoint: otlpCfg.endpoint,
14053
14249
  ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
14054
14250
  ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
@@ -14151,7 +14347,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14151
14347
  ...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
14152
14348
  ...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
14153
14349
  };
14154
- const clientResult = (0, import_core16.createTrackerClient)(trackerCfg);
14350
+ const clientResult = (0, import_core17.createTrackerClient)(trackerCfg);
14155
14351
  if (!clientResult.ok) throw clientResult.error;
14156
14352
  return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
14157
14353
  }
@@ -14730,12 +14926,12 @@ ${messages}`);
14730
14926
  async postLifecycleComment(identifier, externalId, event) {
14731
14927
  try {
14732
14928
  if (!externalId) return;
14733
- const trackerConfig = (0, import_core16.loadTrackerSyncConfig)(this.projectRoot);
14929
+ const trackerConfig = (0, import_core17.loadTrackerSyncConfig)(this.projectRoot);
14734
14930
  if (!trackerConfig) return;
14735
14931
  const token = process.env.GITHUB_TOKEN;
14736
14932
  if (!token) return;
14737
14933
  const orchestratorId = await this.orchestratorIdPromise;
14738
- const adapter = new import_core16.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14934
+ const adapter = new import_core17.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14739
14935
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
14740
14936
  const actionMap = {
14741
14937
  claimed: "Dispatching agent for autonomous execution",
@@ -14809,7 +15005,7 @@ ${messages}`);
14809
15005
  ...f.line !== void 0 ? { line: f.line } : {}
14810
15006
  }))
14811
15007
  );
14812
- (0, import_core15.writeTaint)(
15008
+ (0, import_core16.writeTaint)(
14813
15009
  workspacePath,
14814
15010
  issue.id,
14815
15011
  "Medium-severity injection patterns found in workspace config files",
@@ -15093,6 +15289,24 @@ ${messages}`);
15093
15289
  });
15094
15290
  this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
15095
15291
  }
15292
+ /**
15293
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15294
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15295
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15296
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15297
+ * or no `pool` block is configured, so it is safe to call unconditionally
15298
+ * on the startup path.
15299
+ */
15300
+ async applyConfiguredPoolBounds() {
15301
+ const pool = this.modelPool;
15302
+ const bounds = this.config.localModels?.pool;
15303
+ if (pool === null || !bounds) return;
15304
+ await pool.configurePool({
15305
+ diskBudgetGb: bounds.diskBudgetGb,
15306
+ allowedOrgs: bounds.allowedOrgs,
15307
+ allowedFamilies: bounds.allowedFamilies
15308
+ });
15309
+ }
15096
15310
  /**
15097
15311
  * LMLM Phase 6: arm the single background refresh scheduler over the live
15098
15312
  * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
@@ -15109,7 +15323,18 @@ ${messages}`);
15109
15323
  if (this.modelPool === null) return;
15110
15324
  const pool = this.modelPool;
15111
15325
  const refreshCfg = this.config.localModels?.refresh;
15112
- const recommend = (0, import_local_models4.createNativeRecommender)({ candidates: [] });
15326
+ const frozen = (0, import_local_models4.loadFrozenCandidates)();
15327
+ const candidates = (0, import_local_models4.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
15328
+ for (const warning of frozen.warnings) {
15329
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15330
+ }
15331
+ if (candidates.length === 0) {
15332
+ this.logger.warn("LMLM recommender has no candidates", {
15333
+ frozenCount: frozen.candidates.length,
15334
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15335
+ });
15336
+ }
15337
+ const recommend = (0, import_local_models4.createNativeRecommender)({ candidates });
15113
15338
  this.modelRecommender = recommend;
15114
15339
  this.refreshScheduler = new import_local_models4.RefreshScheduler({
15115
15340
  runTick: () => (0, import_local_models4.runRefreshTick)({
@@ -15120,7 +15345,7 @@ ${messages}`);
15120
15345
  // Phase 7: after persisting the proposal, emit `local-models:proposal`
15121
15346
  // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15122
15347
  // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15123
- emitProposal: (c) => (0, import_core17.createModelProposal)(this.projectRoot, c).then((record) => {
15348
+ emitProposal: (c) => (0, import_core18.createModelProposal)(this.projectRoot, c).then((record) => {
15124
15349
  this.emit("local-models:proposal", {
15125
15350
  id: record.id,
15126
15351
  status: "created",
@@ -15148,7 +15373,7 @@ ${messages}`);
15148
15373
  }
15149
15374
  /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15150
15375
  async lmlmDedupSource() {
15151
- const proposals = await (0, import_core17.listProposals)(this.projectRoot, { kind: "model" });
15376
+ const proposals = await (0, import_core18.listProposals)(this.projectRoot, { kind: "model" });
15152
15377
  const pending = [];
15153
15378
  const rejected = [];
15154
15379
  for (const p of proposals) {
@@ -15186,6 +15411,7 @@ ${messages}`);
15186
15411
  }
15187
15412
  if (this.poolStateStore !== null) {
15188
15413
  await this.poolStateStore.load();
15414
+ await this.applyConfiguredPoolBounds();
15189
15415
  }
15190
15416
  for (const resolver of this.localResolvers.values()) {
15191
15417
  await resolver.start();
package/dist/index.mjs CHANGED
@@ -3976,9 +3976,11 @@ import {
3976
3976
  HardwareDetector,
3977
3977
  RefreshScheduler,
3978
3978
  runRefreshTick,
3979
- createNativeRecommender
3979
+ createNativeRecommender,
3980
+ loadFrozenCandidates,
3981
+ selectCandidates as selectCandidates2
3980
3982
  } from "@harness-engineering/local-models";
3981
- import { createModelProposal, listProposals as listProposals2 } from "@harness-engineering/core";
3983
+ import { createModelProposal as createModelProposal2, listProposals as listProposals2 } from "@harness-engineering/core";
3982
3984
 
3983
3985
  // src/agent/config-migration.ts
3984
3986
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
@@ -9139,17 +9141,187 @@ async function runForceRefresh(res, scheduler, deps) {
9139
9141
  });
9140
9142
  }
9141
9143
 
9144
+ // src/server/routes/v1/local-models-pool-mutation.ts
9145
+ import { createModelProposal, updateProposal as updateProposal4 } from "@harness-engineering/core";
9146
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9147
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9148
+ var RESOLVE_TOP = 50;
9149
+ function sendJSON10(res, status, body) {
9150
+ res.writeHead(status, { "Content-Type": "application/json" });
9151
+ res.end(JSON.stringify(body));
9152
+ }
9153
+ function decidedByOf(deps, req) {
9154
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9155
+ const token = req._authToken;
9156
+ return token?.id ?? "operator";
9157
+ }
9158
+ function handlerDeps(deps, req, pool) {
9159
+ return {
9160
+ pool,
9161
+ bus: deps.bus,
9162
+ updateProposal: (id, patch) => updateProposal4(deps.projectPath, id, patch),
9163
+ decidedBy: decidedByOf(deps, req),
9164
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9165
+ };
9166
+ }
9167
+ async function readJsonBody(req) {
9168
+ try {
9169
+ const raw = await readBody(req);
9170
+ if (raw.length === 0) return {};
9171
+ const parsed = JSON.parse(raw);
9172
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9173
+ } catch {
9174
+ return void 0;
9175
+ }
9176
+ }
9177
+ async function handleInstall(req, res, deps) {
9178
+ const pool = deps.getModelPool();
9179
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9180
+ const body = await readJsonBody(req);
9181
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9182
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9183
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9184
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9185
+ if (!deps.getRecommendations) {
9186
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9187
+ }
9188
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9189
+ const match = ranked.find(
9190
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9191
+ );
9192
+ if (!match) {
9193
+ return sendJSON10(res, 404, {
9194
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9195
+ });
9196
+ }
9197
+ if (!match.ollamaName) {
9198
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9199
+ }
9200
+ const content = {
9201
+ action: "add",
9202
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9203
+ scoreDelta: match.score,
9204
+ justification: {
9205
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9206
+ benchmarkBasis: [],
9207
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9208
+ evidence: match.evidence,
9209
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9210
+ },
9211
+ // 0 → the installer resolves the true on-disk size via `inspect` before the
9212
+ // budget check, rather than trusting an estimate.
9213
+ diskImpactGb: 0
9214
+ };
9215
+ const record = await createModelProposal(deps.projectPath, content, {
9216
+ proposedBy: decidedByOf(deps, req)
9217
+ });
9218
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9219
+ if (outcome.status === "approved") {
9220
+ const result = {
9221
+ disposition: "installed",
9222
+ proposalId: record.id,
9223
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9224
+ };
9225
+ return sendJSON10(res, 200, result);
9226
+ }
9227
+ if (outcome.status === "failed_target_missing") {
9228
+ return sendJSON10(res, 404, {
9229
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9230
+ proposalId: record.id
9231
+ });
9232
+ }
9233
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9234
+ return sendJSON10(res, status, {
9235
+ error: outcome.message,
9236
+ code: outcome.code,
9237
+ proposalId: record.id
9238
+ });
9239
+ }
9240
+ async function handleRemove(req, res, deps) {
9241
+ const pool = deps.getModelPool();
9242
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9243
+ const body = await readJsonBody(req);
9244
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9245
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9246
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9247
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9248
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9249
+ const content = {
9250
+ action: "evict",
9251
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9252
+ scoreDelta: 0,
9253
+ justification: {
9254
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9255
+ benchmarkBasis: [],
9256
+ hardwareFit: "",
9257
+ evidence: "operator",
9258
+ freshness: ""
9259
+ },
9260
+ diskImpactGb: entry.sizeOnDiskGb
9261
+ };
9262
+ const record = await createModelProposal(deps.projectPath, content, {
9263
+ proposedBy: decidedByOf(deps, req)
9264
+ });
9265
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9266
+ if (outcome.status === "error") {
9267
+ return sendJSON10(res, 502, {
9268
+ error: outcome.message,
9269
+ code: outcome.code,
9270
+ proposalId: record.id
9271
+ });
9272
+ }
9273
+ if (outcome.status === "failed_target_missing") {
9274
+ return sendJSON10(res, 500, {
9275
+ error: "unexpected failed_target_missing on evict",
9276
+ proposalId: record.id
9277
+ });
9278
+ }
9279
+ if (outcome.evicted.length > 0) {
9280
+ const result2 = {
9281
+ disposition: "removed",
9282
+ proposalId: record.id,
9283
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9284
+ };
9285
+ return sendJSON10(res, 200, result2);
9286
+ }
9287
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9288
+ if (stillPresent) {
9289
+ const result2 = {
9290
+ disposition: "deferred",
9291
+ proposalId: record.id,
9292
+ evicted: [],
9293
+ message: "model is in use; it will be removed after the current run"
9294
+ };
9295
+ return sendJSON10(res, 202, result2);
9296
+ }
9297
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9298
+ return sendJSON10(res, 200, result);
9299
+ }
9300
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9301
+ const url = req.url ?? "";
9302
+ if ((req.method ?? "GET") !== "POST") return false;
9303
+ if (INSTALL_RE.test(url)) {
9304
+ void handleInstall(req, res, deps);
9305
+ return true;
9306
+ }
9307
+ if (REMOVE_RE.test(url)) {
9308
+ void handleRemove(req, res, deps);
9309
+ return true;
9310
+ }
9311
+ return false;
9312
+ }
9313
+
9142
9314
  // src/server/routes/v1/routing.ts
9143
9315
  import { z as z14 } from "zod";
9144
9316
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
9145
9317
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
9146
9318
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
9147
- function sendJSON10(res, status, body) {
9319
+ function sendJSON11(res, status, body) {
9148
9320
  res.writeHead(status, { "Content-Type": "application/json" });
9149
9321
  res.end(JSON.stringify(body));
9150
9322
  }
9151
9323
  function unavailable(res) {
9152
- sendJSON10(res, 503, { error: "BackendRouter not available" });
9324
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
9153
9325
  return true;
9154
9326
  }
9155
9327
  function resolveChain(value, backends) {
@@ -9186,7 +9358,7 @@ function buildResolvedChains(routing, backends) {
9186
9358
  }
9187
9359
  function handleConfig(res, deps) {
9188
9360
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
9189
- sendJSON10(res, 200, {
9361
+ sendJSON11(res, 200, {
9190
9362
  routing: deps.routing,
9191
9363
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
9192
9364
  backends: Object.keys(deps.backends)
@@ -9214,7 +9386,7 @@ function parseDecisionsQuery(url) {
9214
9386
  function handleDecisions(req, res, deps) {
9215
9387
  if (!deps.bus) return unavailable(res);
9216
9388
  const filter = parseDecisionsQuery(req.url ?? "");
9217
- sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
9389
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
9218
9390
  return true;
9219
9391
  }
9220
9392
  var UseCaseSchema = z14.discriminatedUnion("kind", [
@@ -9246,19 +9418,19 @@ async function handleTrace(req, res, deps) {
9246
9418
  try {
9247
9419
  raw = await readBody(req);
9248
9420
  } catch {
9249
- sendJSON10(res, 400, { error: "body read failed" });
9421
+ sendJSON11(res, 400, { error: "body read failed" });
9250
9422
  return true;
9251
9423
  }
9252
9424
  let parsed;
9253
9425
  try {
9254
9426
  parsed = JSON.parse(raw);
9255
9427
  } catch {
9256
- sendJSON10(res, 400, { error: "invalid JSON body" });
9428
+ sendJSON11(res, 400, { error: "invalid JSON body" });
9257
9429
  return true;
9258
9430
  }
9259
9431
  const r = TraceBodySchema.safeParse(parsed);
9260
9432
  if (!r.success) {
9261
- sendJSON10(res, 400, { error: r.error.message });
9433
+ sendJSON11(res, 400, { error: r.error.message });
9262
9434
  return true;
9263
9435
  }
9264
9436
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -9271,9 +9443,9 @@ async function handleTrace(req, res, deps) {
9271
9443
  r.data.useCase,
9272
9444
  opts
9273
9445
  );
9274
- sendJSON10(res, 200, { decision, def: { type: def.type } });
9446
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
9275
9447
  } catch (err) {
9276
- sendJSON10(res, 500, { error: String(err) });
9448
+ sendJSON11(res, 500, { error: String(err) });
9277
9449
  }
9278
9450
  return true;
9279
9451
  }
@@ -9514,7 +9686,7 @@ var CreateBodySchema = z16.object({
9514
9686
  tenantId: z16.string().optional(),
9515
9687
  expiresAt: z16.string().datetime().optional()
9516
9688
  });
9517
- function sendJSON11(res, status, body) {
9689
+ function sendJSON12(res, status, body) {
9518
9690
  res.writeHead(status, { "Content-Type": "application/json" });
9519
9691
  res.end(JSON.stringify(body));
9520
9692
  }
@@ -9524,19 +9696,19 @@ async function handlePost(req, res, store) {
9524
9696
  raw = await readBody(req);
9525
9697
  } catch (err) {
9526
9698
  const msg = err instanceof Error ? err.message : "Failed to read body";
9527
- sendJSON11(res, 413, { error: msg });
9699
+ sendJSON12(res, 413, { error: msg });
9528
9700
  return;
9529
9701
  }
9530
9702
  let json;
9531
9703
  try {
9532
9704
  json = JSON.parse(raw);
9533
9705
  } catch {
9534
- sendJSON11(res, 400, { error: "Invalid JSON body" });
9706
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9535
9707
  return;
9536
9708
  }
9537
9709
  const parsed = CreateBodySchema.safeParse(json);
9538
9710
  if (!parsed.success) {
9539
- sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9711
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9540
9712
  return;
9541
9713
  }
9542
9714
  try {
@@ -9549,37 +9721,37 @@ async function handlePost(req, res, store) {
9549
9721
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9550
9722
  const result = await store.create(input);
9551
9723
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
9552
- sendJSON11(res, 200, {
9724
+ sendJSON12(res, 200, {
9553
9725
  ...publicRecord,
9554
9726
  token: result.token
9555
9727
  });
9556
9728
  } catch (err) {
9557
9729
  const msg = err instanceof Error ? err.message : "Failed to create token";
9558
9730
  if (msg.includes("already exists")) {
9559
- sendJSON11(res, 409, { error: msg });
9731
+ sendJSON12(res, 409, { error: msg });
9560
9732
  return;
9561
9733
  }
9562
- sendJSON11(res, 500, { error: "Internal error creating token" });
9734
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9563
9735
  }
9564
9736
  }
9565
9737
  async function handleList3(res, store) {
9566
9738
  try {
9567
9739
  const list = await store.list();
9568
- sendJSON11(res, 200, list);
9740
+ sendJSON12(res, 200, list);
9569
9741
  } catch {
9570
- sendJSON11(res, 500, { error: "Internal error listing tokens" });
9742
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9571
9743
  }
9572
9744
  }
9573
9745
  async function handleDelete2(res, store, id) {
9574
9746
  try {
9575
9747
  const ok = await store.revoke(id);
9576
9748
  if (!ok) {
9577
- sendJSON11(res, 404, { error: "Token not found" });
9749
+ sendJSON12(res, 404, { error: "Token not found" });
9578
9750
  return;
9579
9751
  }
9580
- sendJSON11(res, 200, { deleted: true });
9752
+ sendJSON12(res, 200, { deleted: true });
9581
9753
  } catch {
9582
- sendJSON11(res, 500, { error: "Internal error revoking token" });
9754
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9583
9755
  }
9584
9756
  }
9585
9757
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9604,12 +9776,12 @@ function handleAuthRoute(req, res, store) {
9604
9776
  return true;
9605
9777
  }
9606
9778
  }
9607
- sendJSON11(res, 405, { error: "Method not allowed" });
9779
+ sendJSON12(res, 405, { error: "Method not allowed" });
9608
9780
  return true;
9609
9781
  }
9610
9782
 
9611
9783
  // src/server/routes/local-model.ts
9612
- function sendJSON12(res, status, body) {
9784
+ function sendJSON13(res, status, body) {
9613
9785
  res.writeHead(status, { "Content-Type": "application/json" });
9614
9786
  res.end(JSON.stringify(body));
9615
9787
  }
@@ -9617,30 +9789,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9617
9789
  const { method, url } = req;
9618
9790
  if (url !== "/api/v1/local-model/status") return false;
9619
9791
  if (method !== "GET") {
9620
- sendJSON12(res, 405, { error: "Method not allowed" });
9792
+ sendJSON13(res, 405, { error: "Method not allowed" });
9621
9793
  return true;
9622
9794
  }
9623
9795
  if (!getStatus) {
9624
- sendJSON12(res, 503, { error: "Local backend not configured" });
9796
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9625
9797
  return true;
9626
9798
  }
9627
9799
  const status = getStatus();
9628
9800
  if (!status) {
9629
- sendJSON12(res, 503, { error: "Local backend not configured" });
9801
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9630
9802
  return true;
9631
9803
  }
9632
- sendJSON12(res, 200, status);
9804
+ sendJSON13(res, 200, status);
9633
9805
  return true;
9634
9806
  }
9635
9807
  function handleLocalModelsRoute(req, res, getStatuses) {
9636
9808
  const { method, url } = req;
9637
9809
  if (url !== "/api/v1/local-models/status") return false;
9638
9810
  if (method !== "GET") {
9639
- sendJSON12(res, 405, { error: "Method not allowed" });
9811
+ sendJSON13(res, 405, { error: "Method not allowed" });
9640
9812
  return true;
9641
9813
  }
9642
9814
  const statuses = getStatuses ? getStatuses() : [];
9643
- sendJSON12(res, 200, statuses);
9815
+ sendJSON13(res, 200, statuses);
9644
9816
  return true;
9645
9817
  }
9646
9818
 
@@ -10016,6 +10188,21 @@ var V1_BRIDGE_ROUTES = [
10016
10188
  scope: "manage-proposals",
10017
10189
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10018
10190
  },
10191
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10192
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10193
+ // write scope that governs approve/reject governs these too.
10194
+ {
10195
+ method: "POST",
10196
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10197
+ scope: "manage-proposals",
10198
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10199
+ },
10200
+ {
10201
+ method: "POST",
10202
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10203
+ scope: "manage-proposals",
10204
+ description: "Remove a model from the local pool (auto-approved proposal)."
10205
+ },
10019
10206
  // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10020
10207
  // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10021
10208
  // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
@@ -10450,6 +10637,17 @@ var OrchestratorServer = class {
10450
10637
  ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10451
10638
  });
10452
10639
  },
10640
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10641
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10642
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10643
+ // Registered before the read surface so /pool/install|remove match first.
10644
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
10645
+ projectPath: this.projectPath,
10646
+ bus: this.orchestrator,
10647
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10648
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10649
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10650
+ }),
10453
10651
  // LMLM Phase 6/7 — POST /refresh + the GET read surface
10454
10652
  // (hardware/pool/recommendations/proposals). Registered before the
10455
10653
  // chat-proxy fallback so it owns the path. Each accessor is spread in
@@ -15061,6 +15259,24 @@ ${messages}`);
15061
15259
  });
15062
15260
  this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
15063
15261
  }
15262
+ /**
15263
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15264
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15265
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15266
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15267
+ * or no `pool` block is configured, so it is safe to call unconditionally
15268
+ * on the startup path.
15269
+ */
15270
+ async applyConfiguredPoolBounds() {
15271
+ const pool = this.modelPool;
15272
+ const bounds = this.config.localModels?.pool;
15273
+ if (pool === null || !bounds) return;
15274
+ await pool.configurePool({
15275
+ diskBudgetGb: bounds.diskBudgetGb,
15276
+ allowedOrgs: bounds.allowedOrgs,
15277
+ allowedFamilies: bounds.allowedFamilies
15278
+ });
15279
+ }
15064
15280
  /**
15065
15281
  * LMLM Phase 6: arm the single background refresh scheduler over the live
15066
15282
  * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
@@ -15077,7 +15293,18 @@ ${messages}`);
15077
15293
  if (this.modelPool === null) return;
15078
15294
  const pool = this.modelPool;
15079
15295
  const refreshCfg = this.config.localModels?.refresh;
15080
- const recommend = createNativeRecommender({ candidates: [] });
15296
+ const frozen = loadFrozenCandidates();
15297
+ const candidates = selectCandidates2(frozen.candidates, this.config.localModels?.pool);
15298
+ for (const warning of frozen.warnings) {
15299
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15300
+ }
15301
+ if (candidates.length === 0) {
15302
+ this.logger.warn("LMLM recommender has no candidates", {
15303
+ frozenCount: frozen.candidates.length,
15304
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15305
+ });
15306
+ }
15307
+ const recommend = createNativeRecommender({ candidates });
15081
15308
  this.modelRecommender = recommend;
15082
15309
  this.refreshScheduler = new RefreshScheduler({
15083
15310
  runTick: () => runRefreshTick({
@@ -15088,7 +15315,7 @@ ${messages}`);
15088
15315
  // Phase 7: after persisting the proposal, emit `local-models:proposal`
15089
15316
  // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15090
15317
  // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15091
- emitProposal: (c) => createModelProposal(this.projectRoot, c).then((record) => {
15318
+ emitProposal: (c) => createModelProposal2(this.projectRoot, c).then((record) => {
15092
15319
  this.emit("local-models:proposal", {
15093
15320
  id: record.id,
15094
15321
  status: "created",
@@ -15154,6 +15381,7 @@ ${messages}`);
15154
15381
  }
15155
15382
  if (this.poolStateStore !== null) {
15156
15383
  await this.poolStateStore.load();
15384
+ await this.applyConfiguredPoolBounds();
15157
15385
  }
15158
15386
  for (const resolver of this.localResolvers.values()) {
15159
15387
  await resolver.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-engineering/orchestrator",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Orchestrator daemon for dispatching coding agents to issues",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -37,22 +37,22 @@
37
37
  "homepage": "https://github.com/Intense-Visions/harness-engineering/tree/main/packages/orchestrator#readme",
38
38
  "dependencies": {
39
39
  "@anthropic-ai/sdk": "^0.95.1",
40
- "@earendil-works/pi-coding-agent": "^0.74.1",
40
+ "@earendil-works/pi-coding-agent": "^0.79.0",
41
41
  "@google/genai": "^2.0.4",
42
42
  "bcryptjs": "^2.4.3",
43
43
  "better-sqlite3": "^12.10.0",
44
44
  "ink": "^4.4.1",
45
- "liquidjs": "^10.25.7",
45
+ "liquidjs": "^10.26.0",
46
46
  "openai": "^6.0.0",
47
47
  "react": "^18.3.1",
48
48
  "ws": "^8.21.0",
49
49
  "yaml": "^2.8.3",
50
50
  "zod": "^3.25.76",
51
- "@harness-engineering/core": "0.34.0",
52
- "@harness-engineering/graph": "0.11.4",
53
- "@harness-engineering/intelligence": "0.4.2",
54
- "@harness-engineering/local-models": "0.3.0",
55
- "@harness-engineering/types": "0.18.0"
51
+ "@harness-engineering/core": "0.34.1",
52
+ "@harness-engineering/graph": "0.11.5",
53
+ "@harness-engineering/intelligence": "0.4.3",
54
+ "@harness-engineering/local-models": "0.4.0",
55
+ "@harness-engineering/types": "0.19.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@asteasolutions/zod-to-openapi": "^7.3.0",