@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.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
|
|
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
|
|
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");
|
|
@@ -3881,6 +3881,7 @@ var AgentRunner = class {
|
|
|
3881
3881
|
};
|
|
3882
3882
|
|
|
3883
3883
|
// src/agent/local-model-resolver.ts
|
|
3884
|
+
var import_local_models = require("@harness-engineering/local-models");
|
|
3884
3885
|
var DEFAULT_PROBE_INTERVAL_MS = 3e4;
|
|
3885
3886
|
var MIN_PROBE_INTERVAL_MS = 1e3;
|
|
3886
3887
|
var DEFAULT_API_KEY = "lm-studio";
|
|
@@ -3937,6 +3938,7 @@ var LocalModelResolver = class {
|
|
|
3937
3938
|
endpoint;
|
|
3938
3939
|
apiKey;
|
|
3939
3940
|
configured;
|
|
3941
|
+
poolState;
|
|
3940
3942
|
probeIntervalMs;
|
|
3941
3943
|
fetchModels;
|
|
3942
3944
|
logger;
|
|
@@ -3965,6 +3967,9 @@ var LocalModelResolver = class {
|
|
|
3965
3967
|
this.apiKey = opts.apiKey;
|
|
3966
3968
|
}
|
|
3967
3969
|
this.configured = [...opts.configured];
|
|
3970
|
+
if (opts.poolState !== void 0) {
|
|
3971
|
+
this.poolState = opts.poolState;
|
|
3972
|
+
}
|
|
3968
3973
|
const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
|
3969
3974
|
this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
|
|
3970
3975
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
@@ -3978,13 +3983,21 @@ var LocalModelResolver = class {
|
|
|
3978
3983
|
return {
|
|
3979
3984
|
available: this.available,
|
|
3980
3985
|
resolved: this.resolved,
|
|
3981
|
-
configured:
|
|
3986
|
+
configured: this.candidates(),
|
|
3982
3987
|
detected: [...this.detected],
|
|
3983
3988
|
lastProbeAt: this.lastProbeAt,
|
|
3984
3989
|
lastError: this.lastError,
|
|
3985
3990
|
warnings: [...this.warnings]
|
|
3986
3991
|
};
|
|
3987
3992
|
}
|
|
3993
|
+
/**
|
|
3994
|
+
* Effective candidate list. With a poolState port present the list derives
|
|
3995
|
+
* from pool entries (currentScore desc → ollamaName); otherwise the static
|
|
3996
|
+
* `configured` list is returned unchanged (byte-identical to pre-Phase-4).
|
|
3997
|
+
*/
|
|
3998
|
+
candidates() {
|
|
3999
|
+
return this.poolState ? (0, import_local_models.poolStateToCandidates)(this.poolState.snapshot()) : [...this.configured];
|
|
4000
|
+
}
|
|
3988
4001
|
onStatusChange(handler) {
|
|
3989
4002
|
this.listeners.add(handler);
|
|
3990
4003
|
return () => {
|
|
@@ -4008,11 +4021,12 @@ var LocalModelResolver = class {
|
|
|
4008
4021
|
this.detected = [...detected];
|
|
4009
4022
|
this.lastError = null;
|
|
4010
4023
|
this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4011
|
-
const
|
|
4024
|
+
const candidates = this.candidates();
|
|
4025
|
+
const match = candidates.find((id) => detected.includes(id)) ?? null;
|
|
4012
4026
|
this.resolved = match;
|
|
4013
4027
|
this.available = match !== null;
|
|
4014
4028
|
this.warnings = match ? [] : [
|
|
4015
|
-
`No configured local model is loaded. Configured: [${
|
|
4029
|
+
`No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
|
|
4016
4030
|
];
|
|
4017
4031
|
} catch (err) {
|
|
4018
4032
|
const message = err instanceof Error ? err.message : "probe failed";
|
|
@@ -4061,7 +4075,7 @@ var LocalModelResolver = class {
|
|
|
4061
4075
|
return JSON.stringify({
|
|
4062
4076
|
available: this.available,
|
|
4063
4077
|
resolved: this.resolved,
|
|
4064
|
-
configured: this.
|
|
4078
|
+
configured: this.candidates(),
|
|
4065
4079
|
detected: this.detected,
|
|
4066
4080
|
lastError: this.lastError,
|
|
4067
4081
|
warnings: this.warnings
|
|
@@ -4069,6 +4083,10 @@ var LocalModelResolver = class {
|
|
|
4069
4083
|
}
|
|
4070
4084
|
};
|
|
4071
4085
|
|
|
4086
|
+
// src/orchestrator.ts
|
|
4087
|
+
var import_local_models4 = require("@harness-engineering/local-models");
|
|
4088
|
+
var import_core18 = require("@harness-engineering/core");
|
|
4089
|
+
|
|
4072
4090
|
// src/agent/config-migration.ts
|
|
4073
4091
|
var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
|
|
4074
4092
|
var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
|
|
@@ -7031,7 +7049,7 @@ function buildRoutingUseCase(issue, backendParam, catalog) {
|
|
|
7031
7049
|
// src/server/http.ts
|
|
7032
7050
|
var http = __toESM(require("http"));
|
|
7033
7051
|
var path17 = __toESM(require("path"));
|
|
7034
|
-
var
|
|
7052
|
+
var import_core12 = require("@harness-engineering/core");
|
|
7035
7053
|
|
|
7036
7054
|
// src/server/websocket.ts
|
|
7037
7055
|
var import_ws = require("ws");
|
|
@@ -8395,10 +8413,10 @@ function checkDiff(diff) {
|
|
|
8395
8413
|
function deriveFindings(proposal) {
|
|
8396
8414
|
const findings = [];
|
|
8397
8415
|
findings.push(...checkName(proposal.content.name));
|
|
8398
|
-
if (proposal.
|
|
8416
|
+
if (proposal.skillKind === "new-skill") {
|
|
8399
8417
|
findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
|
|
8400
8418
|
findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
|
|
8401
|
-
} else if (proposal.
|
|
8419
|
+
} else if (proposal.skillKind === "refinement") {
|
|
8402
8420
|
findings.push(...checkDiff(proposal.content.diff ?? ""));
|
|
8403
8421
|
}
|
|
8404
8422
|
return findings;
|
|
@@ -8406,6 +8424,9 @@ function deriveFindings(proposal) {
|
|
|
8406
8424
|
async function runGate(projectPath, proposalId) {
|
|
8407
8425
|
const proposal = await (0, import_core8.getProposal)(projectPath, proposalId);
|
|
8408
8426
|
if (!proposal) throw new import_core8.ProposalNotFoundError(proposalId);
|
|
8427
|
+
if (proposal.kind !== "skill") {
|
|
8428
|
+
throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
|
|
8429
|
+
}
|
|
8409
8430
|
if (proposal.status === "approved" || proposal.status === "rejected") {
|
|
8410
8431
|
throw new GateRunError(
|
|
8411
8432
|
`proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
|
|
@@ -8531,8 +8552,11 @@ async function promoteRefinement(projectPath, proposal) {
|
|
|
8531
8552
|
async function promote(projectPath, proposalId, decidedBy) {
|
|
8532
8553
|
const proposal = await (0, import_core9.getProposal)(projectPath, proposalId);
|
|
8533
8554
|
if (!proposal) throw new import_core9.ProposalNotFoundError(proposalId);
|
|
8555
|
+
if (proposal.kind !== "skill") {
|
|
8556
|
+
throw new PromotionError("only skill proposals are promotable to the catalog");
|
|
8557
|
+
}
|
|
8534
8558
|
assertGateReady(proposal);
|
|
8535
|
-
const out = proposal.
|
|
8559
|
+
const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
|
|
8536
8560
|
await (0, import_core9.updateProposal)(projectPath, proposalId, {
|
|
8537
8561
|
status: "approved",
|
|
8538
8562
|
decision: {
|
|
@@ -8555,7 +8579,7 @@ function emit3(bus, topic, data) {
|
|
|
8555
8579
|
function emitProposalCreated(bus, proposal) {
|
|
8556
8580
|
const data = {
|
|
8557
8581
|
id: proposal.id,
|
|
8558
|
-
kind: proposal.
|
|
8582
|
+
kind: proposal.skillKind,
|
|
8559
8583
|
name: proposal.content.name,
|
|
8560
8584
|
proposedBy: proposal.proposedBy,
|
|
8561
8585
|
justification: proposal.source.justification
|
|
@@ -8566,7 +8590,7 @@ function emitProposalCreated(bus, proposal) {
|
|
|
8566
8590
|
function emitProposalApproved(bus, proposal) {
|
|
8567
8591
|
const data = {
|
|
8568
8592
|
id: proposal.id,
|
|
8569
|
-
kind: proposal.
|
|
8593
|
+
kind: proposal.skillKind,
|
|
8570
8594
|
name: proposal.content.name,
|
|
8571
8595
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
|
|
8572
8596
|
};
|
|
@@ -8576,7 +8600,7 @@ function emitProposalApproved(bus, proposal) {
|
|
|
8576
8600
|
function emitProposalRejected(bus, proposal) {
|
|
8577
8601
|
const data = {
|
|
8578
8602
|
id: proposal.id,
|
|
8579
|
-
kind: proposal.
|
|
8603
|
+
kind: proposal.skillKind,
|
|
8580
8604
|
name: proposal.content.name,
|
|
8581
8605
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
|
|
8582
8606
|
reason: proposal.decision?.reason ?? "(no reason given)"
|
|
@@ -8584,6 +8608,163 @@ function emitProposalRejected(bus, proposal) {
|
|
|
8584
8608
|
emit3(bus, "proposal.rejected", data);
|
|
8585
8609
|
}
|
|
8586
8610
|
|
|
8611
|
+
// src/proposals/model-handlers.ts
|
|
8612
|
+
function isInUse(deps, ollamaName) {
|
|
8613
|
+
return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
|
|
8614
|
+
}
|
|
8615
|
+
var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
|
|
8616
|
+
var MODEL_POOL_TOPIC = "local-models:pool";
|
|
8617
|
+
function decisionOf(deps, action, reason) {
|
|
8618
|
+
return {
|
|
8619
|
+
decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
8620
|
+
decidedBy: deps.decidedBy ?? "orchestrator",
|
|
8621
|
+
action,
|
|
8622
|
+
...reason !== void 0 ? { reason } : {}
|
|
8623
|
+
};
|
|
8624
|
+
}
|
|
8625
|
+
function emitApproved(deps, proposal) {
|
|
8626
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8627
|
+
id: proposal.id,
|
|
8628
|
+
status: "approved",
|
|
8629
|
+
action: proposal.model.action,
|
|
8630
|
+
target: proposal.model.target.ollamaName
|
|
8631
|
+
});
|
|
8632
|
+
}
|
|
8633
|
+
async function deferEviction(deps, proposal, deferredName, event, evicted) {
|
|
8634
|
+
deps.pool.markPendingEviction(deferredName);
|
|
8635
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8636
|
+
status: "approved",
|
|
8637
|
+
decision: decisionOf(deps, "approved")
|
|
8638
|
+
});
|
|
8639
|
+
deps.bus.emit(MODEL_POOL_TOPIC, event);
|
|
8640
|
+
emitApproved(deps, proposal);
|
|
8641
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8642
|
+
}
|
|
8643
|
+
async function onApproveModelProposal(deps, proposal) {
|
|
8644
|
+
const { model } = proposal;
|
|
8645
|
+
if (model.action === "evict") {
|
|
8646
|
+
return applyEvictOnly(deps, proposal);
|
|
8647
|
+
}
|
|
8648
|
+
const installResult = await deps.pool.install({
|
|
8649
|
+
hfRepoId: model.target.hfRepoId,
|
|
8650
|
+
ollamaName: model.target.ollamaName,
|
|
8651
|
+
...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
|
|
8652
|
+
...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
|
|
8653
|
+
});
|
|
8654
|
+
if (installResult.status === "error") {
|
|
8655
|
+
if (installResult.code === "failed_target_missing") {
|
|
8656
|
+
const updated2 = await deps.updateProposal(proposal.id, {
|
|
8657
|
+
status: "failed_target_missing"
|
|
8658
|
+
});
|
|
8659
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8660
|
+
id: proposal.id,
|
|
8661
|
+
status: "failed_target_missing",
|
|
8662
|
+
action: model.action,
|
|
8663
|
+
target: model.target.ollamaName
|
|
8664
|
+
});
|
|
8665
|
+
return { status: "failed_target_missing", proposal: updated2 };
|
|
8666
|
+
}
|
|
8667
|
+
return { status: "error", code: installResult.code, message: installResult.message };
|
|
8668
|
+
}
|
|
8669
|
+
const evicted = [...installResult.evicted];
|
|
8670
|
+
if (model.action === "swap" && model.replaces !== void 0) {
|
|
8671
|
+
const replacesName = model.replaces.ollamaName;
|
|
8672
|
+
if (isInUse(deps, replacesName)) {
|
|
8673
|
+
return deferEviction(
|
|
8674
|
+
deps,
|
|
8675
|
+
proposal,
|
|
8676
|
+
replacesName,
|
|
8677
|
+
{
|
|
8678
|
+
id: proposal.id,
|
|
8679
|
+
action: model.action,
|
|
8680
|
+
installed: model.target.ollamaName,
|
|
8681
|
+
phase: "evict_deferred",
|
|
8682
|
+
deferred: replacesName,
|
|
8683
|
+
...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
|
|
8684
|
+
},
|
|
8685
|
+
evicted
|
|
8686
|
+
);
|
|
8687
|
+
}
|
|
8688
|
+
const evictResult = await deps.pool.evict({ ollamaName: replacesName });
|
|
8689
|
+
if (evictResult.status === "error") {
|
|
8690
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8691
|
+
id: proposal.id,
|
|
8692
|
+
action: model.action,
|
|
8693
|
+
installed: model.target.ollamaName,
|
|
8694
|
+
phase: "swap_evict_failed"
|
|
8695
|
+
});
|
|
8696
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8697
|
+
}
|
|
8698
|
+
if (evictResult.removed !== null) {
|
|
8699
|
+
evicted.push(evictResult.removed);
|
|
8700
|
+
}
|
|
8701
|
+
}
|
|
8702
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8703
|
+
status: "approved",
|
|
8704
|
+
decision: decisionOf(deps, "approved")
|
|
8705
|
+
});
|
|
8706
|
+
const evictedNames = [
|
|
8707
|
+
...installResult.evicted.map((e) => e.ollamaName),
|
|
8708
|
+
...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
|
|
8709
|
+
];
|
|
8710
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8711
|
+
id: proposal.id,
|
|
8712
|
+
action: model.action,
|
|
8713
|
+
installed: model.target.ollamaName,
|
|
8714
|
+
...evictedNames.length > 0 ? { evicted: evictedNames } : {}
|
|
8715
|
+
});
|
|
8716
|
+
emitApproved(deps, proposal);
|
|
8717
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8718
|
+
}
|
|
8719
|
+
async function applyEvictOnly(deps, proposal) {
|
|
8720
|
+
const targetName = proposal.model.target.ollamaName;
|
|
8721
|
+
if (isInUse(deps, targetName)) {
|
|
8722
|
+
return deferEviction(
|
|
8723
|
+
deps,
|
|
8724
|
+
proposal,
|
|
8725
|
+
targetName,
|
|
8726
|
+
{ id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
|
|
8727
|
+
[]
|
|
8728
|
+
);
|
|
8729
|
+
}
|
|
8730
|
+
const evictResult = await deps.pool.evict({ ollamaName: targetName });
|
|
8731
|
+
if (evictResult.status === "error") {
|
|
8732
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8733
|
+
}
|
|
8734
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8735
|
+
status: "approved",
|
|
8736
|
+
decision: decisionOf(deps, "approved")
|
|
8737
|
+
});
|
|
8738
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8739
|
+
id: proposal.id,
|
|
8740
|
+
action: "evict",
|
|
8741
|
+
// XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
|
|
8742
|
+
// list multiple removals) — the evict-only path wraps its single removal in
|
|
8743
|
+
// an array too, so consumers never branch on string-vs-array.
|
|
8744
|
+
evicted: [proposal.model.target.ollamaName]
|
|
8745
|
+
});
|
|
8746
|
+
emitApproved(deps, proposal);
|
|
8747
|
+
return {
|
|
8748
|
+
status: "approved",
|
|
8749
|
+
proposal: updated,
|
|
8750
|
+
evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
|
|
8751
|
+
};
|
|
8752
|
+
}
|
|
8753
|
+
async function onRejectModelProposal(deps, proposal, reason) {
|
|
8754
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8755
|
+
status: "rejected",
|
|
8756
|
+
decision: decisionOf(deps, "rejected", reason)
|
|
8757
|
+
});
|
|
8758
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8759
|
+
id: proposal.id,
|
|
8760
|
+
status: "rejected",
|
|
8761
|
+
target: proposal.model.target.ollamaName,
|
|
8762
|
+
replaces: proposal.model.replaces?.ollamaName,
|
|
8763
|
+
reason
|
|
8764
|
+
});
|
|
8765
|
+
return updated;
|
|
8766
|
+
}
|
|
8767
|
+
|
|
8587
8768
|
// src/server/routes/v1/proposals.ts
|
|
8588
8769
|
var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
|
|
8589
8770
|
var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -8609,6 +8790,20 @@ function getDecidedBy(req, deps) {
|
|
|
8609
8790
|
const token = req._authToken;
|
|
8610
8791
|
return token?.id ?? "unknown";
|
|
8611
8792
|
}
|
|
8793
|
+
function modelHandlerDeps(deps, req) {
|
|
8794
|
+
return {
|
|
8795
|
+
pool: deps.modelPool,
|
|
8796
|
+
bus: deps.bus,
|
|
8797
|
+
// Core's `updateProposal` patch type intersects the skill+model status
|
|
8798
|
+
// enums, which statically excludes model-only statuses like
|
|
8799
|
+
// `failed_target_missing`. The runtime parses through `ProposalSchema` (the
|
|
8800
|
+
// model variant accepts that status), so the cast is sound — only the
|
|
8801
|
+
// intersection type is too narrow.
|
|
8802
|
+
updateProposal: (id, patch) => (0, import_core10.updateProposal)(deps.projectPath, id, patch),
|
|
8803
|
+
decidedBy: getDecidedBy(req, deps),
|
|
8804
|
+
...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
|
|
8805
|
+
};
|
|
8806
|
+
}
|
|
8612
8807
|
function parseStatusFromQuery(url) {
|
|
8613
8808
|
const queryIdx = url.indexOf("?");
|
|
8614
8809
|
if (queryIdx === -1) return void 0;
|
|
@@ -8653,11 +8848,31 @@ async function handleRunGate(res, deps, id) {
|
|
|
8653
8848
|
}
|
|
8654
8849
|
}
|
|
8655
8850
|
async function handleApprove(req, res, deps, id) {
|
|
8851
|
+
const existing = await (0, import_core10.getProposal)(deps.projectPath, id);
|
|
8852
|
+
if (!existing) {
|
|
8853
|
+
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8854
|
+
return;
|
|
8855
|
+
}
|
|
8856
|
+
if (existing.kind === "model") {
|
|
8857
|
+
if (!deps.modelPool) {
|
|
8858
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8859
|
+
return;
|
|
8860
|
+
}
|
|
8861
|
+
if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
|
|
8862
|
+
sendJSON8(res, 409, {
|
|
8863
|
+
error: `proposal already ${existing.status}; cannot approve`
|
|
8864
|
+
});
|
|
8865
|
+
return;
|
|
8866
|
+
}
|
|
8867
|
+
const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
|
|
8868
|
+
sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
|
|
8869
|
+
return;
|
|
8870
|
+
}
|
|
8656
8871
|
const decidedBy = getDecidedBy(req, deps);
|
|
8657
8872
|
try {
|
|
8658
8873
|
const result = await promote(deps.projectPath, id, decidedBy);
|
|
8659
8874
|
const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
|
|
8660
|
-
if (proposal) emitProposalApproved(deps.bus, proposal);
|
|
8875
|
+
if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
|
|
8661
8876
|
sendJSON8(res, 200, { promotion: result, proposal });
|
|
8662
8877
|
} catch (err) {
|
|
8663
8878
|
if (err instanceof import_core10.ProposalNotFoundError) {
|
|
@@ -8709,6 +8924,19 @@ async function handleReject(req, res, deps, id) {
|
|
|
8709
8924
|
});
|
|
8710
8925
|
return;
|
|
8711
8926
|
}
|
|
8927
|
+
if (proposal.kind === "model") {
|
|
8928
|
+
if (!deps.modelPool) {
|
|
8929
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8930
|
+
return;
|
|
8931
|
+
}
|
|
8932
|
+
const updated2 = await onRejectModelProposal(
|
|
8933
|
+
modelHandlerDeps(deps, req),
|
|
8934
|
+
proposal,
|
|
8935
|
+
parsed.data.reason
|
|
8936
|
+
);
|
|
8937
|
+
sendJSON8(res, 200, updated2);
|
|
8938
|
+
return;
|
|
8939
|
+
}
|
|
8712
8940
|
const decidedBy = getDecidedBy(req, deps);
|
|
8713
8941
|
const updated = await (0, import_core10.updateProposal)(deps.projectPath, id, {
|
|
8714
8942
|
status: "rejected",
|
|
@@ -8719,7 +8947,7 @@ async function handleReject(req, res, deps, id) {
|
|
|
8719
8947
|
reason: parsed.data.reason
|
|
8720
8948
|
}
|
|
8721
8949
|
});
|
|
8722
|
-
emitProposalRejected(deps.bus, updated);
|
|
8950
|
+
if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
|
|
8723
8951
|
sendJSON8(res, 200, updated);
|
|
8724
8952
|
}
|
|
8725
8953
|
async function handleEdit(req, res, deps, id) {
|
|
@@ -8747,6 +8975,10 @@ async function handleEdit(req, res, deps, id) {
|
|
|
8747
8975
|
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8748
8976
|
return;
|
|
8749
8977
|
}
|
|
8978
|
+
if (existing.kind !== "skill") {
|
|
8979
|
+
sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
|
|
8980
|
+
return;
|
|
8981
|
+
}
|
|
8750
8982
|
if (existing.status === "approved" || existing.status === "rejected") {
|
|
8751
8983
|
sendJSON8(res, 409, {
|
|
8752
8984
|
error: `proposal already ${existing.status}; cannot edit`
|
|
@@ -8812,17 +9044,329 @@ function handleV1ProposalsRoute(req, res, deps) {
|
|
|
8812
9044
|
return false;
|
|
8813
9045
|
}
|
|
8814
9046
|
|
|
9047
|
+
// src/server/routes/v1/local-models.ts
|
|
9048
|
+
var import_local_models2 = require("@harness-engineering/local-models");
|
|
9049
|
+
var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
|
|
9050
|
+
var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
|
|
9051
|
+
var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
|
|
9052
|
+
var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
|
|
9053
|
+
var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
|
|
9054
|
+
var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
|
|
9055
|
+
function sendJSON9(res, status, body) {
|
|
9056
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9057
|
+
res.end(JSON.stringify(body));
|
|
9058
|
+
}
|
|
9059
|
+
function handleV1LocalModelsRoute(req, res, deps) {
|
|
9060
|
+
const url = req.url ?? "";
|
|
9061
|
+
const method = req.method ?? "GET";
|
|
9062
|
+
if (method === "GET") {
|
|
9063
|
+
if (HARDWARE_RE.test(url)) {
|
|
9064
|
+
void runGet(res, () => handleHardware(res, deps));
|
|
9065
|
+
return true;
|
|
9066
|
+
}
|
|
9067
|
+
if (POOL_RE.test(url)) {
|
|
9068
|
+
void runGet(res, () => handlePool(res, deps));
|
|
9069
|
+
return true;
|
|
9070
|
+
}
|
|
9071
|
+
if (RECS_RE.test(url)) {
|
|
9072
|
+
void runGet(res, () => handleRecommendations(res, url, deps));
|
|
9073
|
+
return true;
|
|
9074
|
+
}
|
|
9075
|
+
if (PROPOSALS_RE.test(url)) {
|
|
9076
|
+
void runGet(res, () => handleProposals(res, deps));
|
|
9077
|
+
return true;
|
|
9078
|
+
}
|
|
9079
|
+
return false;
|
|
9080
|
+
}
|
|
9081
|
+
if (method !== "POST" || !REFRESH_RE.test(url)) return false;
|
|
9082
|
+
const scheduler = deps.getRefreshScheduler();
|
|
9083
|
+
if (scheduler === null) {
|
|
9084
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9085
|
+
return true;
|
|
9086
|
+
}
|
|
9087
|
+
void runForceRefresh(res, scheduler, deps);
|
|
9088
|
+
return true;
|
|
9089
|
+
}
|
|
9090
|
+
async function runGet(res, handler) {
|
|
9091
|
+
try {
|
|
9092
|
+
await handler();
|
|
9093
|
+
} catch (err) {
|
|
9094
|
+
sendJSON9(res, 500, {
|
|
9095
|
+
error: "local-models route failed",
|
|
9096
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9097
|
+
});
|
|
9098
|
+
}
|
|
9099
|
+
}
|
|
9100
|
+
async function handleHardware(res, deps) {
|
|
9101
|
+
const pending = deps.getHardwareProfile?.() ?? null;
|
|
9102
|
+
if (pending === null) {
|
|
9103
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9104
|
+
return;
|
|
9105
|
+
}
|
|
9106
|
+
sendJSON9(res, 200, await pending);
|
|
9107
|
+
}
|
|
9108
|
+
async function handlePool(res, deps) {
|
|
9109
|
+
const pool = deps.getModelPool?.() ?? null;
|
|
9110
|
+
if (pool === null) {
|
|
9111
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
|
|
9115
|
+
}
|
|
9116
|
+
var MAX_RECOMMENDATION_TOP = 100;
|
|
9117
|
+
async function handleRecommendations(res, url, deps) {
|
|
9118
|
+
if (deps.getRecommendations === void 0) {
|
|
9119
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9120
|
+
return;
|
|
9121
|
+
}
|
|
9122
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
9123
|
+
const topRaw = params.get("top");
|
|
9124
|
+
let top = 10;
|
|
9125
|
+
if (topRaw !== null) {
|
|
9126
|
+
const n = Number(topRaw);
|
|
9127
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
9128
|
+
sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
|
|
9129
|
+
return;
|
|
9130
|
+
}
|
|
9131
|
+
if (n > MAX_RECOMMENDATION_TOP) {
|
|
9132
|
+
sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
|
|
9133
|
+
return;
|
|
9134
|
+
}
|
|
9135
|
+
top = n;
|
|
9136
|
+
}
|
|
9137
|
+
const profileRaw = params.get("profile");
|
|
9138
|
+
let profile = "general";
|
|
9139
|
+
if (profileRaw !== null) {
|
|
9140
|
+
if (!isRecommendationProfile(profileRaw)) {
|
|
9141
|
+
sendJSON9(res, 400, {
|
|
9142
|
+
error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
|
|
9143
|
+
});
|
|
9144
|
+
return;
|
|
9145
|
+
}
|
|
9146
|
+
profile = profileRaw;
|
|
9147
|
+
}
|
|
9148
|
+
sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
|
|
9149
|
+
}
|
|
9150
|
+
async function handleProposals(res, deps) {
|
|
9151
|
+
if (deps.listModelProposals === void 0) {
|
|
9152
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9153
|
+
return;
|
|
9154
|
+
}
|
|
9155
|
+
sendJSON9(res, 200, await deps.listModelProposals());
|
|
9156
|
+
}
|
|
9157
|
+
function isRecommendationProfile(value) {
|
|
9158
|
+
return RECOMMENDATION_PROFILES.includes(value);
|
|
9159
|
+
}
|
|
9160
|
+
async function runForceRefresh(res, scheduler, deps) {
|
|
9161
|
+
let result;
|
|
9162
|
+
try {
|
|
9163
|
+
result = await scheduler.forceRefresh();
|
|
9164
|
+
} catch (err) {
|
|
9165
|
+
sendJSON9(res, 500, {
|
|
9166
|
+
error: "refresh tick failed",
|
|
9167
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9168
|
+
});
|
|
9169
|
+
return;
|
|
9170
|
+
}
|
|
9171
|
+
if ((0, import_local_models2.isTickHardFailure)(result)) {
|
|
9172
|
+
sendJSON9(res, 503, {
|
|
9173
|
+
error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
|
|
9174
|
+
emitted: result.proposalsEmitted,
|
|
9175
|
+
warnings: result.warnings,
|
|
9176
|
+
errors: result.errors
|
|
9177
|
+
});
|
|
9178
|
+
return;
|
|
9179
|
+
}
|
|
9180
|
+
const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
|
|
9181
|
+
sendJSON9(res, 200, {
|
|
9182
|
+
emitted: result.proposalsEmitted,
|
|
9183
|
+
reconciledRemoved: result.reconciledRemoved,
|
|
9184
|
+
proposals,
|
|
9185
|
+
warnings: result.warnings
|
|
9186
|
+
});
|
|
9187
|
+
}
|
|
9188
|
+
|
|
9189
|
+
// src/server/routes/v1/local-models-pool-mutation.ts
|
|
9190
|
+
var import_core11 = require("@harness-engineering/core");
|
|
9191
|
+
var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
|
|
9192
|
+
var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
|
|
9193
|
+
var RESOLVE_TOP = 50;
|
|
9194
|
+
function sendJSON10(res, status, body) {
|
|
9195
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9196
|
+
res.end(JSON.stringify(body));
|
|
9197
|
+
}
|
|
9198
|
+
function decidedByOf(deps, req) {
|
|
9199
|
+
if (deps.getDecidedBy) return deps.getDecidedBy(req);
|
|
9200
|
+
const token = req._authToken;
|
|
9201
|
+
return token?.id ?? "operator";
|
|
9202
|
+
}
|
|
9203
|
+
function handlerDeps(deps, req, pool) {
|
|
9204
|
+
return {
|
|
9205
|
+
pool,
|
|
9206
|
+
bus: deps.bus,
|
|
9207
|
+
updateProposal: (id, patch) => (0, import_core11.updateProposal)(deps.projectPath, id, patch),
|
|
9208
|
+
decidedBy: decidedByOf(deps, req),
|
|
9209
|
+
...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
|
|
9210
|
+
};
|
|
9211
|
+
}
|
|
9212
|
+
async function readJsonBody(req) {
|
|
9213
|
+
try {
|
|
9214
|
+
const raw = await readBody(req);
|
|
9215
|
+
if (raw.length === 0) return {};
|
|
9216
|
+
const parsed = JSON.parse(raw);
|
|
9217
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
9218
|
+
} catch {
|
|
9219
|
+
return void 0;
|
|
9220
|
+
}
|
|
9221
|
+
}
|
|
9222
|
+
async function handleInstall(req, res, deps) {
|
|
9223
|
+
const pool = deps.getModelPool();
|
|
9224
|
+
if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
|
|
9225
|
+
const body = await readJsonBody(req);
|
|
9226
|
+
if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
|
|
9227
|
+
const hfRepoId = typeof body.hfRepoId === "string" ? body.hfRepoId : "";
|
|
9228
|
+
if (hfRepoId.length === 0) return sendJSON10(res, 400, { error: "hfRepoId is required" });
|
|
9229
|
+
const quant = typeof body.quant === "string" ? body.quant : void 0;
|
|
9230
|
+
if (!deps.getRecommendations) {
|
|
9231
|
+
return sendJSON10(res, 503, { error: "recommendations unavailable" });
|
|
9232
|
+
}
|
|
9233
|
+
const ranked = await deps.getRecommendations({ top: RESOLVE_TOP, profile: "general" });
|
|
9234
|
+
const match = ranked.find(
|
|
9235
|
+
(r) => r.hfRepoId === hfRepoId && (quant === void 0 || r.quant === quant)
|
|
9236
|
+
);
|
|
9237
|
+
if (!match) {
|
|
9238
|
+
return sendJSON10(res, 404, {
|
|
9239
|
+
error: `no recommendation for ${hfRepoId}${quant ? ` @ ${quant}` : ""}`
|
|
9240
|
+
});
|
|
9241
|
+
}
|
|
9242
|
+
if (!match.ollamaName) {
|
|
9243
|
+
return sendJSON10(res, 422, { error: `${hfRepoId} has no known Ollama mirror to install` });
|
|
9244
|
+
}
|
|
9245
|
+
const content = {
|
|
9246
|
+
action: "add",
|
|
9247
|
+
target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
|
|
9248
|
+
scoreDelta: match.score,
|
|
9249
|
+
justification: {
|
|
9250
|
+
summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
|
|
9251
|
+
benchmarkBasis: [],
|
|
9252
|
+
hardwareFit: match.fitsHardware ? "fits detected hardware" : "may not fit detected hardware",
|
|
9253
|
+
evidence: match.evidence,
|
|
9254
|
+
freshness: `snapshot ${match.benchmarkSnapshot}`
|
|
9255
|
+
},
|
|
9256
|
+
// 0 → the installer resolves the true on-disk size via `inspect` before the
|
|
9257
|
+
// budget check, rather than trusting an estimate.
|
|
9258
|
+
diskImpactGb: 0
|
|
9259
|
+
};
|
|
9260
|
+
const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
|
|
9261
|
+
proposedBy: decidedByOf(deps, req)
|
|
9262
|
+
});
|
|
9263
|
+
const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
|
|
9264
|
+
if (outcome.status === "approved") {
|
|
9265
|
+
const result = {
|
|
9266
|
+
disposition: "installed",
|
|
9267
|
+
proposalId: record.id,
|
|
9268
|
+
evicted: outcome.evicted.map((e) => e.ollamaName)
|
|
9269
|
+
};
|
|
9270
|
+
return sendJSON10(res, 200, result);
|
|
9271
|
+
}
|
|
9272
|
+
if (outcome.status === "failed_target_missing") {
|
|
9273
|
+
return sendJSON10(res, 404, {
|
|
9274
|
+
error: `${hfRepoId} is no longer available on HuggingFace`,
|
|
9275
|
+
proposalId: record.id
|
|
9276
|
+
});
|
|
9277
|
+
}
|
|
9278
|
+
const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
|
|
9279
|
+
return sendJSON10(res, status, {
|
|
9280
|
+
error: outcome.message,
|
|
9281
|
+
code: outcome.code,
|
|
9282
|
+
proposalId: record.id
|
|
9283
|
+
});
|
|
9284
|
+
}
|
|
9285
|
+
async function handleRemove(req, res, deps) {
|
|
9286
|
+
const pool = deps.getModelPool();
|
|
9287
|
+
if (pool === null) return sendJSON10(res, 503, { error: "LMLM disabled" });
|
|
9288
|
+
const body = await readJsonBody(req);
|
|
9289
|
+
if (body === void 0) return sendJSON10(res, 400, { error: "invalid JSON body" });
|
|
9290
|
+
const ollamaName = typeof body.ollamaName === "string" ? body.ollamaName : "";
|
|
9291
|
+
if (ollamaName.length === 0) return sendJSON10(res, 400, { error: "ollamaName is required" });
|
|
9292
|
+
const entry = pool.snapshot().entries.find((e) => e.ollamaName === ollamaName);
|
|
9293
|
+
if (!entry) return sendJSON10(res, 404, { error: `${ollamaName} is not in the pool` });
|
|
9294
|
+
const content = {
|
|
9295
|
+
action: "evict",
|
|
9296
|
+
target: { hfRepoId: entry.hfRepoId, ollamaName },
|
|
9297
|
+
scoreDelta: 0,
|
|
9298
|
+
justification: {
|
|
9299
|
+
summary: `Operator-initiated removal of ${ollamaName}.`,
|
|
9300
|
+
benchmarkBasis: [],
|
|
9301
|
+
hardwareFit: "",
|
|
9302
|
+
evidence: "operator",
|
|
9303
|
+
freshness: ""
|
|
9304
|
+
},
|
|
9305
|
+
diskImpactGb: entry.sizeOnDiskGb
|
|
9306
|
+
};
|
|
9307
|
+
const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
|
|
9308
|
+
proposedBy: decidedByOf(deps, req)
|
|
9309
|
+
});
|
|
9310
|
+
const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
|
|
9311
|
+
if (outcome.status === "error") {
|
|
9312
|
+
return sendJSON10(res, 502, {
|
|
9313
|
+
error: outcome.message,
|
|
9314
|
+
code: outcome.code,
|
|
9315
|
+
proposalId: record.id
|
|
9316
|
+
});
|
|
9317
|
+
}
|
|
9318
|
+
if (outcome.status === "failed_target_missing") {
|
|
9319
|
+
return sendJSON10(res, 500, {
|
|
9320
|
+
error: "unexpected failed_target_missing on evict",
|
|
9321
|
+
proposalId: record.id
|
|
9322
|
+
});
|
|
9323
|
+
}
|
|
9324
|
+
if (outcome.evicted.length > 0) {
|
|
9325
|
+
const result2 = {
|
|
9326
|
+
disposition: "removed",
|
|
9327
|
+
proposalId: record.id,
|
|
9328
|
+
evicted: outcome.evicted.map((e) => e.ollamaName)
|
|
9329
|
+
};
|
|
9330
|
+
return sendJSON10(res, 200, result2);
|
|
9331
|
+
}
|
|
9332
|
+
const stillPresent = pool.snapshot().entries.some((e) => e.ollamaName === ollamaName);
|
|
9333
|
+
if (stillPresent) {
|
|
9334
|
+
const result2 = {
|
|
9335
|
+
disposition: "deferred",
|
|
9336
|
+
proposalId: record.id,
|
|
9337
|
+
evicted: [],
|
|
9338
|
+
message: "model is in use; it will be removed after the current run"
|
|
9339
|
+
};
|
|
9340
|
+
return sendJSON10(res, 202, result2);
|
|
9341
|
+
}
|
|
9342
|
+
const result = { disposition: "removed", proposalId: record.id, evicted: [] };
|
|
9343
|
+
return sendJSON10(res, 200, result);
|
|
9344
|
+
}
|
|
9345
|
+
function handleV1LocalModelsMutationRoute(req, res, deps) {
|
|
9346
|
+
const url = req.url ?? "";
|
|
9347
|
+
if ((req.method ?? "GET") !== "POST") return false;
|
|
9348
|
+
if (INSTALL_RE.test(url)) {
|
|
9349
|
+
void handleInstall(req, res, deps);
|
|
9350
|
+
return true;
|
|
9351
|
+
}
|
|
9352
|
+
if (REMOVE_RE.test(url)) {
|
|
9353
|
+
void handleRemove(req, res, deps);
|
|
9354
|
+
return true;
|
|
9355
|
+
}
|
|
9356
|
+
return false;
|
|
9357
|
+
}
|
|
9358
|
+
|
|
8815
9359
|
// src/server/routes/v1/routing.ts
|
|
8816
9360
|
var import_zod14 = require("zod");
|
|
8817
9361
|
var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
|
|
8818
9362
|
var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
|
|
8819
9363
|
var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
|
|
8820
|
-
function
|
|
9364
|
+
function sendJSON11(res, status, body) {
|
|
8821
9365
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
8822
9366
|
res.end(JSON.stringify(body));
|
|
8823
9367
|
}
|
|
8824
9368
|
function unavailable(res) {
|
|
8825
|
-
|
|
9369
|
+
sendJSON11(res, 503, { error: "BackendRouter not available" });
|
|
8826
9370
|
return true;
|
|
8827
9371
|
}
|
|
8828
9372
|
function resolveChain(value, backends) {
|
|
@@ -8859,7 +9403,7 @@ function buildResolvedChains(routing, backends) {
|
|
|
8859
9403
|
}
|
|
8860
9404
|
function handleConfig(res, deps) {
|
|
8861
9405
|
if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
|
|
8862
|
-
|
|
9406
|
+
sendJSON11(res, 200, {
|
|
8863
9407
|
routing: deps.routing,
|
|
8864
9408
|
resolvedChains: buildResolvedChains(deps.routing, deps.backends),
|
|
8865
9409
|
backends: Object.keys(deps.backends)
|
|
@@ -8887,7 +9431,7 @@ function parseDecisionsQuery(url) {
|
|
|
8887
9431
|
function handleDecisions(req, res, deps) {
|
|
8888
9432
|
if (!deps.bus) return unavailable(res);
|
|
8889
9433
|
const filter = parseDecisionsQuery(req.url ?? "");
|
|
8890
|
-
|
|
9434
|
+
sendJSON11(res, 200, { decisions: deps.bus.recent(filter) });
|
|
8891
9435
|
return true;
|
|
8892
9436
|
}
|
|
8893
9437
|
var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
|
|
@@ -8919,19 +9463,19 @@ async function handleTrace(req, res, deps) {
|
|
|
8919
9463
|
try {
|
|
8920
9464
|
raw = await readBody(req);
|
|
8921
9465
|
} catch {
|
|
8922
|
-
|
|
9466
|
+
sendJSON11(res, 400, { error: "body read failed" });
|
|
8923
9467
|
return true;
|
|
8924
9468
|
}
|
|
8925
9469
|
let parsed;
|
|
8926
9470
|
try {
|
|
8927
9471
|
parsed = JSON.parse(raw);
|
|
8928
9472
|
} catch {
|
|
8929
|
-
|
|
9473
|
+
sendJSON11(res, 400, { error: "invalid JSON body" });
|
|
8930
9474
|
return true;
|
|
8931
9475
|
}
|
|
8932
9476
|
const r = TraceBodySchema.safeParse(parsed);
|
|
8933
9477
|
if (!r.success) {
|
|
8934
|
-
|
|
9478
|
+
sendJSON11(res, 400, { error: r.error.message });
|
|
8935
9479
|
return true;
|
|
8936
9480
|
}
|
|
8937
9481
|
const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
|
|
@@ -8944,9 +9488,9 @@ async function handleTrace(req, res, deps) {
|
|
|
8944
9488
|
r.data.useCase,
|
|
8945
9489
|
opts
|
|
8946
9490
|
);
|
|
8947
|
-
|
|
9491
|
+
sendJSON11(res, 200, { decision, def: { type: def.type } });
|
|
8948
9492
|
} catch (err) {
|
|
8949
|
-
|
|
9493
|
+
sendJSON11(res, 500, { error: String(err) });
|
|
8950
9494
|
}
|
|
8951
9495
|
return true;
|
|
8952
9496
|
}
|
|
@@ -9183,7 +9727,7 @@ var CreateBodySchema = import_zod16.z.object({
|
|
|
9183
9727
|
tenantId: import_zod16.z.string().optional(),
|
|
9184
9728
|
expiresAt: import_zod16.z.string().datetime().optional()
|
|
9185
9729
|
});
|
|
9186
|
-
function
|
|
9730
|
+
function sendJSON12(res, status, body) {
|
|
9187
9731
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9188
9732
|
res.end(JSON.stringify(body));
|
|
9189
9733
|
}
|
|
@@ -9193,19 +9737,19 @@ async function handlePost(req, res, store) {
|
|
|
9193
9737
|
raw = await readBody(req);
|
|
9194
9738
|
} catch (err) {
|
|
9195
9739
|
const msg = err instanceof Error ? err.message : "Failed to read body";
|
|
9196
|
-
|
|
9740
|
+
sendJSON12(res, 413, { error: msg });
|
|
9197
9741
|
return;
|
|
9198
9742
|
}
|
|
9199
9743
|
let json;
|
|
9200
9744
|
try {
|
|
9201
9745
|
json = JSON.parse(raw);
|
|
9202
9746
|
} catch {
|
|
9203
|
-
|
|
9747
|
+
sendJSON12(res, 400, { error: "Invalid JSON body" });
|
|
9204
9748
|
return;
|
|
9205
9749
|
}
|
|
9206
9750
|
const parsed = CreateBodySchema.safeParse(json);
|
|
9207
9751
|
if (!parsed.success) {
|
|
9208
|
-
|
|
9752
|
+
sendJSON12(res, 422, { error: "Invalid body", issues: parsed.error.issues });
|
|
9209
9753
|
return;
|
|
9210
9754
|
}
|
|
9211
9755
|
try {
|
|
@@ -9218,37 +9762,37 @@ async function handlePost(req, res, store) {
|
|
|
9218
9762
|
if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
|
|
9219
9763
|
const result = await store.create(input);
|
|
9220
9764
|
const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
|
|
9221
|
-
|
|
9765
|
+
sendJSON12(res, 200, {
|
|
9222
9766
|
...publicRecord,
|
|
9223
9767
|
token: result.token
|
|
9224
9768
|
});
|
|
9225
9769
|
} catch (err) {
|
|
9226
9770
|
const msg = err instanceof Error ? err.message : "Failed to create token";
|
|
9227
9771
|
if (msg.includes("already exists")) {
|
|
9228
|
-
|
|
9772
|
+
sendJSON12(res, 409, { error: msg });
|
|
9229
9773
|
return;
|
|
9230
9774
|
}
|
|
9231
|
-
|
|
9775
|
+
sendJSON12(res, 500, { error: "Internal error creating token" });
|
|
9232
9776
|
}
|
|
9233
9777
|
}
|
|
9234
9778
|
async function handleList3(res, store) {
|
|
9235
9779
|
try {
|
|
9236
9780
|
const list = await store.list();
|
|
9237
|
-
|
|
9781
|
+
sendJSON12(res, 200, list);
|
|
9238
9782
|
} catch {
|
|
9239
|
-
|
|
9783
|
+
sendJSON12(res, 500, { error: "Internal error listing tokens" });
|
|
9240
9784
|
}
|
|
9241
9785
|
}
|
|
9242
9786
|
async function handleDelete2(res, store, id) {
|
|
9243
9787
|
try {
|
|
9244
9788
|
const ok = await store.revoke(id);
|
|
9245
9789
|
if (!ok) {
|
|
9246
|
-
|
|
9790
|
+
sendJSON12(res, 404, { error: "Token not found" });
|
|
9247
9791
|
return;
|
|
9248
9792
|
}
|
|
9249
|
-
|
|
9793
|
+
sendJSON12(res, 200, { deleted: true });
|
|
9250
9794
|
} catch {
|
|
9251
|
-
|
|
9795
|
+
sendJSON12(res, 500, { error: "Internal error revoking token" });
|
|
9252
9796
|
}
|
|
9253
9797
|
}
|
|
9254
9798
|
var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -9273,12 +9817,12 @@ function handleAuthRoute(req, res, store) {
|
|
|
9273
9817
|
return true;
|
|
9274
9818
|
}
|
|
9275
9819
|
}
|
|
9276
|
-
|
|
9820
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9277
9821
|
return true;
|
|
9278
9822
|
}
|
|
9279
9823
|
|
|
9280
9824
|
// src/server/routes/local-model.ts
|
|
9281
|
-
function
|
|
9825
|
+
function sendJSON13(res, status, body) {
|
|
9282
9826
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9283
9827
|
res.end(JSON.stringify(body));
|
|
9284
9828
|
}
|
|
@@ -9286,30 +9830,30 @@ function handleLocalModelRoute(req, res, getStatus) {
|
|
|
9286
9830
|
const { method, url } = req;
|
|
9287
9831
|
if (url !== "/api/v1/local-model/status") return false;
|
|
9288
9832
|
if (method !== "GET") {
|
|
9289
|
-
|
|
9833
|
+
sendJSON13(res, 405, { error: "Method not allowed" });
|
|
9290
9834
|
return true;
|
|
9291
9835
|
}
|
|
9292
9836
|
if (!getStatus) {
|
|
9293
|
-
|
|
9837
|
+
sendJSON13(res, 503, { error: "Local backend not configured" });
|
|
9294
9838
|
return true;
|
|
9295
9839
|
}
|
|
9296
9840
|
const status = getStatus();
|
|
9297
9841
|
if (!status) {
|
|
9298
|
-
|
|
9842
|
+
sendJSON13(res, 503, { error: "Local backend not configured" });
|
|
9299
9843
|
return true;
|
|
9300
9844
|
}
|
|
9301
|
-
|
|
9845
|
+
sendJSON13(res, 200, status);
|
|
9302
9846
|
return true;
|
|
9303
9847
|
}
|
|
9304
9848
|
function handleLocalModelsRoute(req, res, getStatuses) {
|
|
9305
9849
|
const { method, url } = req;
|
|
9306
9850
|
if (url !== "/api/v1/local-models/status") return false;
|
|
9307
9851
|
if (method !== "GET") {
|
|
9308
|
-
|
|
9852
|
+
sendJSON13(res, 405, { error: "Method not allowed" });
|
|
9309
9853
|
return true;
|
|
9310
9854
|
}
|
|
9311
9855
|
const statuses = getStatuses ? getStatuses() : [];
|
|
9312
|
-
|
|
9856
|
+
sendJSON13(res, 200, statuses);
|
|
9313
9857
|
return true;
|
|
9314
9858
|
}
|
|
9315
9859
|
|
|
@@ -9673,6 +10217,60 @@ var V1_BRIDGE_ROUTES = [
|
|
|
9673
10217
|
scope: "read-telemetry",
|
|
9674
10218
|
description: "Prompt-cache hit/miss snapshot (rolling window)."
|
|
9675
10219
|
},
|
|
10220
|
+
// ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
|
|
10221
|
+
// Reuses `manage-proposals`: a forced refresh emits model proposals, so the
|
|
10222
|
+
// same write scope that governs approve/reject governs triggering the tick.
|
|
10223
|
+
{
|
|
10224
|
+
method: "POST",
|
|
10225
|
+
pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
|
|
10226
|
+
scope: "manage-proposals",
|
|
10227
|
+
description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
|
|
10228
|
+
},
|
|
10229
|
+
// ── LMLM dashboard pool mutation — operator-initiated install/remove ──
|
|
10230
|
+
// Modeled as auto-approved model proposals, so the same `manage-proposals`
|
|
10231
|
+
// write scope that governs approve/reject governs these too.
|
|
10232
|
+
{
|
|
10233
|
+
method: "POST",
|
|
10234
|
+
pattern: /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/,
|
|
10235
|
+
scope: "manage-proposals",
|
|
10236
|
+
description: "Install a recommended model into the local pool (auto-approved proposal)."
|
|
10237
|
+
},
|
|
10238
|
+
{
|
|
10239
|
+
method: "POST",
|
|
10240
|
+
pattern: /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/,
|
|
10241
|
+
scope: "manage-proposals",
|
|
10242
|
+
description: "Remove a model from the local pool (auto-approved proposal)."
|
|
10243
|
+
},
|
|
10244
|
+
// ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
|
|
10245
|
+
// Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
|
|
10246
|
+
// the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
|
|
10247
|
+
// `/api/local-models/<name>` and misroute it to the legacy status handler.
|
|
10248
|
+
// `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
|
|
10249
|
+
// default-deny read scope. All four are read-only observability (`read-status`).
|
|
10250
|
+
{
|
|
10251
|
+
method: "GET",
|
|
10252
|
+
pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
|
|
10253
|
+
scope: "read-status",
|
|
10254
|
+
description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
|
|
10255
|
+
},
|
|
10256
|
+
{
|
|
10257
|
+
method: "GET",
|
|
10258
|
+
pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
|
|
10259
|
+
scope: "read-status",
|
|
10260
|
+
description: "Live PoolState view, including transient pendingEviction flags."
|
|
10261
|
+
},
|
|
10262
|
+
{
|
|
10263
|
+
method: "GET",
|
|
10264
|
+
pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
|
|
10265
|
+
scope: "read-status",
|
|
10266
|
+
description: "Hardware-ranked model recommendations (top/profile query params)."
|
|
10267
|
+
},
|
|
10268
|
+
{
|
|
10269
|
+
method: "GET",
|
|
10270
|
+
pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
|
|
10271
|
+
scope: "read-status",
|
|
10272
|
+
description: "Open model-kind proposals (pending review queue)."
|
|
10273
|
+
},
|
|
9676
10274
|
// ── Spec B Phase 5 routing observability ──
|
|
9677
10275
|
// D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
|
|
9678
10276
|
// precedent (read-only observability). A dedicated `read-routing`
|
|
@@ -9815,7 +10413,19 @@ var OrchestratorServer = class {
|
|
|
9815
10413
|
getRoutingDecisionBusFn = null;
|
|
9816
10414
|
getRoutingConfigFn = null;
|
|
9817
10415
|
getBackendsFn = null;
|
|
10416
|
+
// LMLM Phase 6 — live model pool + refresh scheduler accessors.
|
|
10417
|
+
getModelPoolFn = null;
|
|
10418
|
+
getRefreshSchedulerFn = null;
|
|
10419
|
+
// LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
|
|
10420
|
+
getHardwareProfileFn = null;
|
|
10421
|
+
getRecommendationsFn = null;
|
|
10422
|
+
listModelProposalsFn = null;
|
|
10423
|
+
// LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
|
|
10424
|
+
isModelInUseFn = null;
|
|
9818
10425
|
routingDecisionUnsubscribe = null;
|
|
10426
|
+
// LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
|
|
10427
|
+
modelProposalListener = null;
|
|
10428
|
+
modelPoolListener = null;
|
|
9819
10429
|
recorder = null;
|
|
9820
10430
|
planWatcher = null;
|
|
9821
10431
|
tokenStore;
|
|
@@ -9860,6 +10470,12 @@ var OrchestratorServer = class {
|
|
|
9860
10470
|
this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
|
|
9861
10471
|
this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
|
|
9862
10472
|
this.getBackendsFn = deps?.getBackends ?? null;
|
|
10473
|
+
this.getModelPoolFn = deps?.getModelPool ?? null;
|
|
10474
|
+
this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
|
|
10475
|
+
this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
|
|
10476
|
+
this.getRecommendationsFn = deps?.getRecommendations ?? null;
|
|
10477
|
+
this.listModelProposalsFn = deps?.listModelProposals ?? null;
|
|
10478
|
+
this.isModelInUseFn = deps?.isModelInUse ?? null;
|
|
9863
10479
|
}
|
|
9864
10480
|
wireEvents() {
|
|
9865
10481
|
this.stateChangeListener = (snapshot) => {
|
|
@@ -9876,6 +10492,10 @@ var OrchestratorServer = class {
|
|
|
9876
10492
|
this.broadcaster.broadcast("routing:decision", decision);
|
|
9877
10493
|
});
|
|
9878
10494
|
}
|
|
10495
|
+
this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
|
|
10496
|
+
this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
|
|
10497
|
+
this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10498
|
+
this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
9879
10499
|
}
|
|
9880
10500
|
/**
|
|
9881
10501
|
* Broadcast a new interaction to all WebSocket clients.
|
|
@@ -10044,9 +10664,38 @@ var OrchestratorServer = class {
|
|
|
10044
10664
|
// upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
|
|
10045
10665
|
// business logic. `projectPath` defaults to process.cwd() — that is
|
|
10046
10666
|
// where `.harness/proposals/` lives in every deployment we ship.
|
|
10047
|
-
(req, res) =>
|
|
10667
|
+
(req, res) => {
|
|
10668
|
+
const modelPool = this.getModelPoolFn?.() ?? null;
|
|
10669
|
+
return handleV1ProposalsRoute(req, res, {
|
|
10670
|
+
projectPath: this.projectPath,
|
|
10671
|
+
bus: this.orchestrator,
|
|
10672
|
+
...modelPool ? { modelPool } : {},
|
|
10673
|
+
// S1: thread the in-use probe so an approved swap/evict of a model an
|
|
10674
|
+
// agent could be using is deferred (marked pendingEviction) not applied.
|
|
10675
|
+
...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
|
|
10676
|
+
});
|
|
10677
|
+
},
|
|
10678
|
+
// LMLM dashboard pool mutation — POST /pool/install + /pool/remove.
|
|
10679
|
+
// Operator-initiated install/remove via auto-approved proposals; reuses
|
|
10680
|
+
// the same mutation pool + bus + in-use probe the proposals route uses.
|
|
10681
|
+
// Registered before the read surface so /pool/install|remove match first.
|
|
10682
|
+
(req, res) => handleV1LocalModelsMutationRoute(req, res, {
|
|
10048
10683
|
projectPath: this.projectPath,
|
|
10049
|
-
bus: this.orchestrator
|
|
10684
|
+
bus: this.orchestrator,
|
|
10685
|
+
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10686
|
+
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
10687
|
+
...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
|
|
10688
|
+
}),
|
|
10689
|
+
// LMLM Phase 6/7 — POST /refresh + the GET read surface
|
|
10690
|
+
// (hardware/pool/recommendations/proposals). Registered before the
|
|
10691
|
+
// chat-proxy fallback so it owns the path. Each accessor is spread in
|
|
10692
|
+
// only when configured so absent ones surface as 503 (LMLM disabled).
|
|
10693
|
+
(req, res) => handleV1LocalModelsRoute(req, res, {
|
|
10694
|
+
getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
|
|
10695
|
+
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10696
|
+
...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
|
|
10697
|
+
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
10698
|
+
...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
|
|
10050
10699
|
}),
|
|
10051
10700
|
// Chat proxy route (spawns Claude Code CLI — no API key required)
|
|
10052
10701
|
(req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
|
|
@@ -10130,7 +10779,7 @@ var OrchestratorServer = class {
|
|
|
10130
10779
|
return this.broadcaster.clientCount;
|
|
10131
10780
|
}
|
|
10132
10781
|
async start() {
|
|
10133
|
-
(0,
|
|
10782
|
+
(0, import_core12.assertPortUsable)(this.port, "orchestrator");
|
|
10134
10783
|
if (this.interactionQueue) {
|
|
10135
10784
|
this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
|
|
10136
10785
|
this.planWatcher.start();
|
|
@@ -10150,6 +10799,14 @@ var OrchestratorServer = class {
|
|
|
10150
10799
|
this.routingDecisionUnsubscribe();
|
|
10151
10800
|
this.routingDecisionUnsubscribe = null;
|
|
10152
10801
|
}
|
|
10802
|
+
if (this.modelProposalListener) {
|
|
10803
|
+
this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10804
|
+
this.modelProposalListener = null;
|
|
10805
|
+
}
|
|
10806
|
+
if (this.modelPoolListener) {
|
|
10807
|
+
this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10808
|
+
this.modelPoolListener = null;
|
|
10809
|
+
}
|
|
10153
10810
|
if (this.planWatcher) {
|
|
10154
10811
|
this.planWatcher.stop();
|
|
10155
10812
|
this.planWatcher = null;
|
|
@@ -10624,7 +11281,7 @@ function wireWebhookFanout({ bus, store, delivery }) {
|
|
|
10624
11281
|
|
|
10625
11282
|
// src/gateway/telemetry/fanout.ts
|
|
10626
11283
|
var import_node_crypto13 = require("crypto");
|
|
10627
|
-
var
|
|
11284
|
+
var import_core13 = require("@harness-engineering/core");
|
|
10628
11285
|
var TOPICS = {
|
|
10629
11286
|
MAINTENANCE_STARTED: "maintenance:started",
|
|
10630
11287
|
MAINTENANCE_COMPLETED: "maintenance:completed",
|
|
@@ -10756,7 +11413,7 @@ function buildSpan(topic, plan, payload) {
|
|
|
10756
11413
|
spanId: plan.spanId,
|
|
10757
11414
|
...plan.parentSpanId !== void 0 ? { parentSpanId: plan.parentSpanId } : {},
|
|
10758
11415
|
name: SPAN_NAME[topic],
|
|
10759
|
-
kind:
|
|
11416
|
+
kind: import_core13.SpanKind.INTERNAL,
|
|
10760
11417
|
startTimeNs: startNs,
|
|
10761
11418
|
endTimeNs: startNs,
|
|
10762
11419
|
attributes: buildAttributes(payload, { "harness.topic": topic }),
|
|
@@ -11065,8 +11722,50 @@ var ENVELOPE_DERIVERS = {
|
|
|
11065
11722
|
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11066
11723
|
severity: "warning"
|
|
11067
11724
|
};
|
|
11068
|
-
}
|
|
11725
|
+
},
|
|
11726
|
+
// LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
|
|
11727
|
+
// lookup (below) so each status branch is its own small function and the
|
|
11728
|
+
// deriver stays under the cyclomatic-complexity gate.
|
|
11729
|
+
"local-models.proposal": (event) => deriveModelProposalEnvelope(event)
|
|
11069
11730
|
};
|
|
11731
|
+
var MODEL_PROPOSAL_ENVELOPES = {
|
|
11732
|
+
created: (data) => {
|
|
11733
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11734
|
+
return {
|
|
11735
|
+
title: `New model proposal: ${label}`,
|
|
11736
|
+
summary: `A model proposal (${label}) is awaiting review.`,
|
|
11737
|
+
severity: "info"
|
|
11738
|
+
};
|
|
11739
|
+
},
|
|
11740
|
+
approved: (data) => {
|
|
11741
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11742
|
+
return {
|
|
11743
|
+
title: `Model proposal approved: ${label}`,
|
|
11744
|
+
summary: `The model proposal (${label}) was approved and applied to the pool.`,
|
|
11745
|
+
severity: "success"
|
|
11746
|
+
};
|
|
11747
|
+
},
|
|
11748
|
+
rejected: (data) => ({
|
|
11749
|
+
title: "Model proposal rejected",
|
|
11750
|
+
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11751
|
+
severity: "warning"
|
|
11752
|
+
}),
|
|
11753
|
+
failed_target_missing: (data) => ({
|
|
11754
|
+
title: "Model proposal target missing",
|
|
11755
|
+
summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
|
|
11756
|
+
severity: "error"
|
|
11757
|
+
})
|
|
11758
|
+
};
|
|
11759
|
+
function deriveModelProposalEnvelope(event) {
|
|
11760
|
+
const data = asObj(event.data);
|
|
11761
|
+
const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
|
|
11762
|
+
if (build) return build(data);
|
|
11763
|
+
return {
|
|
11764
|
+
title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
|
|
11765
|
+
summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
|
|
11766
|
+
severity: "info"
|
|
11767
|
+
};
|
|
11768
|
+
}
|
|
11070
11769
|
function truncate(s, max) {
|
|
11071
11770
|
return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
|
|
11072
11771
|
}
|
|
@@ -11113,7 +11812,9 @@ var NOTIFICATION_TOPICS = [
|
|
|
11113
11812
|
// Hermes Phase 4 — skill proposal lifecycle.
|
|
11114
11813
|
"proposal.created",
|
|
11115
11814
|
"proposal.approved",
|
|
11116
|
-
"proposal.rejected"
|
|
11815
|
+
"proposal.rejected",
|
|
11816
|
+
// LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
|
|
11817
|
+
"local-models:proposal"
|
|
11117
11818
|
];
|
|
11118
11819
|
function newEventId3() {
|
|
11119
11820
|
return `evt_${(0, import_node_crypto14.randomBytes)(8).toString("hex")}`;
|
|
@@ -11169,7 +11870,7 @@ function wireNotificationSinks({ bus, registry }) {
|
|
|
11169
11870
|
}
|
|
11170
11871
|
|
|
11171
11872
|
// src/orchestrator.ts
|
|
11172
|
-
var
|
|
11873
|
+
var import_core19 = require("@harness-engineering/core");
|
|
11173
11874
|
|
|
11174
11875
|
// src/logging/logger.ts
|
|
11175
11876
|
var StructuredLogger = class {
|
|
@@ -11211,7 +11912,7 @@ var StructuredLogger = class {
|
|
|
11211
11912
|
// src/workspace/config-scanner.ts
|
|
11212
11913
|
var import_node_fs = require("fs");
|
|
11213
11914
|
var import_node_path4 = require("path");
|
|
11214
|
-
var
|
|
11915
|
+
var import_core14 = require("@harness-engineering/core");
|
|
11215
11916
|
var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
|
|
11216
11917
|
var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
|
|
11217
11918
|
var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
|
|
@@ -11233,29 +11934,29 @@ async function scanSingleFile(filePath, targetDir, scanner) {
|
|
|
11233
11934
|
} catch {
|
|
11234
11935
|
return null;
|
|
11235
11936
|
}
|
|
11236
|
-
const injectionFindings = (0,
|
|
11237
|
-
const findings = (0,
|
|
11937
|
+
const injectionFindings = (0, import_core14.scanForInjection)(content);
|
|
11938
|
+
const findings = (0, import_core14.mapInjectionFindings)(injectionFindings);
|
|
11238
11939
|
const secFindings = await scanner.scanFile(filePath);
|
|
11239
|
-
findings.push(...(0,
|
|
11940
|
+
findings.push(...(0, import_core14.mapSecurityFindings)(secFindings, findings));
|
|
11240
11941
|
const adjusted = adjustFindingSeverity(findings);
|
|
11241
11942
|
return {
|
|
11242
11943
|
file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
|
|
11243
11944
|
findings: adjusted,
|
|
11244
|
-
overallSeverity: (0,
|
|
11945
|
+
overallSeverity: (0, import_core14.computeOverallSeverity)(adjusted)
|
|
11245
11946
|
};
|
|
11246
11947
|
}
|
|
11247
11948
|
async function scanWorkspaceConfig(workspacePath) {
|
|
11248
|
-
const scanner = new
|
|
11949
|
+
const scanner = new import_core14.SecurityScanner((0, import_core14.parseSecurityConfig)({}));
|
|
11249
11950
|
const results = [];
|
|
11250
11951
|
for (const configFile of CONFIG_FILES) {
|
|
11251
11952
|
const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
|
|
11252
11953
|
if (result) results.push(result);
|
|
11253
11954
|
}
|
|
11254
|
-
return { exitCode: (0,
|
|
11955
|
+
return { exitCode: (0, import_core14.computeScanExitCode)(results), results };
|
|
11255
11956
|
}
|
|
11256
11957
|
|
|
11257
11958
|
// src/core/lane-persistence.ts
|
|
11258
|
-
var
|
|
11959
|
+
var import_core15 = require("@harness-engineering/core");
|
|
11259
11960
|
var import_types29 = require("@harness-engineering/types");
|
|
11260
11961
|
var SIGNAL_TO_LANE = {
|
|
11261
11962
|
claim: "claimed",
|
|
@@ -11269,16 +11970,16 @@ function mapOrchestratorLane(signal) {
|
|
|
11269
11970
|
}
|
|
11270
11971
|
async function persistLane(projectPath, issueId, signal) {
|
|
11271
11972
|
try {
|
|
11272
|
-
const reg = await
|
|
11973
|
+
const reg = await import_core15.eventSourcing.registerTask(projectPath, issueId, []);
|
|
11273
11974
|
if (!reg.ok) return reg;
|
|
11274
|
-
return await
|
|
11975
|
+
return await import_core15.eventSourcing.transitionLane(projectPath, issueId, mapOrchestratorLane(signal));
|
|
11275
11976
|
} catch (err) {
|
|
11276
11977
|
return (0, import_types29.Err)(err instanceof Error ? err : new Error(String(err)));
|
|
11277
11978
|
}
|
|
11278
11979
|
}
|
|
11279
11980
|
async function readPersistedLanes(projectPath) {
|
|
11280
11981
|
try {
|
|
11281
|
-
const snap = await
|
|
11982
|
+
const snap = await import_core15.eventSourcing.readSnapshot(projectPath);
|
|
11282
11983
|
return snap.ok ? snap.value.lanes : { tasks: {} };
|
|
11283
11984
|
} catch {
|
|
11284
11985
|
return { tasks: {} };
|
|
@@ -13198,6 +13899,45 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13198
13899
|
* so this map is the single source of truth post-migration.
|
|
13199
13900
|
*/
|
|
13200
13901
|
localResolvers = /* @__PURE__ */ new Map();
|
|
13902
|
+
/** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
|
|
13903
|
+
poolStateProvider = null;
|
|
13904
|
+
poolStateStore = null;
|
|
13905
|
+
/**
|
|
13906
|
+
* LMLM Phase 6: live model pool + its installer. Constructed only when
|
|
13907
|
+
* `localModels.enabled` and a real `PoolStateStore` exists (not a test
|
|
13908
|
+
* override). Exposed to the server via `getModelPool()`, which retires the
|
|
13909
|
+
* proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
|
|
13910
|
+
* is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
|
|
13911
|
+
* before `store.load()` (in initLocalModelAndPipeline) is safe.
|
|
13912
|
+
*/
|
|
13913
|
+
modelPool = null;
|
|
13914
|
+
modelInstaller = null;
|
|
13915
|
+
/**
|
|
13916
|
+
* S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
|
|
13917
|
+
* is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
|
|
13918
|
+
* piggybacked on each refresh tick). Two overlapping drains would both read the
|
|
13919
|
+
* same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
|
|
13920
|
+
* both `await pool.evict` the SAME name — double-calling the installer and
|
|
13921
|
+
* broadcasting a duplicate `evict_completed` frame. The single-threaded event
|
|
13922
|
+
* loop makes a plain boolean sufficient: a drain that arrives while one is
|
|
13923
|
+
* running returns early rather than double-processing.
|
|
13924
|
+
*/
|
|
13925
|
+
draining = false;
|
|
13926
|
+
/**
|
|
13927
|
+
* LMLM Phase 6: single per-instance background refresh scheduler. Started in
|
|
13928
|
+
* `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
|
|
13929
|
+
* when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
|
|
13930
|
+
*/
|
|
13931
|
+
refreshScheduler = null;
|
|
13932
|
+
/**
|
|
13933
|
+
* LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
|
|
13934
|
+
* by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
|
|
13935
|
+
* pool → scheduler never armed). Ranks the (currently empty) candidate set —
|
|
13936
|
+
* see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
|
|
13937
|
+
*/
|
|
13938
|
+
modelRecommender = null;
|
|
13939
|
+
/** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
|
|
13940
|
+
schedulerTimerOverride;
|
|
13201
13941
|
/**
|
|
13202
13942
|
* Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
|
|
13203
13943
|
* construction from `projectRoot/agents/skills/`. Consulted by
|
|
@@ -13288,6 +14028,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13288
14028
|
*/
|
|
13289
14029
|
constructor(config, promptTemplate, overrides) {
|
|
13290
14030
|
super();
|
|
14031
|
+
this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
|
|
13291
14032
|
this.setMaxListeners(50);
|
|
13292
14033
|
this.config = config;
|
|
13293
14034
|
this.promptTemplate = promptTemplate;
|
|
@@ -13335,6 +14076,16 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13335
14076
|
this
|
|
13336
14077
|
);
|
|
13337
14078
|
this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
|
|
14079
|
+
const localModelsEnabled = this.config.localModels?.enabled === true;
|
|
14080
|
+
if (overrides?.poolState) {
|
|
14081
|
+
this.poolStateProvider = overrides.poolState;
|
|
14082
|
+
} else if (localModelsEnabled) {
|
|
14083
|
+
this.poolStateStore = new import_local_models4.PoolStateStore({
|
|
14084
|
+
onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
|
|
14085
|
+
});
|
|
14086
|
+
this.poolStateProvider = this.poolStateStore;
|
|
14087
|
+
this.initModelPool(this.poolStateStore);
|
|
14088
|
+
}
|
|
13338
14089
|
const backendsMap = this.config.agent.backends ?? {};
|
|
13339
14090
|
for (const [name, def] of Object.entries(backendsMap)) {
|
|
13340
14091
|
if (def.type === "local" || def.type === "pi") {
|
|
@@ -13345,10 +14096,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13345
14096
|
};
|
|
13346
14097
|
if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
|
|
13347
14098
|
if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
|
|
14099
|
+
if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
|
|
13348
14100
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
13349
14101
|
}
|
|
13350
14102
|
}
|
|
13351
|
-
this.cacheMetrics = new
|
|
14103
|
+
this.cacheMetrics = new import_core19.CacheMetricsRecorder();
|
|
13352
14104
|
if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
|
|
13353
14105
|
const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
|
|
13354
14106
|
const firstBackendName = Object.keys(this.config.agent.backends)[0];
|
|
@@ -13456,7 +14208,26 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13456
14208
|
roadmapPath: config.tracker.filePath ?? null,
|
|
13457
14209
|
dispatchAdHoc: this.dispatchAdHoc.bind(this),
|
|
13458
14210
|
getLocalModelStatus: () => this.getFirstLocalModelStatus(),
|
|
13459
|
-
getLocalModelStatuses: () => this.buildLocalModelStatuses()
|
|
14211
|
+
getLocalModelStatuses: () => this.buildLocalModelStatuses(),
|
|
14212
|
+
// LMLM Phase 6: expose the live pool so kind:'model' approve/reject
|
|
14213
|
+
// reaches PoolManager (retiring the 501). Null when LMLM is disabled.
|
|
14214
|
+
getModelPool: () => this.modelPool,
|
|
14215
|
+
// LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
|
|
14216
|
+
// of a model an agent could be using is DEFERRED, not applied mid-request
|
|
14217
|
+
// (ADR 0060). Agent-run-coarse; may over-defer (safe).
|
|
14218
|
+
isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
|
|
14219
|
+
// LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
|
|
14220
|
+
getRefreshScheduler: () => this.refreshScheduler,
|
|
14221
|
+
// LMLM Phase 7 read surface — hardware / recommendations / model proposals.
|
|
14222
|
+
// Each returns null/[] when LMLM is disabled so the route renders 503/[].
|
|
14223
|
+
getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
|
|
14224
|
+
getRecommendations: async ({ top }) => {
|
|
14225
|
+
if (this.modelRecommender === null) return [];
|
|
14226
|
+
const hardware = await this.detectLmlmHardware();
|
|
14227
|
+
const { ranked } = await this.modelRecommender(hardware);
|
|
14228
|
+
return ranked.slice(0, top);
|
|
14229
|
+
},
|
|
14230
|
+
listModelProposals: () => (0, import_core18.listProposals)(this.projectRoot, { status: "open", kind: "model" })
|
|
13460
14231
|
});
|
|
13461
14232
|
this.server.setRecorder(this.recorder);
|
|
13462
14233
|
this.interactionQueue.onPush((interaction) => {
|
|
@@ -13473,7 +14244,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13473
14244
|
setupTelemetryExport(config, webhookStore, webhookDelivery) {
|
|
13474
14245
|
const otlpCfg = config.telemetry?.export?.otlp;
|
|
13475
14246
|
if (!otlpCfg) return;
|
|
13476
|
-
this.otlpExporter = new
|
|
14247
|
+
this.otlpExporter = new import_core19.OTLPExporter({
|
|
13477
14248
|
endpoint: otlpCfg.endpoint,
|
|
13478
14249
|
...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
|
|
13479
14250
|
...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
|
|
@@ -13514,6 +14285,60 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13514
14285
|
}
|
|
13515
14286
|
return out;
|
|
13516
14287
|
}
|
|
14288
|
+
/**
|
|
14289
|
+
* S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
|
|
14290
|
+
* is live AND `ollamaName` is a currently-resolved (or last-detected) local
|
|
14291
|
+
* model — i.e. an agent could be routing inference to it right now.
|
|
14292
|
+
*
|
|
14293
|
+
* This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
|
|
14294
|
+
* by GitHub issue (spawned agent runs), NOT by inference call, and no
|
|
14295
|
+
* per-model request counter exists today. The probe therefore MAY OVER-DEFER
|
|
14296
|
+
* (a swap waits until the pool is idle). Over-deferral is exactly S1's
|
|
14297
|
+
* intended safe failure — never yank a model mid-request; occasionally wait
|
|
14298
|
+
* longer than strictly necessary. A fine-grained per-request signal is an
|
|
14299
|
+
* explicit deferred gap (ADR 0060).
|
|
14300
|
+
*/
|
|
14301
|
+
isLocalModelInUse(ollamaName) {
|
|
14302
|
+
if (this.state.running.size === 0) return false;
|
|
14303
|
+
return this.buildLocalModelStatuses().some(
|
|
14304
|
+
(s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
|
|
14305
|
+
);
|
|
14306
|
+
}
|
|
14307
|
+
/**
|
|
14308
|
+
* S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
|
|
14309
|
+
* target was in use, now that the probe reports it idle. Best-effort — called
|
|
14310
|
+
* from the run-completion path; it never blocks dispatch and swallows
|
|
14311
|
+
* per-model errors (a failed or still-busy evict stays pending for the next
|
|
14312
|
+
* drain). `pendingEviction` is a transient overlay, so a missed drain simply
|
|
14313
|
+
* leaves the flag set until the next completion re-checks it.
|
|
14314
|
+
*/
|
|
14315
|
+
async drainDeferredEvictions() {
|
|
14316
|
+
const pool = this.modelPool;
|
|
14317
|
+
if (pool === null) return;
|
|
14318
|
+
if (this.draining) return;
|
|
14319
|
+
this.draining = true;
|
|
14320
|
+
try {
|
|
14321
|
+
for (const ollamaName of pool.listPendingEvictions()) {
|
|
14322
|
+
if (this.isLocalModelInUse(ollamaName)) continue;
|
|
14323
|
+
try {
|
|
14324
|
+
const result = await pool.evict({ ollamaName });
|
|
14325
|
+
if (result.status === "error") continue;
|
|
14326
|
+
pool.clearPendingEviction(ollamaName);
|
|
14327
|
+
this.emit("local-models:pool", {
|
|
14328
|
+
action: "evict",
|
|
14329
|
+
// XP-2: `evicted` is uniformly string[] across all local-models:pool
|
|
14330
|
+
// emit sites — the drain wraps its single completed eviction in an
|
|
14331
|
+
// array to match the swap/add multi-evict shape.
|
|
14332
|
+
evicted: [ollamaName],
|
|
14333
|
+
phase: "evict_completed"
|
|
14334
|
+
});
|
|
14335
|
+
} catch {
|
|
14336
|
+
}
|
|
14337
|
+
}
|
|
14338
|
+
} finally {
|
|
14339
|
+
this.draining = false;
|
|
14340
|
+
}
|
|
14341
|
+
}
|
|
13517
14342
|
createTracker() {
|
|
13518
14343
|
if (this.config.tracker.kind === "github-issues") {
|
|
13519
14344
|
const trackerCfg = {
|
|
@@ -13522,7 +14347,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13522
14347
|
...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
|
|
13523
14348
|
...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
|
|
13524
14349
|
};
|
|
13525
|
-
const clientResult = (0,
|
|
14350
|
+
const clientResult = (0, import_core17.createTrackerClient)(trackerCfg);
|
|
13526
14351
|
if (!clientResult.ok) throw clientResult.error;
|
|
13527
14352
|
return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
|
|
13528
14353
|
}
|
|
@@ -14101,12 +14926,12 @@ ${messages}`);
|
|
|
14101
14926
|
async postLifecycleComment(identifier, externalId, event) {
|
|
14102
14927
|
try {
|
|
14103
14928
|
if (!externalId) return;
|
|
14104
|
-
const trackerConfig = (0,
|
|
14929
|
+
const trackerConfig = (0, import_core17.loadTrackerSyncConfig)(this.projectRoot);
|
|
14105
14930
|
if (!trackerConfig) return;
|
|
14106
14931
|
const token = process.env.GITHUB_TOKEN;
|
|
14107
14932
|
if (!token) return;
|
|
14108
14933
|
const orchestratorId = await this.orchestratorIdPromise;
|
|
14109
|
-
const adapter = new
|
|
14934
|
+
const adapter = new import_core17.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
|
|
14110
14935
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
|
14111
14936
|
const actionMap = {
|
|
14112
14937
|
claimed: "Dispatching agent for autonomous execution",
|
|
@@ -14180,7 +15005,7 @@ ${messages}`);
|
|
|
14180
15005
|
...f.line !== void 0 ? { line: f.line } : {}
|
|
14181
15006
|
}))
|
|
14182
15007
|
);
|
|
14183
|
-
(0,
|
|
15008
|
+
(0, import_core16.writeTaint)(
|
|
14184
15009
|
workspacePath,
|
|
14185
15010
|
issue.id,
|
|
14186
15011
|
"Medium-severity injection patterns found in workspace config files",
|
|
@@ -14360,6 +15185,7 @@ ${messages}`);
|
|
|
14360
15185
|
error,
|
|
14361
15186
|
(effect) => this.handleEffect(effect)
|
|
14362
15187
|
);
|
|
15188
|
+
void this.drainDeferredEvictions();
|
|
14363
15189
|
this.emit("state_change", this.getSnapshot());
|
|
14364
15190
|
}
|
|
14365
15191
|
/**
|
|
@@ -14447,6 +15273,120 @@ ${messages}`);
|
|
|
14447
15273
|
* before constructing the intelligence pipeline. Subscribes the dashboard
|
|
14448
15274
|
* broadcast stub to status changes. Called exactly once from start().
|
|
14449
15275
|
*/
|
|
15276
|
+
/**
|
|
15277
|
+
* LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
|
|
15278
|
+
* loaded pool-state store) and stash it for `getModelPool()`. Defensive
|
|
15279
|
+
* config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
|
|
15280
|
+
* `store.snapshot()` lazily, so this runs safely at construction time before
|
|
15281
|
+
* `store.load()`.
|
|
15282
|
+
*/
|
|
15283
|
+
initModelPool(store) {
|
|
15284
|
+
const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
|
|
15285
|
+
const installerCfg = this.config.localModels?.installer;
|
|
15286
|
+
this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
|
|
15287
|
+
baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
|
|
15288
|
+
onWarn
|
|
15289
|
+
});
|
|
15290
|
+
this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
|
|
15291
|
+
}
|
|
15292
|
+
/**
|
|
15293
|
+
* LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
|
|
15294
|
+
* budget + org/family allowlist) from `localModels.pool` to the live pool.
|
|
15295
|
+
* Called after `PoolStateStore.load()` so config wins over persisted bounds
|
|
15296
|
+
* (D2, declarative precedence). No-op when the pool is null (LMLM disabled)
|
|
15297
|
+
* or no `pool` block is configured, so it is safe to call unconditionally
|
|
15298
|
+
* on the startup path.
|
|
15299
|
+
*/
|
|
15300
|
+
async applyConfiguredPoolBounds() {
|
|
15301
|
+
const pool = this.modelPool;
|
|
15302
|
+
const bounds = this.config.localModels?.pool;
|
|
15303
|
+
if (pool === null || !bounds) return;
|
|
15304
|
+
await pool.configurePool({
|
|
15305
|
+
diskBudgetGb: bounds.diskBudgetGb,
|
|
15306
|
+
allowedOrgs: bounds.allowedOrgs,
|
|
15307
|
+
allowedFamilies: bounds.allowedFamilies
|
|
15308
|
+
});
|
|
15309
|
+
}
|
|
15310
|
+
/**
|
|
15311
|
+
* LMLM Phase 6: arm the single background refresh scheduler over the live
|
|
15312
|
+
* pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
|
|
15313
|
+
* hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
|
|
15314
|
+
*
|
|
15315
|
+
* NOTE (deferred): the recommender is seeded with an empty candidate set —
|
|
15316
|
+
* Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
|
|
15317
|
+
* swap-proposal discovery is out of scope here (flagged concern). The tick
|
|
15318
|
+
* still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
|
|
15319
|
+
* whatever candidates are supplied — the wiring is complete and candidate
|
|
15320
|
+
* breadth is the only piece deferred to the Phase 2 recommender.
|
|
15321
|
+
*/
|
|
15322
|
+
startRefreshScheduler() {
|
|
15323
|
+
if (this.modelPool === null) return;
|
|
15324
|
+
const pool = this.modelPool;
|
|
15325
|
+
const refreshCfg = this.config.localModels?.refresh;
|
|
15326
|
+
const frozen = (0, import_local_models4.loadFrozenCandidates)();
|
|
15327
|
+
const candidates = (0, import_local_models4.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
|
|
15328
|
+
for (const warning of frozen.warnings) {
|
|
15329
|
+
this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
|
|
15330
|
+
}
|
|
15331
|
+
if (candidates.length === 0) {
|
|
15332
|
+
this.logger.warn("LMLM recommender has no candidates", {
|
|
15333
|
+
frozenCount: frozen.candidates.length,
|
|
15334
|
+
allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
|
|
15335
|
+
});
|
|
15336
|
+
}
|
|
15337
|
+
const recommend = (0, import_local_models4.createNativeRecommender)({ candidates });
|
|
15338
|
+
this.modelRecommender = recommend;
|
|
15339
|
+
this.refreshScheduler = new import_local_models4.RefreshScheduler({
|
|
15340
|
+
runTick: () => (0, import_local_models4.runRefreshTick)({
|
|
15341
|
+
detectHardware: () => this.detectLmlmHardware(),
|
|
15342
|
+
recommend,
|
|
15343
|
+
poolManager: pool,
|
|
15344
|
+
dedupSource: () => this.lmlmDedupSource(),
|
|
15345
|
+
// Phase 7: after persisting the proposal, emit `local-models:proposal`
|
|
15346
|
+
// (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
|
|
15347
|
+
// notification sinks. Literal to avoid a proposals/model-handlers cycle.
|
|
15348
|
+
emitProposal: (c) => (0, import_core18.createModelProposal)(this.projectRoot, c).then((record) => {
|
|
15349
|
+
this.emit("local-models:proposal", {
|
|
15350
|
+
id: record.id,
|
|
15351
|
+
status: "created",
|
|
15352
|
+
action: c.action,
|
|
15353
|
+
target: c.target.ollamaName
|
|
15354
|
+
});
|
|
15355
|
+
}),
|
|
15356
|
+
proposalThreshold: refreshCfg?.proposalThreshold ?? 5
|
|
15357
|
+
}).then((result) => {
|
|
15358
|
+
void this.drainDeferredEvictions();
|
|
15359
|
+
return result;
|
|
15360
|
+
}),
|
|
15361
|
+
intervalMs: refreshCfg?.intervalMs ?? 864e5,
|
|
15362
|
+
jitterMs: refreshCfg?.jitterMs ?? 6e5,
|
|
15363
|
+
logger: this.logger,
|
|
15364
|
+
...this.schedulerTimerOverride ?? {}
|
|
15365
|
+
});
|
|
15366
|
+
this.refreshScheduler.start();
|
|
15367
|
+
}
|
|
15368
|
+
/** Resolve the hardware profile for a refresh tick (operator override wins). */
|
|
15369
|
+
async detectLmlmHardware() {
|
|
15370
|
+
const override = this.config.localModels?.hardware?.override;
|
|
15371
|
+
const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
|
|
15372
|
+
return (await detector.detect()).profile;
|
|
15373
|
+
}
|
|
15374
|
+
/** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
|
|
15375
|
+
async lmlmDedupSource() {
|
|
15376
|
+
const proposals = await (0, import_core18.listProposals)(this.projectRoot, { kind: "model" });
|
|
15377
|
+
const pending = [];
|
|
15378
|
+
const rejected = [];
|
|
15379
|
+
for (const p of proposals) {
|
|
15380
|
+
if (p.kind !== "model") continue;
|
|
15381
|
+
const pair = {
|
|
15382
|
+
target: p.model.target.ollamaName,
|
|
15383
|
+
...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
|
|
15384
|
+
};
|
|
15385
|
+
if (p.status === "open") pending.push(pair);
|
|
15386
|
+
else if (p.status === "rejected") rejected.push(pair);
|
|
15387
|
+
}
|
|
15388
|
+
return { pending, rejected };
|
|
15389
|
+
}
|
|
14450
15390
|
async initLocalModelAndPipeline() {
|
|
14451
15391
|
if (this.localResolvers.size > 0) {
|
|
14452
15392
|
const backends = this.config.agent.backends ?? {};
|
|
@@ -14469,10 +15409,15 @@ ${messages}`);
|
|
|
14469
15409
|
});
|
|
14470
15410
|
this.localModelStatusUnsubscribes.push(unsubscribe);
|
|
14471
15411
|
}
|
|
15412
|
+
if (this.poolStateStore !== null) {
|
|
15413
|
+
await this.poolStateStore.load();
|
|
15414
|
+
await this.applyConfiguredPoolBounds();
|
|
15415
|
+
}
|
|
14472
15416
|
for (const resolver of this.localResolvers.values()) {
|
|
14473
15417
|
await resolver.start();
|
|
14474
15418
|
}
|
|
14475
15419
|
}
|
|
15420
|
+
this.startRefreshScheduler();
|
|
14476
15421
|
this.pipeline = this.createIntelligencePipeline();
|
|
14477
15422
|
this.server?.setPipeline(this.pipeline);
|
|
14478
15423
|
}
|
|
@@ -14548,6 +15493,8 @@ ${messages}`);
|
|
|
14548
15493
|
for (const resolver of this.localResolvers.values()) {
|
|
14549
15494
|
resolver.stop();
|
|
14550
15495
|
}
|
|
15496
|
+
this.refreshScheduler?.stop();
|
|
15497
|
+
this.refreshScheduler = null;
|
|
14551
15498
|
if (this.maintenanceScheduler) {
|
|
14552
15499
|
this.maintenanceScheduler.stop();
|
|
14553
15500
|
this.maintenanceScheduler = null;
|