@harness-engineering/orchestrator 0.10.0 → 0.11.2

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");
@@ -4084,8 +4084,8 @@ var LocalModelResolver = class {
4084
4084
  };
4085
4085
 
4086
4086
  // src/orchestrator.ts
4087
- var import_local_models4 = require("@harness-engineering/local-models");
4088
- var import_core17 = require("@harness-engineering/core");
4087
+ var import_local_models5 = require("@harness-engineering/local-models");
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,196 @@ 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 import_local_models3 = require("@harness-engineering/local-models");
9192
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9193
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9194
+ var RESOLVE_TOP = 50;
9195
+ function sendJSON10(res, status, body) {
9196
+ res.writeHead(status, { "Content-Type": "application/json" });
9197
+ res.end(JSON.stringify(body));
9198
+ }
9199
+ function decidedByOf(deps, req) {
9200
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9201
+ const token = req._authToken;
9202
+ return token?.id ?? "operator";
9203
+ }
9204
+ function handlerDeps(deps, req, pool) {
9205
+ return {
9206
+ pool,
9207
+ bus: deps.bus,
9208
+ updateProposal: (id, patch) => (0, import_core11.updateProposal)(deps.projectPath, id, patch),
9209
+ decidedBy: decidedByOf(deps, req),
9210
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9211
+ };
9212
+ }
9213
+ async function readJsonBody(req) {
9214
+ try {
9215
+ const raw = await readBody(req);
9216
+ if (raw.length === 0) return {};
9217
+ const parsed = JSON.parse(raw);
9218
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9219
+ } catch {
9220
+ return void 0;
9221
+ }
9222
+ }
9223
+ async function handleInstall(req, res, deps) {
9224
+ const pool = deps.getModelPool();
9225
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9226
+ const body = await readJsonBody(req);
9227
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9228
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9229
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9230
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9231
+ if (!deps.getRecommendations) {
9232
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9233
+ }
9234
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9235
+ const match = ranked.find(
9236
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9237
+ );
9238
+ if (!match) {
9239
+ return sendJSON10(res, 404, {
9240
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9241
+ });
9242
+ }
9243
+ if (!match.ollamaName) {
9244
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9245
+ }
9246
+ const content = {
9247
+ action: "add",
9248
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9249
+ scoreDelta: match.score,
9250
+ justification: {
9251
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9252
+ benchmarkBasis: [],
9253
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9254
+ evidence: match.evidence,
9255
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9256
+ },
9257
+ // Estimate the on-disk size from the candidate's params + quant (the same
9258
+ // `estimateDiskGb` the automated proposal engine uses). The pool needs a
9259
+ // size for its pre-commit budget check *before* the pull; it cannot inspect
9260
+ // the target first, because ollama `/api/show` 404s for a model that is not
9261
+ // yet pulled locally — which would surface as a spurious "no longer
9262
+ // available on HuggingFace" 404 on every operator install.
9263
+ diskImpactGb: (0, import_local_models3.estimateDiskGb)({
9264
+ sizeB: match.sizeB,
9265
+ quant: match.quant,
9266
+ ...match.activeB !== void 0 ? { activeB: match.activeB } : {}
9267
+ })
9268
+ };
9269
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9270
+ proposedBy: decidedByOf(deps, req)
9271
+ });
9272
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9273
+ if (outcome.status === "approved") {
9274
+ const result = {
9275
+ disposition: "installed",
9276
+ proposalId: record.id,
9277
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9278
+ };
9279
+ return sendJSON10(res, 200, result);
9280
+ }
9281
+ if (outcome.status === "failed_target_missing") {
9282
+ return sendJSON10(res, 404, {
9283
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9284
+ proposalId: record.id
9285
+ });
9286
+ }
9287
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9288
+ return sendJSON10(res, status, {
9289
+ error: outcome.message,
9290
+ code: outcome.code,
9291
+ proposalId: record.id
9292
+ });
9293
+ }
9294
+ async function handleRemove(req, res, deps) {
9295
+ const pool = deps.getModelPool();
9296
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9297
+ const body = await readJsonBody(req);
9298
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9299
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9300
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9301
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9302
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9303
+ const content = {
9304
+ action: "evict",
9305
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9306
+ scoreDelta: 0,
9307
+ justification: {
9308
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9309
+ benchmarkBasis: [],
9310
+ hardwareFit: "",
9311
+ evidence: "operator",
9312
+ freshness: ""
9313
+ },
9314
+ diskImpactGb: entry.sizeOnDiskGb
9315
+ };
9316
+ const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9317
+ proposedBy: decidedByOf(deps, req)
9318
+ });
9319
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9320
+ if (outcome.status === "error") {
9321
+ return sendJSON10(res, 502, {
9322
+ error: outcome.message,
9323
+ code: outcome.code,
9324
+ proposalId: record.id
9325
+ });
9326
+ }
9327
+ if (outcome.status === "failed_target_missing") {
9328
+ return sendJSON10(res, 500, {
9329
+ error: "unexpected failed_target_missing on evict",
9330
+ proposalId: record.id
9331
+ });
9332
+ }
9333
+ if (outcome.evicted.length > 0) {
9334
+ const result2 = {
9335
+ disposition: "removed",
9336
+ proposalId: record.id,
9337
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9338
+ };
9339
+ return sendJSON10(res, 200, result2);
9340
+ }
9341
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9342
+ if (stillPresent) {
9343
+ const result2 = {
9344
+ disposition: "deferred",
9345
+ proposalId: record.id,
9346
+ evicted: [],
9347
+ message: "model is in use; it will be removed after the current run"
9348
+ };
9349
+ return sendJSON10(res, 202, result2);
9350
+ }
9351
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9352
+ return sendJSON10(res, 200, result);
9353
+ }
9354
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9355
+ const url = req.url ?? "";
9356
+ if ((req.method ?? "GET") !== "POST") return false;
9357
+ if (INSTALL_RE.test(url)) {
9358
+ void handleInstall(req, res, deps);
9359
+ return true;
9360
+ }
9361
+ if (REMOVE_RE.test(url)) {
9362
+ void handleRemove(req, res, deps);
9363
+ return true;
9364
+ }
9365
+ return false;
9366
+ }
9367
+
9189
9368
  // src/server/routes/v1/routing.ts
9190
9369
  var import_zod14 = require("zod");
