@ours.network/fleet 0.15.3 → 0.15.4

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.
Files changed (58) hide show
  1. package/README.md +13 -6
  2. package/dist/application/fleet-query-service.js +3 -0
  3. package/dist/application/model-catalog.d.ts +20 -0
  4. package/dist/application/model-catalog.js +57 -0
  5. package/dist/application/role-creation-service.d.ts +7 -0
  6. package/dist/application/role-creation-service.js +21 -4
  7. package/dist/application/role-removal-service.d.ts +32 -0
  8. package/dist/application/role-removal-service.js +87 -0
  9. package/dist/application/role-repository.js +13 -1
  10. package/dist/application/session-control.d.ts +74 -0
  11. package/dist/application/session-control.js +66 -1
  12. package/dist/application/types.d.ts +18 -0
  13. package/dist/briefing.js +10 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/docs.d.ts +1 -1
  18. package/dist/docs.js +14 -5
  19. package/dist/fleet-proxy.js +3 -1
  20. package/dist/harness/claude-code.js +20 -3
  21. package/dist/harness/codex.js +14 -2
  22. package/dist/harness/types.d.ts +6 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +1 -0
  25. package/dist/owner-channel/channel.js +6 -5
  26. package/dist/permissions.d.ts +5 -0
  27. package/dist/permissions.js +7 -0
  28. package/dist/runner.js +2 -0
  29. package/dist/session/acp.d.ts +61 -1
  30. package/dist/session/acp.js +398 -20
  31. package/dist/session/arbiter.d.ts +10 -1
  32. package/dist/session/arbiter.js +24 -0
  33. package/dist/session/control.d.ts +28 -2
  34. package/dist/session/control.js +145 -5
  35. package/dist/session/conversation-normalizer.d.ts +34 -0
  36. package/dist/session/conversation-normalizer.js +356 -0
  37. package/dist/session/conversation-store.d.ts +88 -0
  38. package/dist/session/conversation-store.js +347 -0
  39. package/dist/session/conversation-types.d.ts +274 -0
  40. package/dist/session/conversation-types.js +1 -0
  41. package/dist/session/types.d.ts +40 -0
  42. package/dist/spawn.d.ts +1 -0
  43. package/dist/spawn.js +9 -4
  44. package/dist/web/auth.d.ts +1 -1
  45. package/dist/web/fleet-config-service.d.ts +47 -0
  46. package/dist/web/fleet-config-service.js +204 -0
  47. package/dist/web/runtime.js +14 -1
  48. package/dist/web/server.d.ts +6 -0
  49. package/dist/web/server.js +181 -9
  50. package/dist/web/topology.d.ts +31 -0
  51. package/dist/web/topology.js +61 -0
  52. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  53. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  54. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  55. package/dist/web-app/index.html +2 -2
  56. package/package.json +1 -1
  57. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  58. package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
@@ -1,4 +1,5 @@
1
1
  import type { SessionBackendId } from '../config.js';
2
+ import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
2
3
  export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
3
4
  export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
4
5
  export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
@@ -9,13 +10,23 @@ export type PromptOrigin = {
9
10
  } | {
10
11
  kind: 'owner';
11
12
  requestId: string;
13
+ displayText?: string;
12
14
  } | {
13
15
  kind: 'fleet-monitor';
14
16
  } | {
15
17
  kind: 'scheduled-loop';
16
18
  loop: string;
17
19
  runId: string;
20
+ } | {
21
+ kind: 'owner-admin-console';
22
+ commandId: string;
18
23
  };
24
+ export interface RuntimeSelectorMetadata {
25
+ /** Exact provider/model identifier reported by the live ACP session. */
26
+ value: string;
27
+ /** Complete provider-supplied label for that exact value, when available. */
28
+ label?: string;
29
+ }
19
30
  /**
20
31
  * Two independent facts about one turn, deliberately kept apart:
21
32
  *
@@ -105,6 +116,10 @@ export interface SubmitPromptOptions {
105
116
  origin?: PromptOrigin;
106
117
  /** Use the ACP steering extension when available; ignored by other backends. */
107
118
  steer?: boolean;
119
+ /** Audit-grade actor detail persisted with the conversation admission record. */
120
+ actor?: {
121
+ browserSession?: string;
122
+ };
108
123
  }
