@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.
- package/README.md +13 -6
- package/dist/application/fleet-query-service.js +3 -0
- package/dist/application/model-catalog.d.ts +20 -0
- package/dist/application/model-catalog.js +57 -0
- package/dist/application/role-creation-service.d.ts +7 -0
- package/dist/application/role-creation-service.js +21 -4
- package/dist/application/role-removal-service.d.ts +32 -0
- package/dist/application/role-removal-service.js +87 -0
- package/dist/application/role-repository.js +13 -1
- package/dist/application/session-control.d.ts +74 -0
- package/dist/application/session-control.js +66 -1
- package/dist/application/types.d.ts +18 -0
- package/dist/briefing.js +10 -1
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +4 -1
- package/dist/config.js +3 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +14 -5
- package/dist/fleet-proxy.js +3 -1
- package/dist/harness/claude-code.js +20 -3
- package/dist/harness/codex.js +14 -2
- package/dist/harness/types.d.ts +6 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/owner-channel/channel.js +6 -5
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +7 -0
- package/dist/runner.js +2 -0
- package/dist/session/acp.d.ts +61 -1
- package/dist/session/acp.js +398 -20
- package/dist/session/arbiter.d.ts +10 -1
- package/dist/session/arbiter.js +24 -0
- package/dist/session/control.d.ts +28 -2
- package/dist/session/control.js +145 -5
- package/dist/session/conversation-normalizer.d.ts +34 -0
- package/dist/session/conversation-normalizer.js +356 -0
- package/dist/session/conversation-store.d.ts +88 -0
- package/dist/session/conversation-store.js +347 -0
- package/dist/session/conversation-types.d.ts +274 -0
- package/dist/session/conversation-types.js +1 -0
- package/dist/session/types.d.ts +40 -0
- package/dist/spawn.d.ts +1 -0
- package/dist/spawn.js +9 -4
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/fleet-config-service.d.ts +47 -0
- package/dist/web/fleet-config-service.js +204 -0
- package/dist/web/runtime.js +14 -1
- package/dist/web/server.d.ts +6 -0
- package/dist/web/server.js +181 -9
- package/dist/web/topology.d.ts +31 -0
- package/dist/web/topology.js +61 -0
- package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
- package/dist/web-app/assets/index-COg4Azq1.css +1 -0
- package/dist/web-app/assets/index-Cde9auW0.js +10 -0
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
- package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
package/dist/session/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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,
|
|
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))
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/web/runtime.js
CHANGED
|
@@ -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,
|
package/dist/web/server.d.ts
CHANGED
|
@@ -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 {
|