9191
9370
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
9192
9371
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
9193
9372
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
9194
- function sendJSON10(res, status, body) {
9373
+ function sendJSON11(res, status, body) {
9195
9374
  res.writeHead(status, { "Content-Type": "application/json" });
9196
9375
  res.end(JSON.stringify(body));
9197
9376
  }
9198
9377
  function unavailable(res) {
9199
- sendJSON10(res, 503, { error: "BackendRouter not available" });
9378
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
9200
9379
  return true;
9201
9380
  }
9202
9381
  function resolveChain(value, backends) {
@@ -9233,7 +9412,7 @@ function buildResolvedChains(routing, backends) {
9233
9412
  }
9234
9413
  function handleConfig(res, deps) {
9235
9414
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
9236
- sendJSON10(res, 200, {
9415
+ sendJSON11(res, 200, {
9237
9416
  routing: deps.routing,
9238
9417
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
9239
9418
  backends: Object.keys(deps.backends)
@@ -9261,7 +9440,7 @@ function parseDecisionsQuery(url) {
9261
9440
  function handleDecisions(req, res, deps) {
9262
9441
  if (!deps.bus) return unavailable(res);
9263
9442
  const filter = parseDecisionsQuery(req.url ?? "");
9264
- sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
9443
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
9265
9444
  return true;
9266
9445
  }
9267
9446
  var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
@@ -9293,19 +9472,19 @@ async function handleTrace(req, res, deps) {
9293
9472
  try {
9294
9473
  raw = await readBody(req);
9295
9474
  } catch {
9296
- sendJSON10(res, 400, { error: "body read failed" });
9475
+ sendJSON11(res, 400, { error: "body read failed" });
9297
9476
  return true;
9298
9477
  }
9299
9478
  let parsed;
9300
9479
  try {
9301
9480
  parsed = JSON.parse(raw);
9302
9481
  } catch {
9303
- sendJSON10(res, 400, { error: "invalid JSON body" });
9482
+ sendJSON11(res, 400, { error: "invalid JSON body" });
9304
9483
  return true;
9305
9484
  }
9306
9485
  const r = TraceBodySchema.safeParse(parsed);
9307
9486
  if (!r.success) {
9308
- sendJSON10(res, 400, { error: r.error.message });
9487
+ sendJSON11(res, 400, { error: r.error.message });
9309
9488
  return true;
9310
9489
  }
9311
9490
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -9318,9 +9497,9 @@ async function handleTrace(req, res, deps) {
9318
9497
  r.data.useCase,
9319
9498
  opts
9320
9499
  );
9321
- sendJSON10(res, 200, { decision, def: { type: def.type } });
9500
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
9322
9501
  } catch (err) {
9323
- sendJSON10(res, 500, { error: String(err) });
9502
+ sendJSON11(res, 500, { error: String(err) });
9324
9503
  }
9325
9504
  return true;
9326
9505
  }
@@ -9557,7 +9736,7 @@ var CreateBodySchema = import_zod16.z.object({
9557
9736
  tenantId: import_zod16.z.string().optional(),
9558
9737
  expiresAt: import_zod16.z.string().datetime().optional()
9559
9738
  });
9560
- function sendJSON11(res, status, body) {
9739
+ function sendJSON12(res, status, body) {
9561
9740
  res.writeHead(status, { "Content-Type": "application/json" });
9562
9741
  res.end(JSON.stringify(body));
9563
9742
  }
@@ -9567,19 +9746,19 @@ async function handlePost(req, res, store) {
9567
9746
  raw = await readBody(req);
9568
9747
  } catch (err) {
9569
9748
  const msg = err instanceof Error ? err.message : "Failed to read body";
9570
- sendJSON11(res, 413, { error: msg });
9749
+ sendJSON12(res, 413, { error: msg });
9571
9750
  return;
9572
9751
  }
9573
9752
  let json;
9574
9753
  try {
9575
9754
  json = JSON.parse(raw);
9576
9755
  } catch {
9577
- sendJSON11(res, 400, { error: "Invalid JSON body" });
9756
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9578
9757
  return;
9579
9758
  }
9580
9759
  const parsed = CreateBodySchema.safeParse(json);
9581
9760
  if (!parsed.success) {
9582
- sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9761
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9583
9762
  return;
9584
9763
  }
9585
9764
  try {
@@ -9592,37 +9771,37 @@ async function handlePost(req, res, store) {
9592
9771
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9593
9772
  const result = await store.create(input);
9594
9773
  const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
9595
- sendJSON11(res, 200, {
9774
+ sendJSON12(res, 200, {
9596
9775
  ...publicRecord,
9597
9776
  token: result.token
9598
9777
  });
9599
9778
  } catch (err) {
9600
9779
  const msg = err instanceof Error ? err.message : "Failed to create token";
9601
9780
  if (msg.includes("already exists")) {
9602
- sendJSON11(res, 409, { error: msg });
9781
+ sendJSON12(res, 409, { error: msg });
9603
9782
  return;
9604
9783
  }
9605
- sendJSON11(res, 500, { error: "Internal error creating token" });
9784
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9606
9785
  }
9607
9786
  }
9608
9787
  async function handleList3(res, store) {
9609
9788
  try {
9610
9789
  const list = await store.list();
9611
- sendJSON11(res, 200, list);
9790
+ sendJSON12(res, 200, list);
9612
9791
  } catch {
9613
- sendJSON11(res, 500, { error: "Internal error listing tokens" });
9792
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9614
9793
  }
9615
9794
  }
9616
9795
  async function handleDelete2(res, store, id) {
9617
9796
  try {
9618
9797
  const ok = await store.revoke(id);
9619
9798
  if (!ok) {
9620
- sendJSON11(res, 404, { error: "Token not found" });
9799
+ sendJSON12(res, 404, { error: "Token not found" });
9621
9800
  return;
9622
9801
  }
9623
- sendJSON11(res, 200, { deleted: true });
9802
+ sendJSON12(res, 200, { deleted: true });
9624
9803
  } catch {
9625
- sendJSON11(res, 500, { error: "Internal error revoking token" });
9804
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9626
9805
  }
9627
9806
  }
9628
9807
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9647,12 +9826,12 @@ function handleAuthRoute(req, res, store) {
9647
9826
  return true;
9648
9827
  }
9649
9828
  }
9650
- sendJSON11(res, 405, { error: "Method not allowed" });
9829
+ sendJSON12(res, 405, { error: "Method not allowed" });
9651
9830
  return true;
9652
9831
  }
9653
9832
 
9654
9833
  // src/server/routes/local-model.ts
9655
- function sendJSON12(res, status, body) {
9834
+ function sendJSON13(res, status, body) {
9656
9835
  res.writeHead(status, { "Content-Type": "application/json" });
9657
9836
  res.end(JSON.stringify(body));
9658
9837
  }
@@ -9660,30 +9839,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9660
9839
  const { method, url } = req;
9661
9840
  if (url !== "/api/v1/local-model/status") return false;
9662
9841
  if (method !== "GET") {
9663
- sendJSON12(res, 405, { error: "Method not allowed" });
9842
+ sendJSON13(res, 405, { error: "Method not allowed" });
9664
9843
  return true;
9665
9844
  }
9666
9845
  if (!getStatus) {
9667
- sendJSON12(res, 503, { error: "Local backend not configured" });
9846
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9668
9847
  return true;
9669
9848
  }
9670
9849
  const status = getStatus();
9671
9850
  if (!status) {
9672
- sendJSON12(res, 503, { error: "Local backend not configured" });
9851
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9673
9852
  return true;
9674
9853
  }
9675
- sendJSON12(res, 200, status);
9854
+ sendJSON13(res, 200, status);
9676
9855
  return true;
9677
9856
  }
9678
9857
  function handleLocalModelsRoute(req, res, getStatuses) {
9679
9858
  const { method, url } = req;
9680
9859
  if (url !== "/api/v1/local-models/status") return false;
9681
9860
  if (method !== "GET") {
9682
- sendJSON12(res, 405, { error: "Method not allowed" });
9861
+ sendJSON13(res, 405, { error: "Method not allowed" });
9683
9862
  return true;
9684
9863
  }
9685
9864
  const statuses = getStatuses ? getStatuses() : [];
9686
- sendJSON12(res, 200, statuses);
9865
+ sendJSON13(res, 200, statuses);
9687
9866
  return true;
9688
9867
  }
9689
9868
 
@@ -10056,6 +10235,21 @@ var V1_BRIDGE_ROUTES = [
10056
10235
  scope: "manage-proposals",
10057
10236
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10058
10237
  },
10238
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10239
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10240
+ // write scope that governs approve/reject governs these too.
10241
+ {
10242
+ method: "POST",
10243
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10244
+ scope: "manage-proposals",
10245
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10246
+ },
10247
+ {
10248
+ method: "POST",
10249
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10250
+ scope: "manage-proposals",
10251
+ description: "Remove a model from the local pool (auto-approved proposal)."
10252
+ },
10059
10253
  // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10060
10254
  // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10061
10255
  // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
@@ -10490,6 +10684,17 @@ var OrchestratorServer = class {
10490
10684
  ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10491
10685
  });
10492
10686
  },
10687
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10688
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10689
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10690
+ // Registered before the read surface so /pool/install|remove match first.
10691
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
10692
+ projectPath: this.projectPath,
10693
+ bus: this.orchestrator,
10694
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10695
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10696
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10697
+ }),
10493
10698
  // LMLM Phase 6/7 — POST /refresh + the GET read surface
10494
10699
  // (hardware/pool/recommendations/proposals). Registered before the
10495
10700
  // chat-proxy fallback so it owns the path. Each accessor is spread in
@@ -10583,7 +10788,7 @@ var OrchestratorServer = class {
10583
10788
  return this.broadcaster.clientCount;
10584
10789
  }
10585
10790
  async start() {
10586
- (0, import_core11.assertPortUsable)(this.port, "orchestrator");
10791
+ (0, import_core12.assertPortUsable)(this.port, "orchestrator");
10587
10792
  if (this.interactionQueue) {
10588
10793
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
10589
10794
  this.planWatcher.start();
@@ -11085,7 +11290,7 @@ function wireWebhookFanout({ bus, store, delivery }) {
11085
11290
 
11086
11291
  // src/gateway/telemetry/fanout.ts
11087
11292
  var import_node_crypto13 = require("crypto");
11088
- var import_core12 = require("@harness-engineering/core");
11293
+ var import_core13 = require("@harness-engineering/core");
11089
11294
  var TOPICS = {
11090
11295
  MAINTENANCE_STARTED: "maintenance:started",
11091
11296
  MAINTENANCE_COMPLETED: "maintenance:completed",
@@ -11217,7 +11422,7 @@ function buildSpan(topic, plan, payload) {
11217
11422
  spanId: plan.spanId,
11218
11423
  ...plan.parentSpanId !== void 0 ? { parentSpanId: plan.parentSpanId } : {},
11219
11424
  name: SPAN_NAME[topic],
11220
- kind: import_core12.SpanKind.INTERNAL,
11425
+ kind: import_core13.SpanKind.INTERNAL,
11221
11426
  startTimeNs: startNs,
11222
11427
  endTimeNs: startNs,
11223
11428
  attributes: buildAttributes(payload, { "harness.topic": topic }),
@@ -11674,7 +11879,7 @@ function wireNotificationSinks({ bus, registry }) {
11674
11879
  }
11675
11880
 
11676
11881
  // src/orchestrator.ts
11677
- var import_core18 = require("@harness-engineering/core");
11882
+ var import_core19 = require("@harness-engineering/core");
11678
11883
 
11679
11884
  // src/logging/logger.ts
11680
11885
  var StructuredLogger = class {
@@ -11716,7 +11921,7 @@ var StructuredLogger = class {
11716
11921
  // src/workspace/config-scanner.ts
11717
11922
  var import_node_fs = require("fs");
11718
11923
  var import_node_path4 = require("path");
11719
- var import_core13 = require("@harness-engineering/core");
11924
+ var import_core14 = require("@harness-engineering/core");
11720
11925
  var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
11721
11926
  var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
11722
11927
  var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
@@ -11738,29 +11943,29 @@ async function scanSingleFile(filePath, targetDir, scanner) {
11738
11943
  } catch {
11739
11944
  return null;
11740
11945
  }
11741
- const injectionFindings = (0, import_core13.scanForInjection)(content);
11742
- const findings = (0, import_core13.mapInjectionFindings)(injectionFindings);
11946
+ const injectionFindings = (0, import_core14.scanForInjection)(content);
11947
+ const findings = (0, import_core14.mapInjectionFindings)(injectionFindings);
11743
11948
  const secFindings = await scanner.scanFile(filePath);
11744
- findings.push(...(0, import_core13.mapSecurityFindings)(secFindings, findings));
11949
+ findings.push(...(0, import_core14.mapSecurityFindings)(secFindings, findings));
11745
11950
  const adjusted = adjustFindingSeverity(findings);
11746
11951
  return {
11747
11952
  file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
11748
11953
  findings: adjusted,
11749
- overallSeverity: (0, import_core13.computeOverallSeverity)(adjusted)
11954
+ overallSeverity: (0, import_core14.computeOverallSeverity)(adjusted)
11750
11955
  };
11751
11956
  }
11752
11957
  async function scanWorkspaceConfig(workspacePath) {
11753
- const scanner = new import_core13.SecurityScanner((0, import_core13.parseSecurityConfig)({}));
11958
+ const scanner = new import_core14.SecurityScanner((0, import_core14.parseSecurityConfig)({}));
11754
11959
  const results = [];
11755
11960
  for (const configFile of CONFIG_FILES) {
11756
11961
  const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
11757
11962
  if (result) results.push(result);
11758
11963
  }
11759
- return { exitCode: (0, import_core13.computeScanExitCode)(results), results };
11964
+ return { exitCode: (0, import_core14.computeScanExitCode)(results), results };
11760
11965
  }
11761
11966
 
11762
11967
  // src/core/lane-persistence.ts
11763
- var import_core14 = require("@harness-engineering/core");
11968
+ var import_core15 = require("@harness-engineering/core");
11764
11969
  var import_types29 = require("@harness-engineering/types");
11765
11970
  var SIGNAL_TO_LANE = {
11766
11971
  claim: "claimed",
@@ -11774,16 +11979,16 @@ function mapOrchestratorLane(signal) {
11774
11979
  }
11775
11980
  async function persistLane(projectPath, issueId, signal) {
11776
11981
  try {
11777
- const reg = await import_core14.eventSourcing.registerTask(projectPath, issueId, []);
11982
+ const reg = await import_core15.eventSourcing.registerTask(projectPath, issueId, []);
11778
11983
  if (!reg.ok) return reg;
11779
- return await import_core14.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11984
+ return await import_core15.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
11780
11985
  } catch (err) {
11781
11986
  return (0, import_types29.Err)(err instanceof Error ? err : new Error(String(err)));
11782
11987
  }
11783
11988
  }
11784
11989
  async function readPersistedLanes(projectPath) {
11785
11990
  try {
11786
- const snap = await import_core14.eventSourcing.readSnapshot(projectPath);
11991
+ const snap = await import_core15.eventSourcing.readSnapshot(projectPath);
11787
11992
  return snap.ok ? snap.value.lanes : { tasks: {} };
11788
11993
  } catch {
11789
11994
  return { tasks: {} };
@@ -13884,7 +14089,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13884
14089
  if (overrides?.poolState) {
13885
14090
  this.poolStateProvider = overrides.poolState;
13886
14091
  } else if (localModelsEnabled) {
13887
- this.poolStateStore = new import_local_models4.PoolStateStore({
14092
+ this.poolStateStore = new import_local_models5.PoolStateStore({
13888
14093
  onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
13889
14094
  });
13890
14095
  this.poolStateProvider = this.poolStateStore;
@@ -13904,7 +14109,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13904
14109
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
13905
14110
  }
13906
14111
  }
13907
- this.cacheMetrics = new import_core18.CacheMetricsRecorder();
14112
+ this.cacheMetrics = new import_core19.CacheMetricsRecorder();
13908
14113
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
13909
14114
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
13910
14115
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
@@ -14031,7 +14236,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14031
14236
  const { ranked } = await this.modelRecommender(hardware);
14032
14237
  return ranked.slice(0, top);
14033
14238
  },
14034
- listModelProposals: () => (0, import_core17.listProposals)(this.projectRoot, { status: "open", kind: "model" })
14239
+ listModelProposals: () => (0, import_core18.listProposals)(this.projectRoot, { status: "open", kind: "model" })
14035
14240
  });
14036
14241
  this.server.setRecorder(this.recorder);
14037
14242
  this.interactionQueue.onPush((interaction) => {
@@ -14048,7 +14253,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14048
14253
  setupTelemetryExport(config, webhookStore, webhookDelivery) {
14049
14254
  const otlpCfg = config.telemetry?.export?.otlp;
14050
14255
  if (!otlpCfg) return;
14051
- this.otlpExporter = new import_core18.OTLPExporter({
14256
+ this.otlpExporter = new import_core19.OTLPExporter({
14052
14257
  endpoint: otlpCfg.endpoint,
14053
14258
  ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
14054
14259
  ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
@@ -14151,7 +14356,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14151
14356
  ...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
14152
14357
  ...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
14153
14358
  };
14154
- const clientResult = (0, import_core16.createTrackerClient)(trackerCfg);
14359
+ const clientResult = (0, import_core17.createTrackerClient)(trackerCfg);
14155
14360
  if (!clientResult.ok) throw clientResult.error;
14156
14361
  return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
14157
14362
  }
@@ -14730,12 +14935,12 @@ ${messages}`);
14730
14935
  async postLifecycleComment(identifier, externalId, event) {
14731
14936
  try {
14732
14937
  if (!externalId) return;
14733
- const trackerConfig = (0, import_core16.loadTrackerSyncConfig)(this.projectRoot);
14938
+ const trackerConfig = (0, import_core17.loadTrackerSyncConfig)(this.projectRoot);
14734
14939
  if (!trackerConfig) return;
14735
14940
  const token = process.env.GITHUB_TOKEN;
14736
14941
  if (!token) return;
14737
14942
  const orchestratorId = await this.orchestratorIdPromise;
14738
- const adapter = new import_core16.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14943
+ const adapter = new import_core17.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
14739
14944
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
14740
14945
  const actionMap = {
14741
14946
  claimed: "Dispatching agent for autonomous execution",
@@ -14809,7 +15014,7 @@ ${messages}`);
14809
15014
  ...f.line !== void 0 ? { line: f.line } : {}
14810
15015
  }))
14811
15016
  );
14812
- (0, import_core15.writeTaint)(
15017
+ (0, import_core16.writeTaint)(
14813
15018
  workspacePath,
14814
15019
  issue.id,
14815
15020
  "Medium-severity injection patterns found in workspace config files",
@@ -15087,11 +15292,29 @@ ${messages}`);
15087
15292
  initModelPool(store) {
15088
15293
  const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15089
15294
  const installerCfg = this.config.localModels?.installer;
15090
- this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
15295
+ this.modelInstaller = new import_local_models5.OllamaInstallAdapter({
15091
15296
  baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15092
15297
  onWarn
15093
15298
  });
15094
- this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
15299
+ this.modelPool = new import_local_models5.PoolManager({ store, installer: this.modelInstaller, onWarn });
15300
+ }
15301
+ /**
15302
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15303
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15304
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15305
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15306
+ * or no `pool` block is configured, so it is safe to call unconditionally
15307
+ * on the startup path.
15308
+ */
15309
+ async applyConfiguredPoolBounds() {
15310
+ const pool = this.modelPool;
15311
+ const bounds = this.config.localModels?.pool;
15312
+ if (pool === null || !bounds) return;
15313
+ await pool.configurePool({
15314
+ diskBudgetGb: bounds.diskBudgetGb,
15315
+ allowedOrgs: bounds.allowedOrgs,
15316
+ allowedFamilies: bounds.allowedFamilies
15317
+ });
15095
15318
  }
15096
15319
  /**
15097
15320
  * LMLM Phase 6: arm the single background refresh scheduler over the live
@@ -15109,10 +15332,21 @@ ${messages}`);
15109
15332
  if (this.modelPool === null) return;
15110
15333
  const pool = this.modelPool;
15111
15334
  const refreshCfg = this.config.localModels?.refresh;
15112
- const recommend = (0, import_local_models4.createNativeRecommender)({ candidates: [] });
15335
+ const frozen = (0, import_local_models5.loadFrozenCandidates)();
15336
+ const candidates = (0, import_local_models5.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
15337
+ for (const warning of frozen.warnings) {
15338
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15339
+ }
15340
+ if (candidates.length === 0) {
15341
+ this.logger.warn("LMLM recommender has no candidates", {
15342
+ frozenCount: frozen.candidates.length,
15343
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15344
+ });
15345
+ }
15346
+ const recommend = (0, import_local_models5.createNativeRecommender)({ candidates });
15113
15347
  this.modelRecommender = recommend;
15114
- this.refreshScheduler = new import_local_models4.RefreshScheduler({
15115
- runTick: () => (0, import_local_models4.runRefreshTick)({
15348
+ this.refreshScheduler = new import_local_models5.RefreshScheduler({
15349
+ runTick: () => (0, import_local_models5.runRefreshTick)({
15116
15350
  detectHardware: () => this.detectLmlmHardware(),
15117
15351
  recommend,
15118
15352
  poolManager: pool,
@@ -15120,7 +15354,7 @@ ${messages}`);
15120
15354
  // Phase 7: after persisting the proposal, emit `local-models:proposal`
15121
15355
  // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15122
15356
  // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15123
- emitProposal: (c) => (0, import_core17.createModelProposal)(this.projectRoot, c).then((record) => {
15357
+ emitProposal: (c) => (0, import_core18.createModelProposal)(this.projectRoot, c).then((record) => {
15124
15358
  this.emit("local-models:proposal", {
15125
15359
  id: record.id,
15126
15360
  status: "created",
@@ -15143,12 +15377,12 @@ ${messages}`);
15143
15377
  /** Resolve the hardware profile for a refresh tick (operator override wins). */
15144
15378
  async detectLmlmHardware() {
15145
15379
  const override = this.config.localModels?.hardware?.override;
15146
- const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
15380
+ const detector = new import_local_models5.HardwareDetector(override !== void 0 ? { override } : {});
15147
15381
  return (await detector.detect()).profile;
15148
15382
  }
15149
15383
  /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
15150
15384
  async lmlmDedupSource() {
15151
- const proposals = await (0, import_core17.listProposals)(this.projectRoot, { kind: "model" });
15385
+ const proposals = await (0, import_core18.listProposals)(this.projectRoot, { kind: "model" });
15152
15386
  const pending = [];
15153
15387
  const rejected = [];
15154
15388
  for (const p of proposals) {
@@ -15186,6 +15420,7 @@ ${messages}`);
15186
15420
  }
15187
15421
  if (this.poolStateStore !== null) {
15188
15422
  await this.poolStateStore.load();
15423
+ await this.applyConfiguredPoolBounds();
15189
15424
  }
15190
15425
  for (const resolver of this.localResolvers.values()) {
15191
15426
  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,196 @@ 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
+ import { estimateDiskGb } from "@harness-engineering/local-models";
9147
+ var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9148
+ var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9149
+ var RESOLVE_TOP = 50;
9150
+ function sendJSON10(res, status, body) {
9151
+ res.writeHead(status, { "Content-Type": "application/json" });
9152
+ res.end(JSON.stringify(body));
9153
+ }
9154
+ function decidedByOf(deps, req) {
9155
+ if (deps.getDecidedBy) return deps.getDecidedBy(req);
9156
+ const token = req._authToken;
9157
+ return token?.id ?? "operator";
9158
+ }
9159
+ function handlerDeps(deps, req, pool) {
9160
+ return {
9161
+ pool,
9162
+ bus: deps.bus,
9163
+ updateProposal: (id, patch) => updateProposal4(deps.projectPath, id, patch),
9164
+ decidedBy: decidedByOf(deps, req),
9165
+ ...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
9166
+ };
9167
+ }
9168
+ async function readJsonBody(req) {
9169
+ try {
9170
+ const raw = await readBody(req);
9171
+ if (raw.length === 0) return {};
9172
+ const parsed = JSON.parse(raw);
9173
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
9174
+ } catch {
9175
+ return void 0;
9176
+ }
9177
+ }
9178
+ async function handleInstall(req, res, deps) {
9179
+ const pool = deps.getModelPool();
9180
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9181
+ const body = await readJsonBody(req);
9182
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9183
+ const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
9184
+ if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
9185
+ const quant = typeof body.quant === "string" ? body.quant : void 0;
9186
+ if (!deps.getRecommendations) {
9187
+ return sendJSON10(res, 503, { error: "recommendations unavailable" });
9188
+ }
9189
+ const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
9190
+ const match = ranked.find(
9191
+ (r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
9192
+ );
9193
+ if (!match) {
9194
+ return sendJSON10(res, 404, {
9195
+ error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
9196
+ });
9197
+ }
9198
+ if (!match.ollamaName) {
9199
+ return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
9200
+ }
9201
+ const content = {
9202
+ action: "add",
9203
+ target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9204
+ scoreDelta: match.score,
9205
+ justification: {
9206
+ summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9207
+ benchmarkBasis: [],
9208
+ hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
9209
+ evidence: match.evidence,
9210
+ freshness: `snapshot ${match.benchmarkSnapshot}`
9211
+ },
9212
+ // Estimate the on-disk size from the candidate's params + quant (the same
9213
+ // `estimateDiskGb` the automated proposal engine uses). The pool needs a
9214
+ // size for its pre-commit budget check *before* the pull; it cannot inspect
9215
+ // the target first, because ollama `/api/show` 404s for a model that is not
9216
+ // yet pulled locally — which would surface as a spurious "no longer
9217
+ // available on HuggingFace" 404 on every operator install.
9218
+ diskImpactGb: estimateDiskGb({
9219
+ sizeB: match.sizeB,
9220
+ quant: match.quant,
9221
+ ...match.activeB !== void 0 ? { activeB: match.activeB } : {}
9222
+ })
9223
+ };
9224
+ const record = await createModelProposal(deps.projectPath, content, {
9225
+ proposedBy: decidedByOf(deps, req)
9226
+ });
9227
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9228
+ if (outcome.status === "approved") {
9229
+ const result = {
9230
+ disposition: "installed",
9231
+ proposalId: record.id,
9232
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9233
+ };
9234
+ return sendJSON10(res, 200, result);
9235
+ }
9236
+ if (outcome.status === "failed_target_missing") {
9237
+ return sendJSON10(res, 404, {
9238
+ error: `${hfRepoId} is no longer available on HuggingFace`,
9239
+ proposalId: record.id
9240
+ });
9241
+ }
9242
+ const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9243
+ return sendJSON10(res, status, {
9244
+ error: outcome.message,
9245
+ code: outcome.code,
9246
+ proposalId: record.id
9247
+ });
9248
+ }
9249
+ async function handleRemove(req, res, deps) {
9250
+ const pool = deps.getModelPool();
9251
+ if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
9252
+ const body = await readJsonBody(req);
9253
+ if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
9254
+ const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
9255
+ if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
9256
+ const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
9257
+ if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
9258
+ const content = {
9259
+ action: "evict",
9260
+ target: { hfRepoId: entry.hfRepoId, ollamaName },
9261
+ scoreDelta: 0,
9262
+ justification: {
9263
+ summary: `Operator-initiated removal of ${ollamaName}.`,
9264
+ benchmarkBasis: [],
9265
+ hardwareFit: "",
9266
+ evidence: "operator",
9267
+ freshness: ""
9268
+ },
9269
+ diskImpactGb: entry.sizeOnDiskGb
9270
+ };
9271
+ const record = await createModelProposal(deps.projectPath, content, {
9272
+ proposedBy: decidedByOf(deps, req)
9273
+ });
9274
+ const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9275
+ if (outcome.status === "error") {
9276
+ return sendJSON10(res, 502, {
9277
+ error: outcome.message,
9278
+ code: outcome.code,
9279
+ proposalId: record.id
9280
+ });
9281
+ }
9282
+ if (outcome.status === "failed_target_missing") {
9283
+ return sendJSON10(res, 500, {
9284
+ error: "unexpected failed_target_missing on evict",
9285
+ proposalId: record.id
9286
+ });
9287
+ }
9288
+ if (outcome.evicted.length > 0) {
9289
+ const result2 = {
9290
+ disposition: "removed",
9291
+ proposalId: record.id,
9292
+ evicted: outcome.evicted.map((e) => e.ollamaName)
9293
+ };
9294
+ return sendJSON10(res, 200, result2);
9295
+ }
9296
+ const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
9297
+ if (stillPresent) {
9298
+ const result2 = {
9299
+ disposition: "deferred",
9300
+ proposalId: record.id,
9301
+ evicted: [],
9302
+ message: "model is in use; it will be removed after the current run"
9303
+ };
9304
+ return sendJSON10(res, 202, result2);
9305
+ }
9306
+ const result = { disposition: "removed", proposalId: record.id, evicted: [] };
9307
+ return sendJSON10(res, 200, result);
9308
+ }
9309
+ function handleV1LocalModelsMutationRoute(req, res, deps) {
9310
+ const url = req.url ?? "";
9311
+ if ((req.method ?? "GET") !== "POST") return false;
9312
+ if (INSTALL_RE.test(url)) {
9313
+ void handleInstall(req, res, deps);
9314
+ return true;
9315
+ }
9316
+ if (REMOVE_RE.test(url)) {
9317
+ void handleRemove(req, res, deps);
9318
+ return true;
9319
+ }
9320
+ return false;
9321
+ }
9322
+
9142
9323
  // src/server/routes/v1/routing.ts
9143
9324
  import { z as z14 } from "zod";
9144
9325
  var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
9145
9326
  var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
9146
9327
  var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
9147
- function sendJSON10(res, status, body) {
9328
+ function sendJSON11(res, status, body) {
9148
9329
  res.writeHead(status, { "Content-Type": "application/json" });
9149
9330
  res.end(JSON.stringify(body));
9150
9331
  }
9151
9332
  function unavailable(res) {
9152
- sendJSON10(res, 503, { error: "BackendRouter not available" });
9333
+ sendJSON11(res, 503, { error: "BackendRouter not available" });
9153
9334
  return true;
9154
9335
  }
9155
9336
  function resolveChain(value, backends) {
@@ -9186,7 +9367,7 @@ function buildResolvedChains(routing, backends) {
9186
9367
  }
9187
9368
  function handleConfig(res, deps) {
9188
9369
  if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
9189
- sendJSON10(res, 200, {
9370
+ sendJSON11(res, 200, {
9190
9371
  routing: deps.routing,
9191
9372
  resolvedChains: buildResolvedChains(deps.routing, deps.backends),
9192
9373
  backends: Object.keys(deps.backends)
@@ -9214,7 +9395,7 @@ function parseDecisionsQuery(url) {
9214
9395
  function handleDecisions(req, res, deps) {
9215
9396
  if (!deps.bus) return unavailable(res);
9216
9397
  const filter = parseDecisionsQuery(req.url ?? "");
9217
- sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
9398
+ sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
9218
9399
  return true;
9219
9400
  }
9220
9401
  var UseCaseSchema = z14.discriminatedUnion("kind", [
@@ -9246,19 +9427,19 @@ async function handleTrace(req, res, deps) {
9246
9427
  try {
9247
9428
  raw = await readBody(req);
9248
9429
  } catch {
9249
- sendJSON10(res, 400, { error: "body read failed" });
9430
+ sendJSON11(res, 400, { error: "body read failed" });
9250
9431
  return true;
9251
9432
  }
9252
9433
  let parsed;
9253
9434
  try {
9254
9435
  parsed = JSON.parse(raw);
9255
9436
  } catch {
9256
- sendJSON10(res, 400, { error: "invalid JSON body" });
9437
+ sendJSON11(res, 400, { error: "invalid JSON body" });
9257
9438
  return true;
9258
9439
  }
9259
9440
  const r = TraceBodySchema.safeParse(parsed);
9260
9441
  if (!r.success) {
9261
- sendJSON10(res, 400, { error: r.error.message });
9442
+ sendJSON11(res, 400, { error: r.error.message });
9262
9443
  return true;
9263
9444
  }
9264
9445
  const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
@@ -9271,9 +9452,9 @@ async function handleTrace(req, res, deps) {
9271
9452
  r.data.useCase,
9272
9453
  opts
9273
9454
  );
9274
- sendJSON10(res, 200, { decision, def: { type: def.type } });
9455
+ sendJSON11(res, 200, { decision, def: { type: def.type } });
9275
9456
  } catch (err) {
9276
- sendJSON10(res, 500, { error: String(err) });
9457
+ sendJSON11(res, 500, { error: String(err) });
9277
9458
  }
9278
9459
  return true;
9279
9460
  }
@@ -9514,7 +9695,7 @@ var CreateBodySchema = z16.object({
9514
9695
  tenantId: z16.string().optional(),
9515
9696
  expiresAt: z16.string().datetime().optional()
9516
9697
  });
9517
- function sendJSON11(res, status, body) {
9698
+ function sendJSON12(res, status, body) {
9518
9699
  res.writeHead(status, { "Content-Type": "application/json" });
9519
9700
  res.end(JSON.stringify(body));
9520
9701
  }
@@ -9524,19 +9705,19 @@ async function handlePost(req, res, store) {
9524
9705
  raw = await readBody(req);
9525
9706
  } catch (err) {
9526
9707
  const msg = err instanceof Error ? err.message : "Failed to read body";
9527
- sendJSON11(res, 413, { error: msg });
9708
+ sendJSON12(res, 413, { error: msg });
9528
9709
  return;
9529
9710
  }
9530
9711
  let json;
9531
9712
  try {
9532
9713
  json = JSON.parse(raw);
9533
9714
  } catch {
9534
- sendJSON11(res, 400, { error: "Invalid JSON body" });
9715
+ sendJSON12(res, 400, { error: "Invalid JSON body" });
9535
9716
  return;
9536
9717
  }
9537
9718
  const parsed = CreateBodySchema.safeParse(json);
9538
9719
  if (!parsed.success) {
9539
- sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9720
+ sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
9540
9721
  return;
9541
9722
  }
9542
9723
  try {
@@ -9549,37 +9730,37 @@ async function handlePost(req, res, store) {
9549
9730
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
9550
9731
  const result = await store.create(input);
9551
9732
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
9552
- sendJSON11(res, 200, {
9733
+ sendJSON12(res, 200, {
9553
9734
  ...publicRecord,
9554
9735
  token: result.token
9555
9736
  });
9556
9737
  } catch (err) {
9557
9738
  const msg = err instanceof Error ? err.message : "Failed to create token";
9558
9739
  if (msg.includes("already exists")) {
9559
- sendJSON11(res, 409, { error: msg });
9740
+ sendJSON12(res, 409, { error: msg });
9560
9741
  return;
9561
9742
  }
9562
- sendJSON11(res, 500, { error: "Internal error creating token" });
9743
+ sendJSON12(res, 500, { error: "Internal error creating token" });
9563
9744
  }
9564
9745
  }
9565
9746
  async function handleList3(res, store) {
9566
9747
  try {
9567
9748
  const list = await store.list();
9568
- sendJSON11(res, 200, list);
9749
+ sendJSON12(res, 200, list);
9569
9750
  } catch {
9570
- sendJSON11(res, 500, { error: "Internal error listing tokens" });
9751
+ sendJSON12(res, 500, { error: "Internal error listing tokens" });
9571
9752
  }
9572
9753
  }
9573
9754
  async function handleDelete2(res, store, id) {
9574
9755
  try {
9575
9756
  const ok = await store.revoke(id);
9576
9757
  if (!ok) {
9577
- sendJSON11(res, 404, { error: "Token not found" });
9758
+ sendJSON12(res, 404, { error: "Token not found" });
9578
9759
  return;
9579
9760
  }
9580
- sendJSON11(res, 200, { deleted: true });
9761
+ sendJSON12(res, 200, { deleted: true });
9581
9762
  } catch {
9582
- sendJSON11(res, 500, { error: "Internal error revoking token" });
9763
+ sendJSON12(res, 500, { error: "Internal error revoking token" });
9583
9764
  }
9584
9765
  }
9585
9766
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -9604,12 +9785,12 @@ function handleAuthRoute(req, res, store) {
9604
9785
  return true;
9605
9786
  }
9606
9787
  }
9607
- sendJSON11(res, 405, { error: "Method not allowed" });
9788
+ sendJSON12(res, 405, { error: "Method not allowed" });
9608
9789
  return true;
9609
9790
  }
9610
9791
 
9611
9792
  // src/server/routes/local-model.ts
9612
- function sendJSON12(res, status, body) {
9793
+ function sendJSON13(res, status, body) {
9613
9794
  res.writeHead(status, { "Content-Type": "application/json" });
9614
9795
  res.end(JSON.stringify(body));
9615
9796
  }
@@ -9617,30 +9798,30 @@ function handleLocalModelRoute(req, res, getStatus) {
9617
9798
  const { method, url } = req;
9618
9799
  if (url !== "/api/v1/local-model/status") return false;
9619
9800
  if (method !== "GET") {
9620
- sendJSON12(res, 405, { error: "Method not allowed" });
9801
+ sendJSON13(res, 405, { error: "Method not allowed" });
9621
9802
  return true;
9622
9803
  }
9623
9804
  if (!getStatus) {
9624
- sendJSON12(res, 503, { error: "Local backend not configured" });
9805
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9625
9806
  return true;
9626
9807
  }
9627
9808
  const status = getStatus();
9628
9809
  if (!status) {
9629
- sendJSON12(res, 503, { error: "Local backend not configured" });
9810
+ sendJSON13(res, 503, { error: "Local backend not configured" });
9630
9811
  return true;
9631
9812
  }
9632
- sendJSON12(res, 200, status);
9813
+ sendJSON13(res, 200, status);
9633
9814
  return true;
9634
9815
  }
9635
9816
  function handleLocalModelsRoute(req, res, getStatuses) {
9636
9817
  const { method, url } = req;
9637
9818
  if (url !== "/api/v1/local-models/status") return false;
9638
9819
  if (method !== "GET") {
9639
- sendJSON12(res, 405, { error: "Method not allowed" });
9820
+ sendJSON13(res, 405, { error: "Method not allowed" });
9640
9821
  return true;
9641
9822
  }
9642
9823
  const statuses = getStatuses ? getStatuses() : [];
9643
- sendJSON12(res, 200, statuses);
9824
+ sendJSON13(res, 200, statuses);
9644
9825
  return true;
9645
9826
  }
9646
9827
 
@@ -10016,6 +10197,21 @@ var V1_BRIDGE_ROUTES = [
10016
10197
  scope: "manage-proposals",
10017
10198
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10018
10199
  },
10200
+ // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10201
+ // Modeled as auto-approved model proposals, so the same `manage-proposals`
10202
+ // write scope that governs approve/reject governs these too.
10203
+ {
10204
+ method: "POST",
10205
+ pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
10206
+ scope: "manage-proposals",
10207
+ description: "Install a recommended model into the local pool (auto-approved proposal)."
10208
+ },
10209
+ {
10210
+ method: "POST",
10211
+ pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
10212
+ scope: "manage-proposals",
10213
+ description: "Remove a model from the local pool (auto-approved proposal)."
10214
+ },
10019
10215
  // ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
10020
10216
  // Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
10021
10217
  // the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
@@ -10450,6 +10646,17 @@ var OrchestratorServer = class {
10450
10646
  ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10451
10647
  });
10452
10648
  },
10649
+ // LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
10650
+ // Operator-initiated install/remove via auto-approved proposals; reuses
10651
+ // the same mutation pool + bus + in-use probe the proposals route uses.
10652
+ // Registered before the read surface so /pool/install|remove match first.
10653
+ (req, res) => handleV1LocalModelsMutationRoute(req, res, {
10654
+ projectPath: this.projectPath,
10655
+ bus: this.orchestrator,
10656
+ getModelPool: () => this.getModelPoolFn?.() ?? null,
10657
+ ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
10658
+ ...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
10659
+ }),
10453
10660
  // LMLM Phase 6/7 — POST /refresh + the GET read surface
10454
10661
  // (hardware/pool/recommendations/proposals). Registered before the
10455
10662
  // chat-proxy fallback so it owns the path. Each accessor is spread in
@@ -15061,6 +15268,24 @@ ${messages}`);
15061
15268
  });
15062
15269
  this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
15063
15270
  }
15271
+ /**
15272
+ * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15273
+ * budget + org/family allowlist) from `localModels.pool` to the live pool.
15274
+ * Called after `PoolStateStore.load()` so config wins over persisted bounds
15275
+ * (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
15276
+ * or no `pool` block is configured, so it is safe to call unconditionally
15277
+ * on the startup path.
15278
+ */
15279
+ async applyConfiguredPoolBounds() {
15280
+ const pool = this.modelPool;
15281
+ const bounds = this.config.localModels?.pool;
15282
+ if (pool === null || !bounds) return;
15283
+ await pool.configurePool({
15284
+ diskBudgetGb: bounds.diskBudgetGb,
15285
+ allowedOrgs: bounds.allowedOrgs,
15286
+ allowedFamilies: bounds.allowedFamilies
15287
+ });
15288
+ }
15064
15289
  /**
15065
15290
  * LMLM Phase 6: arm the single background refresh scheduler over the live
15066
15291
  * pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
@@ -15077,7 +15302,18 @@ ${messages}`);
15077
15302
  if (this.modelPool === null) return;
15078
15303
  const pool = this.modelPool;
15079
15304
  const refreshCfg = this.config.localModels?.refresh;
15080
- const recommend = createNativeRecommender({ candidates: [] });
15305
+ const frozen = loadFrozenCandidates();
15306
+ const candidates = selectCandidates2(frozen.candidates, this.config.localModels?.pool);
15307
+ for (const warning of frozen.warnings) {
15308
+ this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15309
+ }
15310
+ if (candidates.length === 0) {
15311
+ this.logger.warn("LMLM recommender has no candidates", {
15312
+ frozenCount: frozen.candidates.length,
15313
+ allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15314
+ });
15315
+ }
15316
+ const recommend = createNativeRecommender({ candidates });
15081
15317
  this.modelRecommender = recommend;
15082
15318
  this.refreshScheduler = new RefreshScheduler({
15083
15319
  runTick: () => runRefreshTick({
@@ -15088,7 +15324,7 @@ ${messages}`);
15088
15324
  // Phase 7: after persisting the proposal, emit `local-models:proposal`
15089
15325
  // (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
15090
15326
  // notification sinks. Literal to avoid a proposals/model-handlers cycle.
15091
- emitProposal: (c) => createModelProposal(this.projectRoot, c).then((record) => {
15327
+ emitProposal: (c) => createModelProposal2(this.projectRoot, c).then((record) => {
15092
15328
  this.emit("local-models:proposal", {
15093
15329
  id: record.id,
15094
15330
  status: "created",
@@ -15154,6 +15390,7 @@ ${messages}`);
15154
15390
  }
15155
15391
  if (this.poolStateStore !== null) {
15156
15392
  await this.poolStateStore.load();
15393
+ await this.applyConfiguredPoolBounds();
15157
15394
  }
15158
15395
  for (const resolver of this.localResolvers.values()) {
15159
15396
  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.2",
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/local-models": "0.4.0",
54
+ "@harness-engineering/intelligence": "0.4.3",
55
+ "@harness-engineering/types": "0.19.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@asteasolutions/zod-to-openapi": "^7.3.0",