@ours.network/fleet 0.15.1 → 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 (65) hide show
  1. package/README.md +49 -13
  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 +21 -1
  14. package/dist/cli.js +39 -7
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/creation.d.ts +6 -3
  18. package/dist/creation.js +5 -1
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +47 -11
  21. package/dist/fleet-proxy.d.ts +25 -0
  22. package/dist/fleet-proxy.js +38 -0
  23. package/dist/harness/claude-code.js +20 -3
  24. package/dist/harness/codex.js +14 -2
  25. package/dist/harness/types.d.ts +6 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/owner-channel/channel.d.ts +13 -0
  29. package/dist/owner-channel/channel.js +191 -9
  30. package/dist/owner-channel/state.d.ts +7 -1
  31. package/dist/owner-channel/state.js +41 -4
  32. package/dist/permissions.d.ts +5 -0
  33. package/dist/permissions.js +7 -0
  34. package/dist/runner.d.ts +2 -0
  35. package/dist/runner.js +86 -2
  36. package/dist/session/acp.d.ts +61 -1
  37. package/dist/session/acp.js +398 -20
  38. package/dist/session/arbiter.d.ts +10 -1
  39. package/dist/session/arbiter.js +24 -0
  40. package/dist/session/control.d.ts +33 -2
  41. package/dist/session/control.js +158 -5
  42. package/dist/session/conversation-normalizer.d.ts +34 -0
  43. package/dist/session/conversation-normalizer.js +356 -0
  44. package/dist/session/conversation-store.d.ts +88 -0
  45. package/dist/session/conversation-store.js +347 -0
  46. package/dist/session/conversation-types.d.ts +274 -0
  47. package/dist/session/conversation-types.js +1 -0
  48. package/dist/session/types.d.ts +40 -0
  49. package/dist/spawn.d.ts +6 -1
  50. package/dist/spawn.js +23 -16
  51. package/dist/web/auth.d.ts +1 -1
  52. package/dist/web/fleet-config-service.d.ts +47 -0
  53. package/dist/web/fleet-config-service.js +204 -0
  54. package/dist/web/runtime.js +14 -1
  55. package/dist/web/server.d.ts +6 -0
  56. package/dist/web/server.js +181 -9
  57. package/dist/web/topology.d.ts +31 -0
  58. package/dist/web/topology.js +61 -0
  59. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  60. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  61. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  62. package/dist/web-app/index.html +2 -2
  63. package/package.json +1 -1
  64. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  65. 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. */
@@ -40,8 +41,12 @@ export interface SpawnOpts {
40
41
  bio?: string;
41
42
  persona?: string;
42
43
  /** Internal, non-sensitive provenance correlation for typed presentation layers. */
43
- surface?: 'cli' | 'web';
44
+ surface?: 'cli' | 'web' | 'agent';
44
45
  creationActionId?: string;
46
+ /** Set only by a live role supervisor after a role-scoped proxy request. */
47
+ callerRole?: string;
48
+ /** Internal provenance labels for values filled by the caller's supervisor. */
49
+ inheritedFromCaller?: string[];
45
50
  /**
46
51
  * Path to a file holding exactly the existing `isolation:` mapping — the same
47
52
  * schema fleet.yaml uses, not a second policy language. The ONE new operator
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))
@@ -238,29 +243,31 @@ export function spawnDryRun(o) {
238
243
  */
239
244
  function provenanceSettings(o, defaults) {
240
245
  const perms = (defaults.permissions ?? {});
246
+ const callerDefaults = new Set(o.inheritedFromCaller ?? []);
247
+ const tagged = (key, entry) => callerDefaults.has(key) ? { ...entry, source: 'caller-role' } : entry;
241
248
  const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
242
249
  const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
243
250
  return {
244
- harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
245
- session: provenanceOf(o.session, defaults.session, 'tmux'),
251
+ harness: tagged('harness', provenanceOf(o.harness, defaults.harness, 'claude-code')),
252
+ session: tagged('session', provenanceOf(o.session, defaults.session, 'tmux')),
246
253
  identity: o.identity
247
254
  ? { value: o.identity, source: 'cli' }
248
255
  : { value: o.name, source: 'built-in' }, // defaults to the role name
249
- cwd: provenanceOf(o.cwd, undefined, undefined),
250
- model: o.model === null
256
+ cwd: tagged('cwd', provenanceOf(o.cwd, undefined, undefined)),
257
+ model: tagged('model', o.model === null
251
258
  ? { value: undefined, source: 'cli' }
252
259
  : explicitModel
253
260
  ? { value: explicitModel, source: 'cli' }
254
- : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
255
- coordinator: provenanceOf(o.coordinator, undefined, undefined),
261
+ : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' }),
262
+ coordinator: tagged('coordinator', provenanceOf(o.coordinator, undefined, undefined)),
256
263
  permission_mode: provenanceOf(o.permissionMode, undefined, undefined),
257
- approval: provenanceOf(o.approval, perms.approval, 'ask'),
258
- filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
259
- unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
264
+ approval: tagged('approval', provenanceOf(o.approval, perms.approval, 'ask')),
265
+ filesystem: tagged('filesystem', provenanceOf(o.filesystem, perms.filesystem, 'workspace')),
266
+ unattended: tagged('unattended', provenanceOf(o.unattended, perms.unattended, 'deny')),
260
267
  isolation: o.isolationFile
261
268
  ? { value: 'declared via --isolation-file', source: 'cli' }
262
269
  : { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
263
- monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
270
+ monitor: tagged('monitorConfig', provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' })),
264
271
  };
265
272
  }
266
273
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
@@ -324,7 +331,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
324
331
  const provenance = buildProvenance({
325
332
  role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
326
333
  settings: provenanceSettings(o, cfg.defaults),
327
- surface: o.surface, creationActionId: o.creationActionId,
334
+ surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
328
335
  });
329
336
  mkdirSync(agentDir(o.name), { recursive: true });
330
337
  writeProvenance(agentDir(o.name), provenance);
@@ -414,7 +421,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
414
421
  const provenance = buildProvenance({
415
422
  role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
416
423
  settings: provenanceSettings(o, cfg.defaults),
417
- surface: o.surface, creationActionId: o.creationActionId,
424
+ surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
418
425
  });
419
426
  writeProvenance(dir, provenance);
420
427
  lastProvenance = provenance;
@@ -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 {