Skip to content

Commit 06a0e8e

Browse files
committed
feat(webui): open the host services window for capability exposure batches
1 parent 38befac commit 06a0e8e

9 files changed

Lines changed: 473 additions & 0 deletions

File tree

‎packages/local-runtime-v2/src/local/host-contract.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from '@mavis/local-runtime';
77
import type { LocalBrowserAdapter, LocalBrowserToolExposure } from '@mavis/agent-tools/desktop';
88
import type { RuntimeApplications } from '../application/initialize.js';
9+
import type { RuntimeServices } from '../services.js';
910
import type {
1011
MiniAppPresenter,
1112
LocalRuntimeApplication,
@@ -80,6 +81,21 @@ interface CreatedLocalRuntimeHost extends V1CreatedLocalRuntimeHost {
8081
*/
8182
applications?: RuntimeApplications;
8283
cliService?: import('./cli-service.js').CliService;
84+
/**
85+
* The composed V2 service owners (`managedWorktrees`, `pinService`,
86+
* `agent`, `mcp`, `skill`, `modelSystem`, …), handed to the embedder
87+
* as-is. Distinct from `application` / `applications`: those two are
88+
* the PRODUCT use cases, this is the OWNER graph behind them, so a
89+
* consumer that finds no use case it can call (`agent` has none on
90+
* the process-local facade) still has a real path to the capability.
91+
*
92+
* Optional because a host without the V2 compatibility slice
93+
* (`compatibility === undefined`) has no service owners at all — the
94+
* member is absent rather than an empty object, so "no owner" and
95+
* "owner with no members" cannot be confused. Every member is
96+
* `readonly`; lifecycle stays with the host's `close()`.
97+
*/
98+
services?: RuntimeServices;
8399
}
84100

85101
export type {

‎packages/local-runtime-v2/src/runtime.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,13 @@ function createStartedHost(
439439
? {
440440
application: ownerRuntime.services.application,
441441
applications: ownerRuntime.services.applications,
442+
// The owner graph itself, for embedders whose product facade
443+
// carries no use case for a capability the services do own
444+
// (the process-local facade declares no `agent` member, while
445+
// `services.agent` is a full AgentApplication). Same object,
446+
// same lifetime: `services.close()` stays reached only through
447+
// the host's own shutdown path.
448+
services: ownerRuntime.services,
442449
}
443450
: {}),
444451
...(cliService ? { cliService } : {}),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// webui/server/engine/host-services.js
2+
//
3+
// The host-services window (placeholder batch PB-8): the one place webui
4+
// code asks the live catalogue host for its `services` object.
5+
//
6+
// Why this file exists at all. The engine abstraction gave webui three
7+
// ways to reach a runtime capability, and only two of them carry most of
8+
// the catalogue:
9+
//
10+
// 1. `host.application` — the process-local product facade (events,
11+
// models, skills, plugins, permissions, miniApps …). PB-5 / PB-6 /
12+
// PB-9 route through this one.
13+
// 2. `host.applications` — the generated feature applications
14+
// (`session.query` / `lifecycle` / `root` / `diff`, `queue`).
15+
// PB-1 / PB-2 route through this one.
16+
// 3. `host.services` — the V2 owner graph itself, handed over by
17+
// `createCatalogueHost` (providers/local-runtime-v2.js). This is
18+
// where `agent`, `managedWorktrees`, `pinService`, `mcp`, `skill`,
19+
// `modelSystem`, `workspace`, `canvas`, `browserUse` and the rest
20+
// live, and for several of them it is the ONLY route: the
21+
// process-local facade declares no `agent` member at all, so an
22+
// Agent endpoint reading `host.application` finds nothing to call.
23+
//
24+
// PB-7 (agents) and PB-3 (worktrees) are both blocked on this window.
25+
// Everything else in the placeholder series may use it or ignore it.
26+
//
27+
// What this batch deliberately does NOT do:
28+
//
29+
// - It wires NO route. An endpoint is not a member of this batch; the
30+
// consumer batches own their own capability gate (hard 501 or soft
31+
// report), because only they know whether their endpoint can
32+
// degrade. A window that arrived with a route attached would make
33+
// that decision for them by accident.
34+
// - It does NOT touch `runtimeOwnerKind`. See F-13 below.
35+
// - It does NOT probe, normalise, wrap or copy the object. The
36+
// returned value is the runtime's own object, by reference. A
37+
// facade that cloned it would freeze the truth at open time and
38+
// hide the next `undefined`.
39+
//
40+
// F-13 — why `services.cron` reads `undefined` here, and why that is
41+
// not this batch's bug to fix. `RuntimeServices.cron` is typed
42+
// `readonly cron?: CronService` and is documented in
43+
// `packages/local-runtime-v2/src/services.ts` as "Electron-only
44+
// scheduled execution capability; absent on embedded CLI". webui boots
45+
// the host with `runtimeOwnerKind: "tui"` (the embedded capability
46+
// ceiling), so the cron owner is never composed and the member is
47+
// absent — `services.cron === undefined`, not null and not a stub.
48+
// Whether the embedded CLI/TUI owner SHOULD get a cron owner is an
49+
// open decision (F-13); this window reports the composition honestly so
50+
// the decision can be made once and read everywhere. Consumers that
51+
// need cron must gate on its presence rather than assume it.
52+
//
53+
// The return contract, stated because both answers are meaningful:
54+
//
55+
// - `null` — there is no host. Same answer `getEngineCatalogueHost`
56+
// gives for a runtime that failed to boot, passed
57+
// through unchanged so a caller cannot mistake "no
58+
// runtime" for "runtime without services".
59+
// - `undefined` — there IS a host, and it carries no `services` (a
60+
// non-V2 host). Distinct from `null` on purpose.
61+
// - `object` — the runtime's own `RuntimeServices`. Individual
62+
// members may still be `undefined` (cron, above, plus
63+
// the other owner-kind-gated members `channelSystem`
64+
// and `llmContextInspector`); that is the truth, not a
65+
// failure, and each consumer batch verifies its own
66+
// members before building on them.
67+
//
68+
// Boot-path discipline, unchanged from engine/host.js: this module
69+
// imports nothing heavy. The host getter is reached through the
70+
// facade's own dynamic import, so adding the window costs a function
71+
// definition and nothing else — `engine/index.js` can re-export it
72+
// without pulling the `@mavis` / `@minimax` package trees into every
73+
// server start.
74+
75+
import { getEngineCatalogueHost } from "./host.js";
76+
77+
/**
78+
* The catalogue host's `services` object, by reference.
79+
*
80+
* @param {object} [deps]
81+
* @param {() => Promise<object|null>} [deps.getHost] Host getter seam;
82+
* defaults to the engine facade's `getEngineCatalogueHost`. It
83+
* exists so a test can hand in a host without booting a runtime
84+
* — the same injection shape `routes/plugins.js` uses for
85+
* `getCliService`. Passing a getter that throws is the caller's
86+
* problem: the error propagates unchanged, exactly as it does
87+
* through `getEngineCatalogueHost`.
88+
* @returns {Promise<object|null|undefined>} The host's `services`, or
89+
* `null` when there is no host, or `undefined` when the host carries
90+
* none. See the module header for why all three are distinct.
91+
*/
92+
export async function getHostServices(deps = {}) {
93+
const getHost = deps.getHost ?? getEngineCatalogueHost;
94+
const host = await getHost();
95+
if (!host) {
96+
// No runtime at all. Forwarding the host getter's own "no host"
97+
// answer keeps the two failure modes apart: a caller that maps
98+
// `null` to RUNTIME_UNAVAILABLE must not fire for a host that
99+
// simply lacks the member.
100+
return null;
101+
}
102+
// `??` would fold a present-but-undefined member into `null` and lose
103+
// the distinction the header promises. Read the member as-is: an
104+
// absent one is the honest answer, and this window never fabricates
105+
// a placeholder object for a consumer to mistake for a real owner.
106+
return host.services;
107+
}

‎packages/webui/server/engine/index.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export {
8181
} from "./errors.js";
8282
// The lazy host getter: a function definition, no host, no @mavis/* import.
8383
export { getEngineCatalogueHost } from "./host.js";
84+
// The host-services window (placeholder batch PB-8): the read side of the
85+
// V2 owner graph, for the capability families the product facades do not
86+
// carry — PB-7 (agents, `services.agent`), PB-3 (worktrees,
87+
// `services.managedWorktrees`) and anything else that finds no use case on
88+
// `host.application` / `host.applications`. It is a window and nothing
89+
// more: no route consumes it yet, and each consumer batch owns its own
90+
// capability gate. Its only static import is `engine/host.js`, so the
91+
// boot-path rule the header above states holds here unchanged —
92+
// re-exporting it costs a function, not a module load.
93+
export { getHostServices } from "./host-services.js";
8494
// The directory-read family's gated reads (step M3, batch B1). Re-exported
8595
// here so the facade is the one import site for engine reads, but the
8696
// dependency runs the other way too — session-reads.js consults

‎packages/webui/server/engine/providers/local-runtime-v2.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function buildAuthContextGetter(dataDir) {
9393
* controller: object,
9494
* application: object|undefined,
9595
* applications: { session: { diff: object }, queue: object }|undefined,
96+
* services: object|undefined,
9697
* close: () => Promise<void>,
9798
* }>}
9899
*/
@@ -178,6 +179,16 @@ export async function createCatalogueHost(options) {
178179
// here and NOT on `application`: the process-local facade declares
179180
// no diff member, so reading diff off `application` yields nothing.
180181
applications: host.applications,
182+
// The V2 owner graph, forwarded untouched. `application` /
183+
// `applications` are the PRODUCT use cases; several real
184+
// capabilities (`agent`, `managedWorktrees`, `pinService`, `mcp`,
185+
// `skill`, `modelSystem`) exist only on `services`, so a consumer
186+
// with no facade route to them has no window at all without this
187+
// member. `undefined` on a host that has no V2 compatibility slice
188+
// — never a synthesised empty object, so "no owner graph" and "owner
189+
// graph without member X" stay distinguishable. Same single-owner
190+
// rule as cliService: never build a second runtime to get one.
191+
services: host.services,
181192
close,
182193
};
183194
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// webui/test/lib/engine/host-services.test.js
2+
//
3+
// The host-services window (placeholder batch PB-8) — shape and contract.
4+
//
5+
// The real-host evidence lives next door, in
6+
// `test/server/host-services-window.test.js`, because booting a runtime
7+
// costs seconds and belongs with the other real-host suites. What this
8+
// file owns is everything that can be decided without one, and one of
9+
// those is load-bearing:
10+
//
11+
// 1. Source shape — the window reaches the host through the facade's
12+
// `getEngineCatalogueHost` and through NOTHING else. The tempting
13+
// short-cut is to import `engine/providers/local-runtime-v2.js`
14+
// directly (it is right there, and it is where `services` is
15+
// forwarded). That would put the whole `@mavis/*` TypeScript tree
16+
// on the boot path, because `app.js` loads `engine/index.js` at
17+
// start — the exact regression M1 paid for once (209ms → 2700ms)
18+
// and the one `host-facade.test.js` exists to guard. A second
19+
// short-cut is to construct a host here, which is worse: two
20+
// runtimes over one dataDir is a split brain against the plugin
21+
// and local-disable tables.
22+
//
23+
// 2. Return contract — three distinct answers (`null` for no host,
24+
// `undefined` for a host without services, the object for a host
25+
// with them). The middle answer is the one a `??` away from
26+
// silently collapsing into the first, and it is the answer a
27+
// consumer gating on presence needs.
28+
//
29+
// 3. Scope — the window ships WITHOUT a route. The only thing this
30+
// batch adds to the HTTP surface is nothing, and that is asserted
31+
// rather than asserted-in-a-comment: the module exports one
32+
// function and no handler.
33+
//
34+
// Anti-mirroring note, since "改坏必红" is the discipline here: every
35+
// assertion below is about what the module does or names, not about a
36+
// restatement of its own implementation. The three ways to break this
37+
// window — return a hardcoded empty object, bypass the facade singleton,
38+
// drop the `await` — each turn at least one of these red.
39+
40+
import { test, describe } from "node:test";
41+
import assert from "node:assert/strict";
42+
import { readFileSync, readdirSync } from "node:fs";
43+
import { join } from "node:path";
44+
45+
const packageDir = join(import.meta.dirname, "..", "..", "..");
46+
const serverDir = join(packageDir, "server");
47+
const read = (rel) => readFileSync(join(serverDir, rel), "utf8");
48+
49+
/** Strip comments — this file's prose legitimately names the guarded modules. */
50+
function codeOf(source) {
51+
return source
52+
.replace(/\/\*[\s\S]*?\*\//g, "")
53+
.split("\n")
54+
.map((line) => line.replace(/^\s*\/\/.*$/, ""))
55+
.join("\n");
56+
}
57+
58+
const WINDOW = "engine/host-services.js";
59+
60+
// --- 1. source shape -------------------------------------------------------
61+
62+
describe("the window reaches the host through the facade singleton only", () => {
63+
test(`${WINDOW} takes the host from the engine facade getter`, () => {
64+
const code = codeOf(read(WINDOW));
65+
assert.match(
66+
code,
67+
/import\s*\{[^}]*getEngineCatalogueHost[^}]*\}\s*from\s*"\.\/host\.js"/,
68+
"the window must import getEngineCatalogueHost from ./host.js — the one process-wide host",
69+
);
70+
assert.match(code, /await\s+getHost\(\)/, "the window must await the host getter");
71+
});
72+
73+
test(`${WINDOW} never names a host-construction module`, () => {
74+
const code = codeOf(read(WINDOW));
75+
for (const forbidden of [
76+
"acp-client",
77+
"runtime-host",
78+
"providers/local-runtime-v2",
79+
"@mavis/",
80+
"@minimax/",
81+
"createCatalogueHost",
82+
"createLocalRuntimeHostV2",
83+
]) {
84+
assert.ok(
85+
!code.includes(forbidden),
86+
`${WINDOW} must not mention ${forbidden} — that is the boot path / second-host rule the facade exists to hold`,
87+
);
88+
}
89+
});
90+
91+
test("engine/index.js re-exports the window beside the host getter", () => {
92+
assert.match(
93+
read("engine/index.js"),
94+
/export\s*\{[^}]*getHostServices[^}]*\}\s*from\s*"\.\/host-services\.js"/,
95+
"engine/index.js must re-export getHostServices, or every consumer would import undefined",
96+
);
97+
});
98+
});
99+
100+
// --- 2. return contract ----------------------------------------------------
101+
102+
describe("the window reports what the host actually carries", () => {
103+
test("returns the host's services object by reference, not a copy", async () => {
104+
const { getHostServices } = await import("../../../server/engine/host-services.js");
105+
const services = { managedWorktrees: { list() {} }, cron: undefined };
106+
const host = { services };
107+
assert.equal(
108+
await getHostServices({ getHost: async () => host }),
109+
services,
110+
"the window must forward the runtime's own object — a clone would freeze the truth at open time",
111+
);
112+
});
113+
114+
test("answers null — not undefined — when there is no host", async () => {
115+
const { getHostServices } = await import("../../../server/engine/host-services.js");
116+
const answer = await getHostServices({ getHost: async () => null });
117+
assert.equal(
118+
answer,
119+
null,
120+
"no runtime is `null`, matching getEngineCatalogueHost — a caller mapping null to RUNTIME_UNAVAILABLE must not fire for a host that merely lacks services",
121+
);
122+
});
123+
124+
test("answers undefined — not null — when a host carries no services", async () => {
125+
const { getHostServices } = await import("../../../server/engine/host-services.js");
126+
const answer = await getHostServices({ getHost: async () => ({ cliService: {} }) });
127+
assert.equal(
128+
answer,
129+
undefined,
130+
"a host without the member is a distinct state; `?? null` here would erase the difference a consumer gating on presence depends on",
131+
);
132+
});
133+
134+
test("propagates a throwing host getter unchanged", async () => {
135+
const { getHostServices } = await import("../../../server/engine/host-services.js");
136+
const boom = new Error("runtime boot failed");
137+
await assert.rejects(
138+
getHostServices({
139+
getHost: async () => {
140+
throw boom;
141+
},
142+
}),
143+
(error) => error === boom,
144+
"the window owns no failure mapping — the caller does, exactly as through getEngineCatalogueHost",
145+
);
146+
});
147+
148+
test("defaults to the facade getter when no seam is injected", () => {
149+
// Not a behaviour test: the default is what every real consumer uses,
150+
// and a window that only worked with an injected getter would be dead
151+
// code that its own unit tests pass.
152+
const code = codeOf(read(WINDOW));
153+
assert.match(
154+
code,
155+
/const\s+getHost\s*=\s*deps\.getHost\s*\?\?\s*getEngineCatalogueHost/,
156+
"the default must be the real facade getter",
157+
);
158+
});
159+
});
160+
161+
// --- 3. scope --------------------------------------------------------------
162+
163+
describe("the batch ships a window and no endpoint", () => {
164+
test(`${WINDOW} exports the window and nothing else`, async () => {
165+
const module = await import("../../../server/engine/host-services.js");
166+
assert.deepEqual(
167+
Object.keys(module),
168+
["getHostServices"],
169+
"a handler exported from this module would mean the batch attached a route; PB-3 / PB-7 own those",
170+
);
171+
});
172+
173+
test("no HTTP route file consumes the window yet", () => {
174+
const routeDir = join(serverDir, "routes");
175+
const routes = readdirSync(routeDir).filter((name) => name.endsWith(".js"));
176+
const consumers = routes.filter((name) => codeOf(read(join("routes", name))).includes("getHostServices"));
177+
assert.deepEqual(
178+
consumers,
179+
[],
180+
"PB-8 is the prerequisite, not the delivery — a route here belongs to the batch that owns that capability",
181+
);
182+
});
183+
});

0 commit comments

Comments
 (0)