109
124
  export interface SessionSnapshot {
110
125
  backend: SessionBackendId;
@@ -113,6 +128,14 @@ export interface SessionSnapshot {
113
128
  sessionId?: string;
114
129
  lastError?: string;
115
130
  pendingPermissionId?: string;
131
+ runtimeModel?: RuntimeSelectorMetadata;
132
+ reasoningEffort?: RuntimeSelectorMetadata;
133
+ permissionMode?: {
134
+ /** Effective harness-neutral policy after native overrides. */
135
+ fleetMode: import('../config.js').FleetPermissionMode;
136
+ /** Exact harness-native approval/permission mode used by this runner. */
137
+ nativeMode: string;
138
+ };
116
139
  }
117
140
  export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
118
141
  /** What a settled permission request resolved to. */
@@ -147,11 +170,26 @@ export interface SessionEvent {
147
170
  /** The option actually selected, when one was. */
148
171
  optionId?: string;
149
172
  }
173
+ export interface ConversationHandlePage {
174
+ events: ConversationEventV1[];
175
+ firstAvailableCursor?: string;
176
+ nextCursor?: string;
177
+ hasMore: boolean;
178
+ snapshot: ConversationSnapshot;
179
+ }
150
180
  export interface SessionHandle {
151
181
  readonly backend: SessionBackendId;
152
182
  readonly pid: number;
153
183
  isAlive(): boolean;
154
184
  snapshot(): SessionSnapshot;
185
+ conversationPage?(request: {
186
+ after?: string;
187
+ limit?: number;
188
+ }): ConversationHandlePage;
189
+ conversationSnapshot?(): ConversationSnapshot;
190
+ subscribeConversation?(listener: (event: ConversationEventV1) => void): () => void;
191
+ /** Durably admit an idempotent browser prompt; resolves on admission. */
192
+ submitPromptBrowser?(command: SubmitPromptCommand): Promise<PromptReceipt>;
155
193
  /**
156
194
  * Hand the session a prompt and return as soon as it has accepted
157
195
  * responsibility for it. Throws `SessionControlError` if it cannot.
@@ -161,6 +199,8 @@ export interface SessionHandle {
161
199
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
162
200
  interrupt(source?: TurnCancellationSource): Promise<void>;
163
201
  respondPermission(permissionId: string, optionId: string): boolean;
202
+ /** Generation-bound browser decision; stale/settled/invalid all fail closed. */
203
+ respondPermissionV2?(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
164
204
  eventsSince(seq: number): SessionEvent[];
165
205
  subscribe(listener: (event: SessionEvent) => void): () => void;
166
206
  setControllerAttached(attached: boolean): void;
package/dist/spawn.d.ts CHANGED
@@ -30,6 +30,7 @@ export interface SpawnOpts {
30
30
  launcher?: string;
31
31
  search?: boolean;
32
32
  codexConfig?: Record<string, string | number | boolean>;
33
+ reasoningEffort?: string | null;
33
34
  addDirs?: string[];
34
35
  monitor?: boolean;
35
36
  /** Typed external monitor configuration used by trusted creation surfaces. */
package/dist/spawn.js CHANGED
@@ -62,8 +62,13 @@ export function buildRoleConfig(o, defaultHarness) {
62
62
  harnessOptions.launcher = o.launcher;
63
63
  if (o.search === true)
64
64
  harnessOptions.search = true;
65
- if (o.codexConfig && Object.keys(o.codexConfig).length)
66
- harnessOptions.config = o.codexConfig;
65
+ const codexConfig = { ...(o.codexConfig ?? {}) };
66
+ if (o.reasoningEffort && harness === 'codex')
67
+ codexConfig.model_reasoning_effort = o.reasoningEffort;
68
+ if (Object.keys(codexConfig).length)
69
+ harnessOptions.config = codexConfig;
70
+ if (o.reasoningEffort && harness === 'claude-code')
71
+ harnessOptions.effort = o.reasoningEffort;
67
72
  if (o.addDirs?.length)
68
73
  harnessOptions.add_dirs = o.addDirs;
69
74
  if (o.monitor === true)
@@ -118,8 +123,8 @@ export function validateSpawnOpts(o) {
118
123
  throw new Error('--mission and --mission-file are mutually exclusive');
119
124
  if (o.session && !['tmux', 'acp'].includes(o.session))
120
125
  throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
121
- if (o.approval && !['ask', 'allow', 'deny'].includes(o.approval))
122
- throw new Error(`invalid --approval '${o.approval}'; allowed: ask, allow, deny`);
126
+ if (o.approval && !['ask', 'auto', 'allow', 'deny'].includes(o.approval))
127
+ throw new Error(`invalid --approval '${o.approval}'; allowed: ask, auto, allow (deprecated alias: deny)`);
123
128
  if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
124
129
  throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
125
130
  if (o.unattended && !['deny', 'wait'].includes(o.unattended))
@@ -16,7 +16,7 @@ export interface AuthResult {
16
16
  interface Ticket {
17
17
  value: string;
18
18
  sessionId: string;
19
- purpose: 'events' | 'terminal';
19
+ purpose: 'events' | 'terminal' | 'conversation';
20
20
  roleId?: string;
21
21
  expiresAt: number;
22
22
  }
@@ -0,0 +1,47 @@
1
+ import type { PrereqReport } from '../harness/types.js';
2
+ export declare const REDACTED_ENV_VALUE = "__OURS_FLEET_SECRET_REDACTED__";
3
+ export type EditableFleetModel = Record<string, unknown>;
4
+ export interface ConfigReadResult {
5
+ path: string;
6
+ exists: boolean;
7
+ firstRun: boolean;
8
+ revision: string;
9
+ model: EditableFleetModel;
10
+ redactions: string[];
11
+ }
12
+ export interface RestartImpact {
13
+ required: boolean;
14
+ roles: string[];
15
+ watchdogScheduler: boolean;
16
+ scheduledLoops: boolean;
17
+ summary: string;
18
+ }
19
+ export interface ConfigPreviewResult {
20
+ valid: true;
21
+ revision: string;
22
+ normalizedModel: EditableFleetModel;
23
+ diff: string;
24
+ redactions: string[];
25
+ impact: RestartImpact;
26
+ preflight: PrereqReport;
27
+ }
28
+ export interface ConfigWriteResult extends ConfigPreviewResult {
29
+ saved: true;
30
+ newRevision: string;
31
+ backup?: string;
32
+ }
33
+ export interface FleetConfigServiceOptions {
34
+ configPath?: string;
35
+ preflight?(configPath: string): Promise<PrereqReport>;
36
+ }
37
+ export declare class FleetConfigService {
38
+ readonly path: string;
39
+ private readonly preflight;
40
+ constructor(options?: FleetConfigServiceOptions);
41
+ read(): ConfigReadResult;
42
+ preview(baseRevision: string, model: unknown): Promise<ConfigPreviewResult>;
43
+ write(baseRevision: string, model: unknown): Promise<ConfigWriteResult>;
44
+ private readSource;
45
+ private assertRevision;
46
+ private validateCandidate;
47
+ }
@@ -0,0 +1,204 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { stringify } from 'yaml';
5
+ import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
6
+ import { loadConfig } from '../config.js';
7
+ import { parseFleetDocument } from '../config-yaml.js';
8
+ import { defaultConfigPath } from '../paths.js';
9
+ import { FleetError } from '../application/errors.js';
10
+ export const REDACTED_ENV_VALUE = '__OURS_FLEET_SECRET_REDACTED__';
11
+ const MAX_CONFIG_BYTES = 256 * 1024;
12
+ const emptyReport = () => ({ ok: true, checks: [] });
13
+ export class FleetConfigService {
14
+ path;
15
+ preflight;
16
+ constructor(options = {}) {
17
+ this.path = options.configPath ?? defaultConfigPath();
18
+ this.preflight = options.preflight ?? (async () => emptyReport());
19
+ }
20
+ read() {
21
+ const source = this.readSource();
22
+ const parsed = parseFleetDocument(this.path, source, 'strict').value;
23
+ const redacted = redactModel(parsed);
24
+ return {
25
+ path: basename(this.path), exists: existsSync(this.path),
26
+ firstRun: !existsSync(this.path), revision: digest(source),
27
+ model: redacted.model, redactions: redacted.paths,
28
+ };
29
+ }
30
+ async preview(baseRevision, model) {
31
+ const currentSource = this.readSource();
32
+ this.assertRevision(baseRevision, currentSource);
33
+ const current = parseFleetDocument(this.path, currentSource, 'strict').value;
34
+ const restored = restoreRedactions(assertModel(model), current);
35
+ const source = serialize(restored);
36
+ const candidate = this.validateCandidate(source);
37
+ const [redactedCurrent, redactedNext] = [redactModel(current), redactModel(restored)];
38
+ const preflight = await this.preflight(candidate.path).finally(candidate.remove);
39
+ return {
40
+ valid: true, revision: digest(currentSource), normalizedModel: redactedNext.model,
41
+ diff: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
42
+ redactions: redactedNext.paths,
43
+ impact: restartImpact(current, restored), preflight,
44
+ };
45
+ }
46
+ async write(baseRevision, model) {
47
+ return withFileLock(`${this.path}.web-edit.lock`, async () => {
48
+ const currentSource = this.readSource();
49
+ this.assertRevision(baseRevision, currentSource);
50
+ const current = parseFleetDocument(this.path, currentSource, 'strict').value;
51
+ const restored = restoreRedactions(assertModel(model), current);
52
+ const nextSource = serialize(restored);
53
+ const candidate = this.validateCandidate(nextSource);
54
+ const preflight = await this.preflight(candidate.path).finally(candidate.remove);
55
+ // The lock coordinates trusted web/agent writers. An operator's editor does
56
+ // not take it, so re-check immediately before replacement as well.
57
+ this.assertRevision(baseRevision, this.readSource());
58
+ const redactedCurrent = redactModel(current);
59
+ const redactedNext = redactModel(restored);
60
+ let backup;
61
+ if (existsSync(this.path)) {
62
+ backup = `${basename(this.path)}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
63
+ replaceFileAtomically(join(dirname(this.path), backup), currentSource, 0o600);
64
+ }
65
+ replaceFileAtomically(this.path, nextSource, 0o600);
66
+ chmodSync(this.path, 0o600);
67
+ return {
68
+ saved: true, valid: true, revision: digest(currentSource), newRevision: digest(nextSource),
69
+ normalizedModel: redactedNext.model,
70
+ diff: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
71
+ redactions: redactedNext.paths, impact: restartImpact(current, restored), preflight,
72
+ backup,
73
+ };
74
+ });
75
+ }
76
+ readSource() {
77
+ if (!existsSync(this.path))
78
+ return 'roles: {}\n';
79
+ const stat = lstatSync(this.path);
80
+ const uid = process.getuid?.();
81
+ if (!stat.isFile() || stat.isSymbolicLink())
82
+ throw new FleetError('invalid_request', 'fleet configuration must be a regular non-symlink file');
83
+ if (stat.size > MAX_CONFIG_BYTES)
84
+ throw new FleetError('invalid_request', `fleet configuration exceeds ${MAX_CONFIG_BYTES} bytes`);
85
+ if (uid !== undefined && stat.uid !== uid)
86
+ throw new FleetError('forbidden', 'fleet configuration is not owned by the current user');
87
+ return readFileSync(this.path, 'utf8');
88
+ }
89
+ assertRevision(expected, source) {
90
+ if (!expected || expected !== digest(source))
91
+ throw new FleetError('stale_state', 'fleet.yaml changed since it was opened; reload before saving');
92
+ }
93
+ validateCandidate(source) {
94
+ if (Buffer.byteLength(source) > MAX_CONFIG_BYTES)
95
+ throw new FleetError('invalid_request', `fleet configuration exceeds ${MAX_CONFIG_BYTES} bytes`);
96
+ const candidate = join(dirname(this.path), `.${basename(this.path)}.preview-${process.pid}-${randomUUID()}.yaml`);
97
+ replaceFileAtomically(candidate, source, 0o600);
98
+ try {
99
+ loadConfig(candidate, { yamlMode: 'strict' });
100
+ }
101
+ catch (error) {
102
+ rmSync(candidate, { force: true });
103
+ throw new FleetError('invalid_request', error.message);
104
+ }
105
+ return { path: candidate, remove: () => rmSync(candidate, { force: true }) };
106
+ }
107
+ }
108
+ function assertModel(value) {
109
+ if (!value || typeof value !== 'object' || Array.isArray(value))
110
+ throw new FleetError('invalid_request', 'model must be a JSON object');
111
+ return structuredClone(value);
112
+ }
113
+ function serialize(model) {
114
+ return stringify(model, { lineWidth: 0, sortMapEntries: false });
115
+ }
116
+ function digest(source) {
117
+ return createHash('sha256').update(source).digest('hex');
118
+ }
119
+ function envMaps(model) {
120
+ const maps = [];
121
+ const add = (value, path) => {
122
+ if (value && typeof value === 'object' && !Array.isArray(value))
123
+ maps.push({ path, map: value });
124
+ };
125
+ add(model.defaults?.env, 'defaults.env');
126
+ const roles = model.roles;
127
+ if (roles && typeof roles === 'object' && !Array.isArray(roles))
128
+ for (const [name, raw] of Object.entries(roles))
129
+ add(raw?.env, `roles.${name}.env`);
130
+ return maps;
131
+ }
132
+ function redactModel(input) {
133
+ const model = structuredClone(input);
134
+ const paths = [];
135
+ const secretVars = new Set();
136
+ for (const item of envMaps(model))
137
+ for (const [key, value] of Object.entries(item.map)) {
138
+ if (typeof value === 'string')
139
+ for (const match of value.matchAll(/\$\{(\w+)\}/g))
140
+ secretVars.add(match[1]);
141
+ item.map[key] = REDACTED_ENV_VALUE;
142
+ paths.push(`${item.path}.${key}`);
143
+ }
144
+ const vars = model.vars;
145
+ if (vars && typeof vars === 'object' && !Array.isArray(vars))
146
+ for (const key of secretVars)
147
+ if (key in vars) {
148
+ vars[key] = REDACTED_ENV_VALUE;
149
+ paths.push(`vars.${key}`);
150
+ }
151
+ return { model, paths: paths.sort() };
152
+ }
153
+ function restoreRedactions(next, current) {
154
+ const oldByPath = new Map(envMaps(current).map(item => [item.path, item.map]));
155
+ for (const item of envMaps(next))
156
+ for (const [key, value] of Object.entries(item.map)) {
157
+ if (value !== REDACTED_ENV_VALUE)
158
+ continue;
159
+ const previous = oldByPath.get(item.path)?.[key];
160
+ if (previous === undefined)
161
+ throw new FleetError('invalid_request', `${item.path}.${key} uses a redaction marker with no prior value`);
162
+ item.map[key] = previous;
163
+ }
164
+ const nextVars = next.vars;
165
+ const oldVars = current.vars;
166
+ if (nextVars)
167
+ for (const [key, value] of Object.entries(nextVars)) {
168
+ if (value !== REDACTED_ENV_VALUE)
169
+ continue;
170
+ if (!oldVars || oldVars[key] === undefined)
171
+ throw new FleetError('invalid_request', `vars.${key} uses a redaction marker with no prior value`);
172
+ nextVars[key] = oldVars[key];
173
+ }
174
+ return next;
175
+ }
176
+ function exactDiff(before, after) {
177
+ if (before === after)
178
+ return '';
179
+ return ['--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
180
+ ...before.trimEnd().split('\n').map(line => `-${line}`),
181
+ ...after.trimEnd().split('\n').map(line => `+${line}`), ''].join('\n');
182
+ }
183
+ function objectKeys(value) {
184
+ return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
185
+ }
186
+ function restartImpact(before, after) {
187
+ const changed = (key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]);
188
+ const beforeRoles = (before.roles ?? {});
189
+ const afterRoles = (after.roles ?? {});
190
+ const roles = [...new Set([...objectKeys(beforeRoles), ...objectKeys(afterRoles)])]
191
+ .filter(name => JSON.stringify(beforeRoles[name]) !== JSON.stringify(afterRoles[name])).sort();
192
+ if (changed('defaults'))
193
+ roles.splice(0, roles.length, ...objectKeys(afterRoles).sort());
194
+ const watchdogScheduler = changed('watchdogs');
195
+ const scheduledLoops = changed('loops');
196
+ const required = roles.length > 0 || watchdogScheduler || scheduledLoops || changed('vars');
197
+ const parts = [roles.length ? `${roles.length} role${roles.length === 1 ? '' : 's'}` : '',
198
+ watchdogScheduler ? 'watchdog scheduler' : '', scheduledLoops ? 'scheduled loops' : ''].filter(Boolean);
199
+ return {
200
+ required, roles, watchdogScheduler, scheduledLoops,
201
+ summary: required ? `Save only writes configuration; apply/restart ${parts.join(', ') || 'affected roles'} separately.`
202
+ : 'No running process needs a restart.',
203
+ };
204
+ }
@@ -8,6 +8,7 @@ import { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from '../application/se
8
8
  import { StructuredLogService } from '../application/log-service.js';
9
9
  import { RoleCommandService } from '../application/role-command-service.js';
10
10
  import { RoleCreationService } from '../application/role-creation-service.js';
11
+ import { RoleRemovalService } from '../application/role-removal-service.js';
11
12
  import { FleetError } from '../application/errors.js';
12
13
  import { controlRequest, controlSocketPath } from '../session/control.js';
13
14
  import { Tmux } from '../tmux.js';
@@ -17,6 +18,9 @@ import { home, stateRoot } from '../paths.js';
17
18
  import { AuditSink } from './audit.js';
18
19
  import { FleetEventBus } from './events.js';
19
20
  import { buildWebServer } from './server.js';
21
+ import { FleetConfigService } from './fleet-config-service.js';
22
+ import { deriveTopology } from './topology.js';
23
+ import { doctor } from '../doctor.js';
20
24
  import { TerminalBridgeManager } from './terminal/bridge.js';
21
25
  import { acquireWebServerLock } from './lock.js';
22
26
  import { TrustedDeviceStore } from './device-store.js';
@@ -123,6 +127,10 @@ export async function startWebConsole(options) {
123
127
  });
124
128
  },
125
129
  });
130
+ const removal = new RoleRemovalService({
131
+ configPath: options.configPath, ops,
132
+ currentControlRole: process.env.OURS_FLEET_PROXY_CALLER,
133
+ });
126
134
  const commands = new RoleCommandService({
127
135
  repository, ops, configPath: options.configPath,
128
136
  status: async (roleId) => (await query.detail(roleId)).status,
@@ -136,10 +144,15 @@ export async function startWebConsole(options) {
136
144
  });
137
145
  const logs = new StructuredLogService(backend, realExec);
138
146
  const watchdogs = new WatchdogQueryService(watchdogConfigProvider);
147
+ const configuration = new FleetConfigService({
148
+ configPath: options.configPath,
149
+ preflight: path => doctor({ configPath: path, yamlMode: 'strict' }),
150
+ });
139
151
  let server;
140
152
  try {
141
153
  server = await buildWebServer({
142
- query, repository, logs, commands, creation, audit, events, watchdogs,
154
+ query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
155
+ topology: async () => deriveTopology(loadConfig(options.configPath), await query.list()),
143
156
  terminalUpgrade: terminalAvailable
144
157
  ? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
145
158
  : undefined,
@@ -10,6 +10,9 @@ import type { WatchdogQueryService } from '../watchdog/query.js';
10
10
  import { AuditSink } from './audit.js';
11
11
  import { WebAuth } from './auth.js';
12
12
  import { FleetEventBus } from './events.js';
13
+ import type { FleetConfigService } from './fleet-config-service.js';
14
+ import type { TopologySnapshot } from './topology.js';
15
+ import type { RoleRemovalService } from '../application/role-removal-service.js';
13
16
  export interface WebServices {
14
17
  query: FleetQueryService;
15
18
  repository: RoleRepository;
@@ -20,6 +23,9 @@ export interface WebServices {
20
23
  audit?: AuditSink;
21
24
  events?: FleetEventBus;
22
25
  watchdogs?: WatchdogQueryService;
26
+ configuration?: FleetConfigService;
27
+ topology?: () => Promise<TopologySnapshot>;
28
+ removal?: RoleRemovalService;
23
29
  terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
24
30
  }
25
31
  export interface WebServer {