@harness-engineering/orchestrator 0.9.2 → 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 +123 -4
- package/dist/index.d.ts +123 -4
- package/dist/index.js +1017 -70
- package/dist/index.mjs +1005 -46
- package/package.json +8 -7
package/dist/index.mjs
CHANGED
|
@@ -3766,6 +3766,7 @@ var AgentRunner = class {
|
|
|
3766
3766
|
};
|
|
3767
3767
|
|
|
3768
3768
|
// src/agent/local-model-resolver.ts
|
|
3769
|
+
import { poolStateToCandidates } from "@harness-engineering/local-models";
|
|
3769
3770
|
var DEFAULT_PROBE_INTERVAL_MS = 3e4;
|
|
3770
3771
|
var MIN_PROBE_INTERVAL_MS = 1e3;
|
|
3771
3772
|
var DEFAULT_API_KEY = "lm-studio";
|
|
@@ -3822,6 +3823,7 @@ var LocalModelResolver = class {
|
|
|
3822
3823
|
endpoint;
|
|
3823
3824
|
apiKey;
|
|
3824
3825
|
configured;
|
|
3826
|
+
poolState;
|
|
3825
3827
|
probeIntervalMs;
|
|
3826
3828
|
fetchModels;
|
|
3827
3829
|
logger;
|
|
@@ -3850,6 +3852,9 @@ var LocalModelResolver = class {
|
|
|
3850
3852
|
this.apiKey = opts.apiKey;
|
|
3851
3853
|
}
|
|
3852
3854
|
this.configured = [...opts.configured];
|
|
3855
|
+
if (opts.poolState !== void 0) {
|
|
3856
|
+
this.poolState = opts.poolState;
|
|
3857
|
+
}
|
|
3853
3858
|
const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
|
3854
3859
|
this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
|
|
3855
3860
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
@@ -3863,13 +3868,21 @@ var LocalModelResolver = class {
|
|
|
3863
3868
|
return {
|
|
3864
3869
|
available: this.available,
|
|
3865
3870
|
resolved: this.resolved,
|
|
3866
|
-
configured:
|
|
3871
|
+
configured: this.candidates(),
|
|
3867
3872
|
detected: [...this.detected],
|
|
3868
3873
|
lastProbeAt: this.lastProbeAt,
|
|
3869
3874
|
lastError: this.lastError,
|
|
3870
3875
|
warnings: [...this.warnings]
|
|
3871
3876
|
};
|
|
3872
3877
|
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Effective candidate list. With a poolState port present the list derives
|
|
3880
|
+
* from pool entries (currentScore desc → ollamaName); otherwise the static
|
|
3881
|
+
* `configured` list is returned unchanged (byte-identical to pre-Phase-4).
|
|
3882
|
+
*/
|
|
3883
|
+
candidates() {
|
|
3884
|
+
return this.poolState ? poolStateToCandidates(this.poolState.snapshot()) : [...this.configured];
|
|
3885
|
+
}
|
|
3873
3886
|
onStatusChange(handler) {
|
|
3874
3887
|
this.listeners.add(handler);
|
|
3875
3888
|
return () => {
|
|
@@ -3893,11 +3906,12 @@ var LocalModelResolver = class {
|
|
|
3893
3906
|
this.detected = [...detected];
|
|
3894
3907
|
this.lastError = null;
|
|
3895
3908
|
this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3896
|
-
const
|
|
3909
|
+
const candidates = this.candidates();
|
|
3910
|
+
const match = candidates.find((id) => detected.includes(id)) ?? null;
|
|
3897
3911
|
this.resolved = match;
|
|
3898
3912
|
this.available = match !== null;
|
|
3899
3913
|
this.warnings = match ? [] : [
|
|
3900
|
-
`No configured local model is loaded. Configured: [${
|
|
3914
|
+
`No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
|
|
3901
3915
|
];
|
|
3902
3916
|
} catch (err) {
|
|
3903
3917
|
const message = err instanceof Error ? err.message : "probe failed";
|
|
@@ -3946,7 +3960,7 @@ var LocalModelResolver = class {
|
|
|
3946
3960
|
return JSON.stringify({
|
|
3947
3961
|
available: this.available,
|
|
3948
3962
|
resolved: this.resolved,
|
|
3949
|
-
configured: this.
|
|
3963
|
+
configured: this.candidates(),
|
|
3950
3964
|
detected: this.detected,
|
|
3951
3965
|
lastError: this.lastError,
|
|
3952
3966
|
warnings: this.warnings
|
|
@@ -3954,6 +3968,20 @@ var LocalModelResolver = class {
|
|
|
3954
3968
|
}
|
|
3955
3969
|
};
|
|
3956
3970
|
|
|
3971
|
+
// src/orchestrator.ts
|
|
3972
|
+
import {
|
|
3973
|
+
PoolStateStore,
|
|
3974
|
+
PoolManager,
|
|
3975
|
+
OllamaInstallAdapter,
|
|
3976
|
+
HardwareDetector,
|
|
3977
|
+
RefreshScheduler,
|
|
3978
|
+
runRefreshTick,
|
|
3979
|
+
createNativeRecommender,
|
|
3980
|
+
loadFrozenCandidates,
|
|
3981
|
+
selectCandidates as selectCandidates2
|
|
3982
|
+
} from "@harness-engineering/local-models";
|
|
3983
|
+
import { createModelProposal as createModelProposal2, listProposals as listProposals2 } from "@harness-engineering/core";
|
|
3984
|
+
|
|
3957
3985
|
// src/agent/config-migration.ts
|
|
3958
3986
|
var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
|
|
3959
3987
|
var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
|
|
@@ -8334,10 +8362,10 @@ function checkDiff(diff) {
|
|
|
8334
8362
|
function deriveFindings(proposal) {
|
|
8335
8363
|
const findings = [];
|
|
8336
8364
|
findings.push(...checkName(proposal.content.name));
|
|
8337
|
-
if (proposal.
|
|
8365
|
+
if (proposal.skillKind === "new-skill") {
|
|
8338
8366
|
findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
|
|
8339
8367
|
findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
|
|
8340
|
-
} else if (proposal.
|
|
8368
|
+
} else if (proposal.skillKind === "refinement") {
|
|
8341
8369
|
findings.push(...checkDiff(proposal.content.diff ?? ""));
|
|
8342
8370
|
}
|
|
8343
8371
|
return findings;
|
|
@@ -8345,6 +8373,9 @@ function deriveFindings(proposal) {
|
|
|
8345
8373
|
async function runGate(projectPath, proposalId) {
|
|
8346
8374
|
const proposal = await getProposal(projectPath, proposalId);
|
|
8347
8375
|
if (!proposal) throw new ProposalNotFoundError(proposalId);
|
|
8376
|
+
if (proposal.kind !== "skill") {
|
|
8377
|
+
throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
|
|
8378
|
+
}
|
|
8348
8379
|
if (proposal.status === "approved" || proposal.status === "rejected") {
|
|
8349
8380
|
throw new GateRunError(
|
|
8350
8381
|
`proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
|
|
@@ -8474,8 +8505,11 @@ async function promoteRefinement(projectPath, proposal) {
|
|
|
8474
8505
|
async function promote(projectPath, proposalId, decidedBy) {
|
|
8475
8506
|
const proposal = await getProposal2(projectPath, proposalId);
|
|
8476
8507
|
if (!proposal) throw new ProposalNotFoundError2(proposalId);
|
|
8508
|
+
if (proposal.kind !== "skill") {
|
|
8509
|
+
throw new PromotionError("only skill proposals are promotable to the catalog");
|
|
8510
|
+
}
|
|
8477
8511
|
assertGateReady(proposal);
|
|
8478
|
-
const out = proposal.
|
|
8512
|
+
const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
|
|
8479
8513
|
await updateProposal2(projectPath, proposalId, {
|
|
8480
8514
|
status: "approved",
|
|
8481
8515
|
decision: {
|
|
@@ -8498,7 +8532,7 @@ function emit3(bus, topic, data) {
|
|
|
8498
8532
|
function emitProposalCreated(bus, proposal) {
|
|
8499
8533
|
const data = {
|
|
8500
8534
|
id: proposal.id,
|
|
8501
|
-
kind: proposal.
|
|
8535
|
+
kind: proposal.skillKind,
|
|
8502
8536
|
name: proposal.content.name,
|
|
8503
8537
|
proposedBy: proposal.proposedBy,
|
|
8504
8538
|
justification: proposal.source.justification
|
|
@@ -8509,7 +8543,7 @@ function emitProposalCreated(bus, proposal) {
|
|
|
8509
8543
|
function emitProposalApproved(bus, proposal) {
|
|
8510
8544
|
const data = {
|
|
8511
8545
|
id: proposal.id,
|
|
8512
|
-
kind: proposal.
|
|
8546
|
+
kind: proposal.skillKind,
|
|
8513
8547
|
name: proposal.content.name,
|
|
8514
8548
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
|
|
8515
8549
|
};
|
|
@@ -8519,7 +8553,7 @@ function emitProposalApproved(bus, proposal) {
|
|
|
8519
8553
|
function emitProposalRejected(bus, proposal) {
|
|
8520
8554
|
const data = {
|
|
8521
8555
|
id: proposal.id,
|
|
8522
|
-
kind: proposal.
|
|
8556
|
+
kind: proposal.skillKind,
|
|
8523
8557
|
name: proposal.content.name,
|
|
8524
8558
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
|
|
8525
8559
|
reason: proposal.decision?.reason ?? "(no reason given)"
|
|
@@ -8527,6 +8561,163 @@ function emitProposalRejected(bus, proposal) {
|
|
|
8527
8561
|
emit3(bus, "proposal.rejected", data);
|
|
8528
8562
|
}
|
|
8529
8563
|
|
|
8564
|
+
// src/proposals/model-handlers.ts
|
|
8565
|
+
function isInUse(deps, ollamaName) {
|
|
8566
|
+
return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
|
|
8567
|
+
}
|
|
8568
|
+
var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
|
|
8569
|
+
var MODEL_POOL_TOPIC = "local-models:pool";
|
|
8570
|
+
function decisionOf(deps, action, reason) {
|
|
8571
|
+
return {
|
|
8572
|
+
decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
8573
|
+
decidedBy: deps.decidedBy ?? "orchestrator",
|
|
8574
|
+
action,
|
|
8575
|
+
...reason !== void 0 ? { reason } : {}
|
|
8576
|
+
};
|
|
8577
|
+
}
|
|
8578
|
+
function emitApproved(deps, proposal) {
|
|
8579
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8580
|
+
id: proposal.id,
|
|
8581
|
+
status: "approved",
|
|
8582
|
+
action: proposal.model.action,
|
|
8583
|
+
target: proposal.model.target.ollamaName
|
|
8584
|
+
});
|
|
8585
|
+
}
|
|
8586
|
+
async function deferEviction(deps, proposal, deferredName, event, evicted) {
|
|
8587
|
+
deps.pool.markPendingEviction(deferredName);
|
|
8588
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8589
|
+
status: "approved",
|
|
8590
|
+
decision: decisionOf(deps, "approved")
|
|
8591
|
+
});
|
|
8592
|
+
deps.bus.emit(MODEL_POOL_TOPIC, event);
|
|
8593
|
+
emitApproved(deps, proposal);
|
|
8594
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8595
|
+
}
|
|
8596
|
+
async function onApproveModelProposal(deps, proposal) {
|
|
8597
|
+
const { model } = proposal;
|
|
8598
|
+
if (model.action === "evict") {
|
|
8599
|
+
return applyEvictOnly(deps, proposal);
|
|
8600
|
+
}
|
|
8601
|
+
const installResult = await deps.pool.install({
|
|
8602
|
+
hfRepoId: model.target.hfRepoId,
|
|
8603
|
+
ollamaName: model.target.ollamaName,
|
|
8604
|
+
...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
|
|
8605
|
+
...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
|
|
8606
|
+
});
|
|
8607
|
+
if (installResult.status === "error") {
|
|
8608
|
+
if (installResult.code === "failed_target_missing") {
|
|
8609
|
+
const updated2 = await deps.updateProposal(proposal.id, {
|
|
8610
|
+
status: "failed_target_missing"
|
|
8611
|
+
});
|
|
8612
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8613
|
+
id: proposal.id,
|
|
8614
|
+
status: "failed_target_missing",
|
|
8615
|
+
action: model.action,
|
|
8616
|
+
target: model.target.ollamaName
|
|
8617
|
+
});
|
|
8618
|
+
return { status: "failed_target_missing", proposal: updated2 };
|
|
8619
|
+
}
|
|
8620
|
+
return { status: "error", code: installResult.code, message: installResult.message };
|
|
8621
|
+
}
|
|
8622
|
+
const evicted = [...installResult.evicted];
|
|
8623
|
+
if (model.action === "swap" && model.replaces !== void 0) {
|
|
8624
|
+
const replacesName = model.replaces.ollamaName;
|
|
8625
|
+
if (isInUse(deps, replacesName)) {
|
|
8626
|
+
return deferEviction(
|
|
8627
|
+
deps,
|
|
8628
|
+
proposal,
|
|
8629
|
+
replacesName,
|
|
8630
|
+
{
|
|
8631
|
+
id: proposal.id,
|
|
8632
|
+
action: model.action,
|
|
8633
|
+
installed: model.target.ollamaName,
|
|
8634
|
+
phase: "evict_deferred",
|
|
8635
|
+
deferred: replacesName,
|
|
8636
|
+
...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
|
|
8637
|
+
},
|
|
8638
|
+
evicted
|
|
8639
|
+
);
|
|
8640
|
+
}
|
|
8641
|
+
const evictResult = await deps.pool.evict({ ollamaName: replacesName });
|
|
8642
|
+
if (evictResult.status === "error") {
|
|
8643
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8644
|
+
id: proposal.id,
|
|
8645
|
+
action: model.action,
|
|
8646
|
+
installed: model.target.ollamaName,
|
|
8647
|
+
phase: "swap_evict_failed"
|
|
8648
|
+
});
|
|
8649
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8650
|
+
}
|
|
8651
|
+
if (evictResult.removed !== null) {
|
|
8652
|
+
evicted.push(evictResult.removed);
|
|
8653
|
+
}
|
|
8654
|
+
}
|
|
8655
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8656
|
+
status: "approved",
|
|
8657
|
+
decision: decisionOf(deps, "approved")
|
|
8658
|
+
});
|
|
8659
|
+
const evictedNames = [
|
|
8660
|
+
...installResult.evicted.map((e) => e.ollamaName),
|
|
8661
|
+
...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
|
|
8662
|
+
];
|
|
8663
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8664
|
+
id: proposal.id,
|
|
8665
|
+
action: model.action,
|
|
8666
|
+
installed: model.target.ollamaName,
|
|
8667
|
+
...evictedNames.length > 0 ? { evicted: evictedNames } : {}
|
|
8668
|
+
});
|
|
8669
|
+
emitApproved(deps, proposal);
|
|
8670
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8671
|
+
}
|
|
8672
|
+
async function applyEvictOnly(deps, proposal) {
|
|
8673
|
+
const targetName = proposal.model.target.ollamaName;
|
|
8674
|
+
if (isInUse(deps, targetName)) {
|
|
8675
|
+
return deferEviction(
|
|
8676
|
+
deps,
|
|
8677
|
+
proposal,
|
|
8678
|
+
targetName,
|
|
8679
|
+
{ id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
|
|
8680
|
+
[]
|
|
8681
|
+
);
|
|
8682
|
+
}
|
|
8683
|
+
const evictResult = await deps.pool.evict({ ollamaName: targetName });
|
|
8684
|
+
if (evictResult.status === "error") {
|
|
8685
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8686
|
+
}
|
|
8687
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8688
|
+
status: "approved",
|
|
8689
|
+
decision: decisionOf(deps, "approved")
|
|
8690
|
+
});
|
|
8691
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8692
|
+
id: proposal.id,
|
|
8693
|
+
action: "evict",
|
|
8694
|
+
// XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
|
|
8695
|
+
// list multiple removals) — the evict-only path wraps its single removal in
|
|
8696
|
+
// an array too, so consumers never branch on string-vs-array.
|
|
8697
|
+
evicted: [proposal.model.target.ollamaName]
|
|
8698
|
+
});
|
|
8699
|
+
emitApproved(deps, proposal);
|
|
8700
|
+
return {
|
|
8701
|
+
status: "approved",
|
|
8702
|
+
proposal: updated,
|
|
8703
|
+
evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
|
|
8704
|
+
};
|
|
8705
|
+
}
|
|
8706
|
+
async function onRejectModelProposal(deps, proposal, reason) {
|
|
8707
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8708
|
+
status: "rejected",
|
|
8709
|
+
decision: decisionOf(deps, "rejected", reason)
|
|
8710
|
+
});
|
|
8711
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8712
|
+
id: proposal.id,
|
|
8713
|
+
status: "rejected",
|
|
8714
|
+
target: proposal.model.target.ollamaName,
|
|
8715
|
+
replaces: proposal.model.replaces?.ollamaName,
|
|
8716
|
+
reason
|
|
8717
|
+
});
|
|
8718
|
+
return updated;
|
|
8719
|
+
}
|
|
8720
|
+
|
|
8530
8721
|
// src/server/routes/v1/proposals.ts
|
|
8531
8722
|
var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
|
|
8532
8723
|
var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -8552,6 +8743,20 @@ function getDecidedBy(req, deps) {
|
|
|
8552
8743
|
const token = req._authToken;
|
|
8553
8744
|
return token?.id ?? "unknown";
|
|
8554
8745
|
}
|
|
8746
|
+
function modelHandlerDeps(deps, req) {
|
|
8747
|
+
return {
|
|
8748
|
+
pool: deps.modelPool,
|
|
8749
|
+
bus: deps.bus,
|
|
8750
|
+
// Core's `updateProposal` patch type intersects the skill+model status
|
|
8751
|
+
// enums, which statically excludes model-only statuses like
|
|
8752
|
+
// `failed_target_missing`. The runtime parses through `ProposalSchema` (the
|
|
8753
|
+
// model variant accepts that status), so the cast is sound — only the
|
|
8754
|
+
// intersection type is too narrow.
|
|
8755
|
+
updateProposal: (id, patch) => updateProposal3(deps.projectPath, id, patch),
|
|
8756
|
+
decidedBy: getDecidedBy(req, deps),
|
|
8757
|
+
...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
|
|
8758
|
+
};
|
|
8759
|
+
}
|
|
8555
8760
|
function parseStatusFromQuery(url) {
|
|
8556
8761
|
const queryIdx = url.indexOf("?");
|
|
8557
8762
|
if (queryIdx === -1) return void 0;
|
|
@@ -8596,11 +8801,31 @@ async function handleRunGate(res, deps, id) {
|
|
|
8596
8801
|
}
|
|
8597
8802
|
}
|
|
8598
8803
|
async function handleApprove(req, res, deps, id) {
|
|
8804
|
+
const existing = await getProposal3(deps.projectPath, id);
|
|
8805
|
+
if (!existing) {
|
|
8806
|
+
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8807
|
+
return;
|
|
8808
|
+
}
|
|
8809
|
+
if (existing.kind === "model") {
|
|
8810
|
+
if (!deps.modelPool) {
|
|
8811
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8812
|
+
return;
|
|
8813
|
+
}
|
|
8814
|
+
if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
|
|
8815
|
+
sendJSON8(res, 409, {
|
|
8816
|
+
error: `proposal already ${existing.status}; cannot approve`
|
|
8817
|
+
});
|
|
8818
|
+
return;
|
|
8819
|
+
}
|
|
8820
|
+
const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
|
|
8821
|
+
sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
|
|
8822
|
+
return;
|
|
8823
|
+
}
|
|
8599
8824
|
const decidedBy = getDecidedBy(req, deps);
|
|
8600
8825
|
try {
|
|
8601
8826
|
const result = await promote(deps.projectPath, id, decidedBy);
|
|
8602
8827
|
const proposal = await getProposal3(deps.projectPath, id);
|
|
8603
|
-
if (proposal) emitProposalApproved(deps.bus, proposal);
|
|
8828
|
+
if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
|
|
8604
8829
|
sendJSON8(res, 200, { promotion: result, proposal });
|
|
8605
8830
|
} catch (err) {
|
|
8606
8831
|
if (err instanceof ProposalNotFoundError3) {
|
|
@@ -8652,6 +8877,19 @@ async function handleReject(req, res, deps, id) {
|
|
|
8652
8877
|
});
|
|
8653
8878
|
return;
|
|
8654
8879
|
}
|
|
8880
|
+
if (proposal.kind === "model") {
|
|
8881
|
+
if (!deps.modelPool) {
|
|
8882
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8883
|
+
return;
|
|
8884
|
+
}
|
|
8885
|
+
const updated2 = await onRejectModelProposal(
|
|
8886
|
+
modelHandlerDeps(deps, req),
|
|
8887
|
+
proposal,
|
|
8888
|
+
parsed.data.reason
|
|
8889
|
+
);
|
|
8890
|
+
sendJSON8(res, 200, updated2);
|
|
8891
|
+
return;
|
|
8892
|
+
}
|
|
8655
8893
|
const decidedBy = getDecidedBy(req, deps);
|
|
8656
8894
|
const updated = await updateProposal3(deps.projectPath, id, {
|
|
8657
8895
|
status: "rejected",
|
|
@@ -8662,7 +8900,7 @@ async function handleReject(req, res, deps, id) {
|
|
|
8662
8900
|
reason: parsed.data.reason
|
|
8663
8901
|
}
|
|
8664
8902
|
});
|
|
8665
|
-
emitProposalRejected(deps.bus, updated);
|
|
8903
|
+
if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
|
|
8666
8904
|
sendJSON8(res, 200, updated);
|
|
8667
8905
|
}
|
|
8668
8906
|
async function handleEdit(req, res, deps, id) {
|
|
@@ -8690,6 +8928,10 @@ async function handleEdit(req, res, deps, id) {
|
|
|
8690
8928
|
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8691
8929
|
return;
|
|
8692
8930
|
}
|
|
8931
|
+
if (existing.kind !== "skill") {
|
|
8932
|
+
sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
|
|
8933
|
+
return;
|
|
8934
|
+
}
|
|
8693
8935
|
if (existing.status === "approved" || existing.status === "rejected") {
|
|
8694
8936
|
sendJSON8(res, 409, {
|
|
8695
8937
|
error: `proposal already ${existing.status}; cannot edit`
|
|
@@ -8755,17 +8997,331 @@ function handleV1ProposalsRoute(req, res, deps) {
|
|
|
8755
8997
|
return false;
|
|
8756
8998
|
}
|
|
8757
8999
|
|
|
9000
|
+
// src/server/routes/v1/local-models.ts
|
|
9001
|
+
import {
|
|
9002
|
+
isTickHardFailure
|
|
9003
|
+
} from "@harness-engineering/local-models";
|
|
9004
|
+
var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
|
|
9005
|
+
var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
|
|
9006
|
+
var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
|
|
9007
|
+
var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
|
|
9008
|
+
var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
|
|
9009
|
+
var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
|
|
9010
|
+
function sendJSON9(res, status, body) {
|
|
9011
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9012
|
+
res.end(JSON.stringify(body));
|
|
9013
|
+
}
|
|
9014
|
+
function handleV1LocalModelsRoute(req, res, deps) {
|
|
9015
|
+
const url = req.url ?? "";
|
|
9016
|
+
const method = req.method ?? "GET";
|
|
9017
|
+
if (method === "GET") {
|
|
9018
|
+
if (HARDWARE_RE.test(url)) {
|
|
9019
|
+
void runGet(res, () => handleHardware(res, deps));
|
|
9020
|
+
return true;
|
|
9021
|
+
}
|
|
9022
|
+
if (POOL_RE.test(url)) {
|
|
9023
|
+
void runGet(res, () => handlePool(res, deps));
|
|
9024
|
+
return true;
|
|
9025
|
+
}
|
|
9026
|
+
if (RECS_RE.test(url)) {
|
|
9027
|
+
void runGet(res, () => handleRecommendations(res, url, deps));
|
|
9028
|
+
return true;
|
|
9029
|
+
}
|
|
9030
|
+
if (PROPOSALS_RE.test(url)) {
|
|
9031
|
+
void runGet(res, () => handleProposals(res, deps));
|
|
9032
|
+
return true;
|
|
9033
|
+
}
|
|
9034
|
+
return false;
|
|
9035
|
+
}
|
|
9036
|
+
if (method !== "POST" || !REFRESH_RE.test(url)) return false;
|
|
9037
|
+
const scheduler = deps.getRefreshScheduler();
|
|
9038
|
+
if (scheduler === null) {
|
|
9039
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9040
|
+
return true;
|
|
9041
|
+
}
|
|
9042
|
+
void runForceRefresh(res, scheduler, deps);
|
|
9043
|
+
return true;
|
|
9044
|
+
}
|
|
9045
|
+
async function runGet(res, handler) {
|
|
9046
|
+
try {
|
|
9047
|
+
await handler();
|
|
9048
|
+
} catch (err) {
|
|
9049
|
+
sendJSON9(res, 500, {
|
|
9050
|
+
error: "local-models route failed",
|
|
9051
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9052
|
+
});
|
|
9053
|
+
}
|
|
9054
|
+
}
|
|
9055
|
+
async function handleHardware(res, deps) {
|
|
9056
|
+
const pending = deps.getHardwareProfile?.() ?? null;
|
|
9057
|
+
if (pending === null) {
|
|
9058
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9059
|
+
return;
|
|
9060
|
+
}
|
|
9061
|
+
sendJSON9(res, 200, await pending);
|
|
9062
|
+
}
|
|
9063
|
+
async function handlePool(res, deps) {
|
|
9064
|
+
const pool = deps.getModelPool?.() ?? null;
|
|
9065
|
+
if (pool === null) {
|
|
9066
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9067
|
+
return;
|
|
9068
|
+
}
|
|
9069
|
+
sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
|
|
9070
|
+
}
|
|
9071
|
+
var MAX_RECOMMENDATION_TOP = 100;
|
|
9072
|
+
async function handleRecommendations(res, url, deps) {
|
|
9073
|
+
if (deps.getRecommendations === void 0) {
|
|
9074
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9075
|
+
return;
|
|
9076
|
+
}
|
|
9077
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
9078
|
+
const topRaw = params.get("top");
|
|
9079
|
+
let top = 10;
|
|
9080
|
+
if (topRaw !== null) {
|
|
9081
|
+
const n = Number(topRaw);
|
|
9082
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
9083
|
+
sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
|
|
9084
|
+
return;
|
|
9085
|
+
}
|
|
9086
|
+
if (n > MAX_RECOMMENDATION_TOP) {
|
|
9087
|
+
sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
|
|
9088
|
+
return;
|
|
9089
|
+
}
|
|
9090
|
+
top = n;
|
|
9091
|
+
}
|
|
9092
|
+
const profileRaw = params.get("profile");
|
|
9093
|
+
let profile = "general";
|
|
9094
|
+
if (profileRaw !== null) {
|
|
9095
|
+
if (!isRecommendationProfile(profileRaw)) {
|
|
9096
|
+
sendJSON9(res, 400, {
|
|
9097
|
+
error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
|
|
9098
|
+
});
|
|
9099
|
+
return;
|
|
9100
|
+
}
|
|
9101
|
+
profile = profileRaw;
|
|
9102
|
+
}
|
|
9103
|
+
sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
|
|
9104
|
+
}
|
|
9105
|
+
async function handleProposals(res, deps) {
|
|
9106
|
+
if (deps.listModelProposals === void 0) {
|
|
9107
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9108
|
+
return;
|
|
9109
|
+
}
|
|
9110
|
+
sendJSON9(res, 200, await deps.listModelProposals());
|
|
9111
|
+
}
|
|
9112
|
+
function isRecommendationProfile(value) {
|
|
9113
|
+
return RECOMMENDATION_PROFILES.includes(value);
|
|
9114
|
+
}
|
|
9115
|
+
async function runForceRefresh(res, scheduler, deps) {
|
|
9116
|
+
let result;
|
|
9117
|
+
try {
|
|
9118
|
+
result = await scheduler.forceRefresh();
|
|
9119
|
+
} catch (err) {
|
|
9120
|
+
sendJSON9(res, 500, {
|
|
9121
|
+
error: "refresh tick failed",
|
|
9122
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9123
|
+
});
|
|
9124
|
+
return;
|
|
9125
|
+
}
|
|
9126
|
+
if (isTickHardFailure(result)) {
|
|
9127
|
+
sendJSON9(res, 503, {
|
|
9128
|
+
error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
|
|
9129
|
+
emitted: result.proposalsEmitted,
|
|
9130
|
+
warnings: result.warnings,
|
|
9131
|
+
errors: result.errors
|
|
9132
|
+
});
|
|
9133
|
+
return;
|
|
9134
|
+
}
|
|
9135
|
+
const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
|
|
9136
|
+
sendJSON9(res, 200, {
|
|
9137
|
+
emitted: result.proposalsEmitted,
|
|
9138
|
+
reconciledRemoved: result.reconciledRemoved,
|
|
9139
|
+
proposals,
|
|
9140
|
+
warnings: result.warnings
|
|
9141
|
+
});
|
|
9142
|
+
}
|
|
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
|
+
|
|
8758
9314
|
// src/server/routes/v1/routing.ts
|
|
8759
9315
|
import { z as z14 } from "zod";
|
|
8760
9316
|
var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
|
|
8761
9317
|
var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
|
|
8762
9318
|
var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
|
|
8763
|
-
function
|
|
9319
|
+
function sendJSON11(res, status, body) {
|
|
8764
9320
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
8765
9321
|
res.end(JSON.stringify(body));
|
|
8766
9322
|
}
|
|
8767
9323
|
function unavailable(res) {
|
|
8768
|
-
|
|
9324
|
+
sendJSON11(res, 503, { error: "BackendRouter not available" });
|
|
8769
9325
|
return true;
|
|
8770
9326
|
}
|
|
8771
9327
|
function resolveChain(value, backends) {
|
|
@@ -8802,7 +9358,7 @@ function buildResolvedChains(routing, backends) {
|
|
|
8802
9358
|
}
|
|
8803
9359
|
function handleConfig(res, deps) {
|
|
8804
9360
|
if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
|
|
8805
|
-
|
|
9361
|
+
sendJSON11(res, 200, {
|
|
8806
9362
|
routing: deps.routing,
|
|
8807
9363
|
resolvedChains: buildResolvedChains(deps.routing, deps.backends),
|
|
8808
9364
|
backends: Object.keys(deps.backends)
|
|
@@ -8830,7 +9386,7 @@ function parseDecisionsQuery(url) {
|
|
|
8830
9386
|
function handleDecisions(req, res, deps) {
|
|
8831
9387
|
if (!deps.bus) return unavailable(res);
|
|
8832
9388
|
const filter = parseDecisionsQuery(req.url ?? "");
|
|
8833
|
-
|
|
9389
|
+
sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
|
|
8834
9390
|
return true;
|
|
8835
9391
|
}
|
|
8836
9392
|
var UseCaseSchema = z14.discriminatedUnion("kind", [
|
|
@@ -8862,19 +9418,19 @@ async function handleTrace(req, res, deps) {
|
|
|
8862
9418
|
try {
|
|
8863
9419
|
raw = await readBody(req);
|
|
8864
9420
|
} catch {
|
|
8865
|
-
|
|
9421
|
+
sendJSON11(res, 400, { error: "body read failed" });
|
|
8866
9422
|
return true;
|
|
8867
9423
|
}
|
|
8868
9424
|
let parsed;
|
|
8869
9425
|
try {
|
|
8870
9426
|
parsed = JSON.parse(raw);
|
|
8871
9427
|
} catch {
|
|
8872
|
-
|
|
9428
|
+
sendJSON11(res, 400, { error: "invalid JSON body" });
|
|
8873
9429
|
return true;
|
|
8874
9430
|
}
|
|
8875
9431
|
const r = TraceBodySchema.safeParse(parsed);
|
|
8876
9432
|
if (!r.success) {
|
|
8877
|
-
|
|
9433
|
+
sendJSON11(res, 400, { error: r.error.message });
|
|
8878
9434
|
return true;
|
|
8879
9435
|
}
|
|
8880
9436
|
const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
|
|
@@ -8887,9 +9443,9 @@ async function handleTrace(req, res, deps) {
|
|
|
8887
9443
|
r.data.useCase,
|
|
8888
9444
|
opts
|
|
8889
9445
|
);
|
|
8890
|
-
|
|
9446
|
+
sendJSON11(res, 200, { decision, def: { type: def.type } });
|
|
8891
9447
|
} catch (err) {
|
|
8892
|
-
|
|
9448
|
+
sendJSON11(res, 500, { error: String(err) });
|
|
8893
9449
|
}
|
|
8894
9450
|
return true;
|
|
8895
9451
|
}
|
|
@@ -9130,7 +9686,7 @@ var CreateBodySchema = z16.object({
|
|
|
9130
9686
|
tenantId: z16.string().optional(),
|
|
9131
9687
|
expiresAt: z16.string().datetime().optional()
|
|
9132
9688
|
});
|
|
9133
|
-
function
|
|
9689
|
+
function sendJSON12(res, status, body) {
|
|
9134
9690
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9135
9691
|
res.end(JSON.stringify(body));
|
|
9136
9692
|
}
|
|
@@ -9140,19 +9696,19 @@ async function handlePost(req, res, store) {
|
|
|
9140
9696
|
raw = await readBody(req);
|
|
9141
9697
|
} catch (err) {
|
|
9142
9698
|
const msg = err instanceof Error ? err.message : "Failed to read body";
|
|
9143
|
-
|
|
9699
|
+
sendJSON12(res, 413, { error: msg });
|
|
9144
9700
|
return;
|
|
9145
9701
|
}
|
|
9146
9702
|
let json;
|
|
9147
9703
|
try {
|
|
9148
9704
|
json = JSON.parse(raw);
|
|
9149
9705
|
} catch {
|
|
9150
|
-
|
|
9706
|
+
sendJSON12(res, 400, { error: "Invalid JSON body" });
|
|
9151
9707
|
return;
|
|
9152
9708
|
}
|
|
9153
9709
|
const parsed = CreateBodySchema.safeParse(json);
|
|
9154
9710
|
if (!parsed.success) {
|
|
9155
|
-
|
|
9711
|
+
sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
|
|
9156
9712
|
return;
|
|
9157
9713
|
}
|
|
9158
9714
|
try {
|
|
@@ -9165,37 +9721,37 @@ async function handlePost(req, res, store) {
|
|
|
9165
9721
|
if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
|
|
9166
9722
|
const result = await store.create(input);
|
|
9167
9723
|
const publicRecord = AuthTokenPublicSchema.parse(result.record);
|
|
9168
|
-
|
|
9724
|
+
sendJSON12(res, 200, {
|
|
9169
9725
|
...publicRecord,
|
|
9170
9726
|
token: result.token
|
|
9171
9727
|
});
|
|
9172
9728
|
} catch (err) {
|
|
9173
9729
|
const msg = err instanceof Error ? err.message : "Failed to create token";
|
|
9174
9730
|
if (msg.includes("already exists")) {
|
|
9175
|
-
|
|
9731
|
+
sendJSON12(res, 409, { error: msg });
|
|
9176
9732
|
return;
|
|
9177
9733
|
}
|
|
9178
|
-
|
|
9734
|
+
sendJSON12(res, 500, { error: "Internal error creating token" });
|
|
9179
9735
|
}
|
|
9180
9736
|
}
|
|
9181
9737
|
async function handleList3(res, store) {
|
|
9182
9738
|
try {
|
|
9183
9739
|
const list = await store.list();
|
|
9184
|
-
|
|
9740
|
+
sendJSON12(res, 200, list);
|
|
9185
9741
|
} catch {
|
|
9186
|
-
|
|
9742
|
+
sendJSON12(res, 500, { error: "Internal error listing tokens" });
|
|
9187
9743
|
}
|
|
9188
9744
|
}
|
|
9189
9745
|
async function handleDelete2(res, store, id) {
|
|
9190
9746
|
try {
|
|
9191
9747
|
const ok = await store.revoke(id);
|
|
9192
9748
|
if (!ok) {
|
|
9193
|
-
|
|
9749
|
+
sendJSON12(res, 404, { error: "Token not found" });
|
|
9194
9750
|
return;
|
|
9195
9751
|
}
|
|
9196
|
-
|
|
9752
|
+
sendJSON12(res, 200, { deleted: true });
|
|
9197
9753
|
} catch {
|
|
9198
|
-
|
|
9754
|
+
sendJSON12(res, 500, { error: "Internal error revoking token" });
|
|
9199
9755
|
}
|
|
9200
9756
|
}
|
|
9201
9757
|
var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -9220,12 +9776,12 @@ function handleAuthRoute(req, res, store) {
|
|
|
9220
9776
|
return true;
|
|
9221
9777
|
}
|
|
9222
9778
|
}
|
|
9223
|
-
|
|
9779
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9224
9780
|
return true;
|
|
9225
9781
|
}
|
|
9226
9782
|
|
|
9227
9783
|
// src/server/routes/local-model.ts
|
|
9228
|
-
function
|
|
9784
|
+
function sendJSON13(res, status, body) {
|
|
9229
9785
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9230
9786
|
res.end(JSON.stringify(body));
|
|
9231
9787
|
}
|
|
@@ -9233,30 +9789,30 @@ function handleLocalModelRoute(req, res, getStatus) {
|
|
|
9233
9789
|
const { method, url } = req;
|
|
9234
9790
|
if (url !== "/api/v1/local-model/status") return false;
|
|
9235
9791
|
if (method !== "GET") {
|
|
9236
|
-
|
|
9792
|
+
sendJSON13(res, 405, { error: "Method not allowed" });
|
|
9237
9793
|
return true;
|
|
9238
9794
|
}
|
|
9239
9795
|
if (!getStatus) {
|
|
9240
|
-
|
|
9796
|
+
sendJSON13(res, 503, { error: "Local backend not configured" });
|
|
9241
9797
|
return true;
|
|
9242
9798
|
}
|
|
9243
9799
|
const status = getStatus();
|
|
9244
9800
|
if (!status) {
|
|
9245
|
-
|
|
9801
|
+
sendJSON13(res, 503, { error: "Local backend not configured" });
|
|
9246
9802
|
return true;
|
|
9247
9803
|
}
|
|
9248
|
-
|
|
9804
|
+
sendJSON13(res, 200, status);
|
|
9249
9805
|
return true;
|
|
9250
9806
|
}
|
|
9251
9807
|
function handleLocalModelsRoute(req, res, getStatuses) {
|
|
9252
9808
|
const { method, url } = req;
|
|
9253
9809
|
if (url !== "/api/v1/local-models/status") return false;
|
|
9254
9810
|
if (method !== "GET") {
|
|
9255
|
-
|
|
9811
|
+
sendJSON13(res, 405, { error: "Method not allowed" });
|
|
9256
9812
|
return true;
|
|
9257
9813
|
}
|
|
9258
9814
|
const statuses = getStatuses ? getStatuses() : [];
|
|
9259
|
-
|
|
9815
|
+
sendJSON13(res, 200, statuses);
|
|
9260
9816
|
return true;
|
|
9261
9817
|
}
|
|
9262
9818
|
|
|
@@ -9623,6 +10179,60 @@ var V1_BRIDGE_ROUTES = [
|
|
|
9623
10179
|
scope: "read-telemetry",
|
|
9624
10180
|
description: "Prompt-cache hit/miss snapshot (rolling window)."
|
|
9625
10181
|
},
|
|
10182
|
+
// ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
|
|
10183
|
+
// Reuses `manage-proposals`: a forced refresh emits model proposals, so the
|
|
10184
|
+
// same write scope that governs approve/reject governs triggering the tick.
|
|
10185
|
+
{
|
|
10186
|
+
method: "POST",
|
|
10187
|
+
pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
|
|
10188
|
+
scope: "manage-proposals",
|
|
10189
|
+
description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
|
|
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
|
+
},
|
|
10206
|
+
// ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
|
|
10207
|
+
// Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
|
|
10208
|
+
// the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
|
|
10209
|
+
// `/api/local-models/<name>` and misroute it to the legacy status handler.
|
|
10210
|
+
// `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
|
|
10211
|
+
// default-deny read scope. All four are read-only observability (`read-status`).
|
|
10212
|
+
{
|
|
10213
|
+
method: "GET",
|
|
10214
|
+
pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
|
|
10215
|
+
scope: "read-status",
|
|
10216
|
+
description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
|
|
10217
|
+
},
|
|
10218
|
+
{
|
|
10219
|
+
method: "GET",
|
|
10220
|
+
pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
|
|
10221
|
+
scope: "read-status",
|
|
10222
|
+
description: "Live PoolState view, including transient pendingEviction flags."
|
|
10223
|
+
},
|
|
10224
|
+
{
|
|
10225
|
+
method: "GET",
|
|
10226
|
+
pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
|
|
10227
|
+
scope: "read-status",
|
|
10228
|
+
description: "Hardware-ranked model recommendations (top/profile query params)."
|
|
10229
|
+
},
|
|
10230
|
+
{
|
|
10231
|
+
method: "GET",
|
|
10232
|
+
pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
|
|
10233
|
+
scope: "read-status",
|
|
10234
|
+
description: "Open model-kind proposals (pending review queue)."
|
|
10235
|
+
},
|
|
9626
10236
|
// ── Spec B Phase 5 routing observability ──
|
|
9627
10237
|
// D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
|
|
9628
10238
|
// precedent (read-only observability). A dedicated `read-routing`
|
|
@@ -9765,7 +10375,19 @@ var OrchestratorServer = class {
|
|
|
9765
10375
|
getRoutingDecisionBusFn = null;
|
|
9766
10376
|
getRoutingConfigFn = null;
|
|
9767
10377
|
getBackendsFn = null;
|
|
10378
|
+
// LMLM Phase 6 — live model pool + refresh scheduler accessors.
|
|
10379
|
+
getModelPoolFn = null;
|
|
10380
|
+
getRefreshSchedulerFn = null;
|
|
10381
|
+
// LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
|
|
10382
|
+
getHardwareProfileFn = null;
|
|
10383
|
+
getRecommendationsFn = null;
|
|
10384
|
+
listModelProposalsFn = null;
|
|
10385
|
+
// LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
|
|
10386
|
+
isModelInUseFn = null;
|
|
9768
10387
|
routingDecisionUnsubscribe = null;
|
|
10388
|
+
// LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
|
|
10389
|
+
modelProposalListener = null;
|
|
10390
|
+
modelPoolListener = null;
|
|
9769
10391
|
recorder = null;
|
|
9770
10392
|
planWatcher = null;
|
|
9771
10393
|
tokenStore;
|
|
@@ -9810,6 +10432,12 @@ var OrchestratorServer = class {
|
|
|
9810
10432
|
this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
|
|
9811
10433
|
this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
|
|
9812
10434
|
this.getBackendsFn = deps?.getBackends ?? null;
|
|
10435
|
+
this.getModelPoolFn = deps?.getModelPool ?? null;
|
|
10436
|
+
this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
|
|
10437
|
+
this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
|
|
10438
|
+
this.getRecommendationsFn = deps?.getRecommendations ?? null;
|
|
10439
|
+
this.listModelProposalsFn = deps?.listModelProposals ?? null;
|
|
10440
|
+
this.isModelInUseFn = deps?.isModelInUse ?? null;
|
|
9813
10441
|
}
|
|
9814
10442
|
wireEvents() {
|
|
9815
10443
|
this.stateChangeListener = (snapshot) => {
|
|
@@ -9826,6 +10454,10 @@ var OrchestratorServer = class {
|
|
|
9826
10454
|
this.broadcaster.broadcast("routing:decision", decision);
|
|
9827
10455
|
});
|
|
9828
10456
|
}
|
|
10457
|
+
this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
|
|
10458
|
+
this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
|
|
10459
|
+
this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10460
|
+
this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
9829
10461
|
}
|
|
9830
10462
|
/**
|
|
9831
10463
|
* Broadcast a new interaction to all WebSocket clients.
|
|
@@ -9994,9 +10626,38 @@ var OrchestratorServer = class {
|
|
|
9994
10626
|
// upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
|
|
9995
10627
|
// business logic. `projectPath` defaults to process.cwd() — that is
|
|
9996
10628
|
// where `.harness/proposals/` lives in every deployment we ship.
|
|
9997
|
-
(req, res) =>
|
|
10629
|
+
(req, res) => {
|
|
10630
|
+
const modelPool = this.getModelPoolFn?.() ?? null;
|
|
10631
|
+
return handleV1ProposalsRoute(req, res, {
|
|
10632
|
+
projectPath: this.projectPath,
|
|
10633
|
+
bus: this.orchestrator,
|
|
10634
|
+
...modelPool ? { modelPool } : {},
|
|
10635
|
+
// S1: thread the in-use probe so an approved swap/evict of a model an
|
|
10636
|
+
// agent could be using is deferred (marked pendingEviction) not applied.
|
|
10637
|
+
...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
|
|
10638
|
+
});
|
|
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, {
|
|
9998
10645
|
projectPath: this.projectPath,
|
|
9999
|
-
bus: this.orchestrator
|
|
10646
|
+
bus: this.orchestrator,
|
|
10647
|
+
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10648
|
+
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
10649
|
+
...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
|
|
10650
|
+
}),
|
|
10651
|
+
// LMLM Phase 6/7 — POST /refresh + the GET read surface
|
|
10652
|
+
// (hardware/pool/recommendations/proposals). Registered before the
|
|
10653
|
+
// chat-proxy fallback so it owns the path. Each accessor is spread in
|
|
10654
|
+
// only when configured so absent ones surface as 503 (LMLM disabled).
|
|
10655
|
+
(req, res) => handleV1LocalModelsRoute(req, res, {
|
|
10656
|
+
getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
|
|
10657
|
+
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10658
|
+
...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
|
|
10659
|
+
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
10660
|
+
...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
|
|
10000
10661
|
}),
|
|
10001
10662
|
// Chat proxy route (spawns Claude Code CLI — no API key required)
|
|
10002
10663
|
(req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
|
|
@@ -10100,6 +10761,14 @@ var OrchestratorServer = class {
|
|
|
10100
10761
|
this.routingDecisionUnsubscribe();
|
|
10101
10762
|
this.routingDecisionUnsubscribe = null;
|
|
10102
10763
|
}
|
|
10764
|
+
if (this.modelProposalListener) {
|
|
10765
|
+
this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10766
|
+
this.modelProposalListener = null;
|
|
10767
|
+
}
|
|
10768
|
+
if (this.modelPoolListener) {
|
|
10769
|
+
this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10770
|
+
this.modelPoolListener = null;
|
|
10771
|
+
}
|
|
10103
10772
|
if (this.planWatcher) {
|
|
10104
10773
|
this.planWatcher.stop();
|
|
10105
10774
|
this.planWatcher = null;
|
|
@@ -11015,8 +11684,50 @@ var ENVELOPE_DERIVERS = {
|
|
|
11015
11684
|
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11016
11685
|
severity: "warning"
|
|
11017
11686
|
};
|
|
11018
|
-
}
|
|
11687
|
+
},
|
|
11688
|
+
// LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
|
|
11689
|
+
// lookup (below) so each status branch is its own small function and the
|
|
11690
|
+
// deriver stays under the cyclomatic-complexity gate.
|
|
11691
|
+
"local-models.proposal": (event) => deriveModelProposalEnvelope(event)
|
|
11692
|
+
};
|
|
11693
|
+
var MODEL_PROPOSAL_ENVELOPES = {
|
|
11694
|
+
created: (data) => {
|
|
11695
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11696
|
+
return {
|
|
11697
|
+
title: `New model proposal: ${label}`,
|
|
11698
|
+
summary: `A model proposal (${label}) is awaiting review.`,
|
|
11699
|
+
severity: "info"
|
|
11700
|
+
};
|
|
11701
|
+
},
|
|
11702
|
+
approved: (data) => {
|
|
11703
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11704
|
+
return {
|
|
11705
|
+
title: `Model proposal approved: ${label}`,
|
|
11706
|
+
summary: `The model proposal (${label}) was approved and applied to the pool.`,
|
|
11707
|
+
severity: "success"
|
|
11708
|
+
};
|
|
11709
|
+
},
|
|
11710
|
+
rejected: (data) => ({
|
|
11711
|
+
title: "Model proposal rejected",
|
|
11712
|
+
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11713
|
+
severity: "warning"
|
|
11714
|
+
}),
|
|
11715
|
+
failed_target_missing: (data) => ({
|
|
11716
|
+
title: "Model proposal target missing",
|
|
11717
|
+
summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
|
|
11718
|
+
severity: "error"
|
|
11719
|
+
})
|
|
11019
11720
|
};
|
|
11721
|
+
function deriveModelProposalEnvelope(event) {
|
|
11722
|
+
const data = asObj(event.data);
|
|
11723
|
+
const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
|
|
11724
|
+
if (build) return build(data);
|
|
11725
|
+
return {
|
|
11726
|
+
title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
|
|
11727
|
+
summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
|
|
11728
|
+
severity: "info"
|
|
11729
|
+
};
|
|
11730
|
+
}
|
|
11020
11731
|
function truncate(s, max) {
|
|
11021
11732
|
return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
|
|
11022
11733
|
}
|
|
@@ -11063,7 +11774,9 @@ var NOTIFICATION_TOPICS = [
|
|
|
11063
11774
|
// Hermes Phase 4 — skill proposal lifecycle.
|
|
11064
11775
|
"proposal.created",
|
|
11065
11776
|
"proposal.approved",
|
|
11066
|
-
"proposal.rejected"
|
|
11777
|
+
"proposal.rejected",
|
|
11778
|
+
// LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
|
|
11779
|
+
"local-models:proposal"
|
|
11067
11780
|
];
|
|
11068
11781
|
function newEventId3() {
|
|
11069
11782
|
return `evt_${randomBytes7(8).toString("hex")}`;
|
|
@@ -13156,6 +13869,45 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13156
13869
|
* so this map is the single source of truth post-migration.
|
|
13157
13870
|
*/
|
|
13158
13871
|
localResolvers = /* @__PURE__ */ new Map();
|
|
13872
|
+
/** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
|
|
13873
|
+
poolStateProvider = null;
|
|
13874
|
+
poolStateStore = null;
|
|
13875
|
+
/**
|
|
13876
|
+
* LMLM Phase 6: live model pool + its installer. Constructed only when
|
|
13877
|
+
* `localModels.enabled` and a real `PoolStateStore` exists (not a test
|
|
13878
|
+
* override). Exposed to the server via `getModelPool()`, which retires the
|
|
13879
|
+
* proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
|
|
13880
|
+
* is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
|
|
13881
|
+
* before `store.load()` (in initLocalModelAndPipeline) is safe.
|
|
13882
|
+
*/
|
|
13883
|
+
modelPool = null;
|
|
13884
|
+
modelInstaller = null;
|
|
13885
|
+
/**
|
|
13886
|
+
* S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
|
|
13887
|
+
* is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
|
|
13888
|
+
* piggybacked on each refresh tick). Two overlapping drains would both read the
|
|
13889
|
+
* same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
|
|
13890
|
+
* both `await pool.evict` the SAME name — double-calling the installer and
|
|
13891
|
+
* broadcasting a duplicate `evict_completed` frame. The single-threaded event
|
|
13892
|
+
* loop makes a plain boolean sufficient: a drain that arrives while one is
|
|
13893
|
+
* running returns early rather than double-processing.
|
|
13894
|
+
*/
|
|
13895
|
+
draining = false;
|
|
13896
|
+
/**
|
|
13897
|
+
* LMLM Phase 6: single per-instance background refresh scheduler. Started in
|
|
13898
|
+
* `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
|
|
13899
|
+
* when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
|
|
13900
|
+
*/
|
|
13901
|
+
refreshScheduler = null;
|
|
13902
|
+
/**
|
|
13903
|
+
* LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
|
|
13904
|
+
* by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
|
|
13905
|
+
* pool → scheduler never armed). Ranks the (currently empty) candidate set —
|
|
13906
|
+
* see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
|
|
13907
|
+
*/
|
|
13908
|
+
modelRecommender = null;
|
|
13909
|
+
/** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
|
|
13910
|
+
schedulerTimerOverride;
|
|
13159
13911
|
/**
|
|
13160
13912
|
* Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
|
|
13161
13913
|
* construction from `projectRoot/agents/skills/`. Consulted by
|
|
@@ -13246,6 +13998,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13246
13998
|
*/
|
|
13247
13999
|
constructor(config, promptTemplate, overrides) {
|
|
13248
14000
|
super();
|
|
14001
|
+
this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
|
|
13249
14002
|
this.setMaxListeners(50);
|
|
13250
14003
|
this.config = config;
|
|
13251
14004
|
this.promptTemplate = promptTemplate;
|
|
@@ -13293,6 +14046,16 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13293
14046
|
this
|
|
13294
14047
|
);
|
|
13295
14048
|
this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
|
|
14049
|
+
const localModelsEnabled = this.config.localModels?.enabled === true;
|
|
14050
|
+
if (overrides?.poolState) {
|
|
14051
|
+
this.poolStateProvider = overrides.poolState;
|
|
14052
|
+
} else if (localModelsEnabled) {
|
|
14053
|
+
this.poolStateStore = new PoolStateStore({
|
|
14054
|
+
onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
|
|
14055
|
+
});
|
|
14056
|
+
this.poolStateProvider = this.poolStateStore;
|
|
14057
|
+
this.initModelPool(this.poolStateStore);
|
|
14058
|
+
}
|
|
13296
14059
|
const backendsMap = this.config.agent.backends ?? {};
|
|
13297
14060
|
for (const [name, def] of Object.entries(backendsMap)) {
|
|
13298
14061
|
if (def.type === "local" || def.type === "pi") {
|
|
@@ -13303,6 +14066,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13303
14066
|
};
|
|
13304
14067
|
if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
|
|
13305
14068
|
if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
|
|
14069
|
+
if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
|
|
13306
14070
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
13307
14071
|
}
|
|
13308
14072
|
}
|
|
@@ -13414,7 +14178,26 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13414
14178
|
roadmapPath: config.tracker.filePath ?? null,
|
|
13415
14179
|
dispatchAdHoc: this.dispatchAdHoc.bind(this),
|
|
13416
14180
|
getLocalModelStatus: () => this.getFirstLocalModelStatus(),
|
|
13417
|
-
getLocalModelStatuses: () => this.buildLocalModelStatuses()
|
|
14181
|
+
getLocalModelStatuses: () => this.buildLocalModelStatuses(),
|
|
14182
|
+
// LMLM Phase 6: expose the live pool so kind:'model' approve/reject
|
|
14183
|
+
// reaches PoolManager (retiring the 501). Null when LMLM is disabled.
|
|
14184
|
+
getModelPool: () => this.modelPool,
|
|
14185
|
+
// LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
|
|
14186
|
+
// of a model an agent could be using is DEFERRED, not applied mid-request
|
|
14187
|
+
// (ADR 0060). Agent-run-coarse; may over-defer (safe).
|
|
14188
|
+
isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
|
|
14189
|
+
// LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
|
|
14190
|
+
getRefreshScheduler: () => this.refreshScheduler,
|
|
14191
|
+
// LMLM Phase 7 read surface — hardware / recommendations / model proposals.
|
|
14192
|
+
// Each returns null/[] when LMLM is disabled so the route renders 503/[].
|
|
14193
|
+
getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
|
|
14194
|
+
getRecommendations: async ({ top }) => {
|
|
14195
|
+
if (this.modelRecommender === null) return [];
|
|
14196
|
+
const hardware = await this.detectLmlmHardware();
|
|
14197
|
+
const { ranked } = await this.modelRecommender(hardware);
|
|
14198
|
+
return ranked.slice(0, top);
|
|
14199
|
+
},
|
|
14200
|
+
listModelProposals: () => listProposals2(this.projectRoot, { status: "open", kind: "model" })
|
|
13418
14201
|
});
|
|
13419
14202
|
this.server.setRecorder(this.recorder);
|
|
13420
14203
|
this.interactionQueue.onPush((interaction) => {
|
|
@@ -13472,6 +14255,60 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13472
14255
|
}
|
|
13473
14256
|
return out;
|
|
13474
14257
|
}
|
|
14258
|
+
/**
|
|
14259
|
+
* S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
|
|
14260
|
+
* is live AND `ollamaName` is a currently-resolved (or last-detected) local
|
|
14261
|
+
* model — i.e. an agent could be routing inference to it right now.
|
|
14262
|
+
*
|
|
14263
|
+
* This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
|
|
14264
|
+
* by GitHub issue (spawned agent runs), NOT by inference call, and no
|
|
14265
|
+
* per-model request counter exists today. The probe therefore MAY OVER-DEFER
|
|
14266
|
+
* (a swap waits until the pool is idle). Over-deferral is exactly S1's
|
|
14267
|
+
* intended safe failure — never yank a model mid-request; occasionally wait
|
|
14268
|
+
* longer than strictly necessary. A fine-grained per-request signal is an
|
|
14269
|
+
* explicit deferred gap (ADR 0060).
|
|
14270
|
+
*/
|
|
14271
|
+
isLocalModelInUse(ollamaName) {
|
|
14272
|
+
if (this.state.running.size === 0) return false;
|
|
14273
|
+
return this.buildLocalModelStatuses().some(
|
|
14274
|
+
(s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
|
|
14275
|
+
);
|
|
14276
|
+
}
|
|
14277
|
+
/**
|
|
14278
|
+
* S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
|
|
14279
|
+
* target was in use, now that the probe reports it idle. Best-effort — called
|
|
14280
|
+
* from the run-completion path; it never blocks dispatch and swallows
|
|
14281
|
+
* per-model errors (a failed or still-busy evict stays pending for the next
|
|
14282
|
+
* drain). `pendingEviction` is a transient overlay, so a missed drain simply
|
|
14283
|
+
* leaves the flag set until the next completion re-checks it.
|
|
14284
|
+
*/
|
|
14285
|
+
async drainDeferredEvictions() {
|
|
14286
|
+
const pool = this.modelPool;
|
|
14287
|
+
if (pool === null) return;
|
|
14288
|
+
if (this.draining) return;
|
|
14289
|
+
this.draining = true;
|
|
14290
|
+
try {
|
|
14291
|
+
for (const ollamaName of pool.listPendingEvictions()) {
|
|
14292
|
+
if (this.isLocalModelInUse(ollamaName)) continue;
|
|
14293
|
+
try {
|
|
14294
|
+
const result = await pool.evict({ ollamaName });
|
|
14295
|
+
if (result.status === "error") continue;
|
|
14296
|
+
pool.clearPendingEviction(ollamaName);
|
|
14297
|
+
this.emit("local-models:pool", {
|
|
14298
|
+
action: "evict",
|
|
14299
|
+
// XP-2: `evicted` is uniformly string[] across all local-models:pool
|
|
14300
|
+
// emit sites — the drain wraps its single completed eviction in an
|
|
14301
|
+
// array to match the swap/add multi-evict shape.
|
|
14302
|
+
evicted: [ollamaName],
|
|
14303
|
+
phase: "evict_completed"
|
|
14304
|
+
});
|
|
14305
|
+
} catch {
|
|
14306
|
+
}
|
|
14307
|
+
}
|
|
14308
|
+
} finally {
|
|
14309
|
+
this.draining = false;
|
|
14310
|
+
}
|
|
14311
|
+
}
|
|
13475
14312
|
createTracker() {
|
|
13476
14313
|
if (this.config.tracker.kind === "github-issues") {
|
|
13477
14314
|
const trackerCfg = {
|
|
@@ -14318,6 +15155,7 @@ ${messages}`);
|
|
|
14318
15155
|
error,
|
|
14319
15156
|
(effect) => this.handleEffect(effect)
|
|
14320
15157
|
);
|
|
15158
|
+
void this.drainDeferredEvictions();
|
|
14321
15159
|
this.emit("state_change", this.getSnapshot());
|
|
14322
15160
|
}
|
|
14323
15161
|
/**
|
|
@@ -14405,6 +15243,120 @@ ${messages}`);
|
|
|
14405
15243
|
* before constructing the intelligence pipeline. Subscribes the dashboard
|
|
14406
15244
|
* broadcast stub to status changes. Called exactly once from start().
|
|
14407
15245
|
*/
|
|
15246
|
+
/**
|
|
15247
|
+
* LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
|
|
15248
|
+
* loaded pool-state store) and stash it for `getModelPool()`. Defensive
|
|
15249
|
+
* config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
|
|
15250
|
+
* `store.snapshot()` lazily, so this runs safely at construction time before
|
|
15251
|
+
* `store.load()`.
|
|
15252
|
+
*/
|
|
15253
|
+
initModelPool(store) {
|
|
15254
|
+
const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
|
|
15255
|
+
const installerCfg = this.config.localModels?.installer;
|
|
15256
|
+
this.modelInstaller = new OllamaInstallAdapter({
|
|
15257
|
+
baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
|
|
15258
|
+
onWarn
|
|
15259
|
+
});
|
|
15260
|
+
this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
|
|
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
|
+
}
|
|
15280
|
+
/**
|
|
15281
|
+
* LMLM Phase 6: arm the single background refresh scheduler over the live
|
|
15282
|
+
* pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
|
|
15283
|
+
* hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
|
|
15284
|
+
*
|
|
15285
|
+
* NOTE (deferred): the recommender is seeded with an empty candidate set —
|
|
15286
|
+
* Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
|
|
15287
|
+
* swap-proposal discovery is out of scope here (flagged concern). The tick
|
|
15288
|
+
* still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
|
|
15289
|
+
* whatever candidates are supplied — the wiring is complete and candidate
|
|
15290
|
+
* breadth is the only piece deferred to the Phase 2 recommender.
|
|
15291
|
+
*/
|
|
15292
|
+
startRefreshScheduler() {
|
|
15293
|
+
if (this.modelPool === null) return;
|
|
15294
|
+
const pool = this.modelPool;
|
|
15295
|
+
const refreshCfg = this.config.localModels?.refresh;
|
|
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 });
|
|
15308
|
+
this.modelRecommender = recommend;
|
|
15309
|
+
this.refreshScheduler = new RefreshScheduler({
|
|
15310
|
+
runTick: () => runRefreshTick({
|
|
15311
|
+
detectHardware: () => this.detectLmlmHardware(),
|
|
15312
|
+
recommend,
|
|
15313
|
+
poolManager: pool,
|
|
15314
|
+
dedupSource: () => this.lmlmDedupSource(),
|
|
15315
|
+
// Phase 7: after persisting the proposal, emit `local-models:proposal`
|
|
15316
|
+
// (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
|
|
15317
|
+
// notification sinks. Literal to avoid a proposals/model-handlers cycle.
|
|
15318
|
+
emitProposal: (c) => createModelProposal2(this.projectRoot, c).then((record) => {
|
|
15319
|
+
this.emit("local-models:proposal", {
|
|
15320
|
+
id: record.id,
|
|
15321
|
+
status: "created",
|
|
15322
|
+
action: c.action,
|
|
15323
|
+
target: c.target.ollamaName
|
|
15324
|
+
});
|
|
15325
|
+
}),
|
|
15326
|
+
proposalThreshold: refreshCfg?.proposalThreshold ?? 5
|
|
15327
|
+
}).then((result) => {
|
|
15328
|
+
void this.drainDeferredEvictions();
|
|
15329
|
+
return result;
|
|
15330
|
+
}),
|
|
15331
|
+
intervalMs: refreshCfg?.intervalMs ?? 864e5,
|
|
15332
|
+
jitterMs: refreshCfg?.jitterMs ?? 6e5,
|
|
15333
|
+
logger: this.logger,
|
|
15334
|
+
...this.schedulerTimerOverride ?? {}
|
|
15335
|
+
});
|
|
15336
|
+
this.refreshScheduler.start();
|
|
15337
|
+
}
|
|
15338
|
+
/** Resolve the hardware profile for a refresh tick (operator override wins). */
|
|
15339
|
+
async detectLmlmHardware() {
|
|
15340
|
+
const override = this.config.localModels?.hardware?.override;
|
|
15341
|
+
const detector = new HardwareDetector(override !== void 0 ? { override } : {});
|
|
15342
|
+
return (await detector.detect()).profile;
|
|
15343
|
+
}
|
|
15344
|
+
/** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
|
|
15345
|
+
async lmlmDedupSource() {
|
|
15346
|
+
const proposals = await listProposals2(this.projectRoot, { kind: "model" });
|
|
15347
|
+
const pending = [];
|
|
15348
|
+
const rejected = [];
|
|
15349
|
+
for (const p of proposals) {
|
|
15350
|
+
if (p.kind !== "model") continue;
|
|
15351
|
+
const pair = {
|
|
15352
|
+
target: p.model.target.ollamaName,
|
|
15353
|
+
...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
|
|
15354
|
+
};
|
|
15355
|
+
if (p.status === "open") pending.push(pair);
|
|
15356
|
+
else if (p.status === "rejected") rejected.push(pair);
|
|
15357
|
+
}
|
|
15358
|
+
return { pending, rejected };
|
|
15359
|
+
}
|
|
14408
15360
|
async initLocalModelAndPipeline() {
|
|
14409
15361
|
if (this.localResolvers.size > 0) {
|
|
14410
15362
|
const backends = this.config.agent.backends ?? {};
|
|
@@ -14427,10 +15379,15 @@ ${messages}`);
|
|
|
14427
15379
|
});
|
|
14428
15380
|
this.localModelStatusUnsubscribes.push(unsubscribe);
|
|
14429
15381
|
}
|
|
15382
|
+
if (this.poolStateStore !== null) {
|
|
15383
|
+
await this.poolStateStore.load();
|
|
15384
|
+
await this.applyConfiguredPoolBounds();
|
|
15385
|
+
}
|
|
14430
15386
|
for (const resolver of this.localResolvers.values()) {
|
|
14431
15387
|
await resolver.start();
|
|
14432
15388
|
}
|
|
14433
15389
|
}
|
|
15390
|
+
this.startRefreshScheduler();
|
|
14434
15391
|
this.pipeline = this.createIntelligencePipeline();
|
|
14435
15392
|
this.server?.setPipeline(this.pipeline);
|
|
14436
15393
|
}
|
|
@@ -14506,6 +15463,8 @@ ${messages}`);
|
|
|
14506
15463
|
for (const resolver of this.localResolvers.values()) {
|
|
14507
15464
|
resolver.stop();
|
|
14508
15465
|
}
|
|
15466
|
+
this.refreshScheduler?.stop();
|
|
15467
|
+
this.refreshScheduler = null;
|
|
14509
15468
|
if (this.maintenanceScheduler) {
|
|
14510
15469
|
this.maintenanceScheduler.stop();
|
|
14511
15470
|
this.maintenanceScheduler = null;
|