@ours.network/fleet 0.15.7 → 0.17.0
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 +29 -3
- package/dist/application/role-creation-service.d.ts +2 -2
- package/dist/config.d.ts +4 -2
- package/dist/config.js +3 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +24 -4
- package/dist/monitor.d.ts +4 -3
- package/dist/monitor.js +11 -2
- package/dist/owner-channel/channel.js +4 -1
- package/dist/runner.js +20 -5
- package/dist/session/acp.d.ts +23 -0
- package/dist/session/acp.js +183 -10
- package/dist/session/arbiter.d.ts +5 -0
- package/dist/session/arbiter.js +8 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +4 -3
- package/dist/session/conversation-types.d.ts +9 -2
- package/dist/session/types.d.ts +13 -1
- package/dist/web/fleet-config-service.js +51 -15
- package/dist/web/runtime.js +9 -2
- package/dist/web/server.d.ts +6 -2
- package/dist/web/server.js +50 -0
- package/dist/web/topology-draft-store.d.ts +80 -0
- package/dist/web/topology-draft-store.js +337 -0
- package/dist/web/topology-model.d.ts +62 -0
- package/dist/web/topology-model.js +220 -0
- package/dist/web/topology-promote.d.ts +31 -0
- package/dist/web/topology-promote.js +168 -0
- package/dist/web/yaml-document-edit.d.ts +31 -0
- package/dist/web/yaml-document-edit.js +401 -0
- package/dist/web-app/assets/{TerminalView-BCbgag6j.js → TerminalView-B3rnVWbo.js} +1 -1
- package/dist/web-app/assets/index-CliHATFt.js +10 -0
- package/dist/web-app/assets/{index-COg4Azq1.css → index-DuC-xnX4.css} +1 -1
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-BstfCP_F.js +0 -10
package/dist/session/acp.js
CHANGED
|
@@ -13,6 +13,9 @@ const CANCEL_SETTLE_GRACE_MS = 15_000;
|
|
|
13
13
|
const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
14
14
|
/** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
|
|
15
15
|
const CONTROLLER_GRACE_MS = 12_000;
|
|
16
|
+
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
17
|
+
export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
|
|
18
|
+
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
16
19
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
17
20
|
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
18
21
|
const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
|
|
@@ -95,9 +98,13 @@ export class AcpSession {
|
|
|
95
98
|
runtimeModel;
|
|
96
99
|
reasoningEffort;
|
|
97
100
|
controllerCount = 0;
|
|
101
|
+
closing = false;
|
|
98
102
|
/** Armed when the last controller detaches; unattended policy applies on fire. */
|
|
99
103
|
controllerGrace;
|
|
100
104
|
cancelEscalation;
|
|
105
|
+
/** ACP-authenticated in-flight calls, including independently reserved permissions. */
|
|
106
|
+
activeToolCalls = new Map();
|
|
107
|
+
toolBoundaryWaiters = new Set();
|
|
101
108
|
activeTurn;
|
|
102
109
|
constructor(options, child, connection) {
|
|
103
110
|
this.options = options;
|
|
@@ -215,6 +222,142 @@ export class AcpSession {
|
|
|
215
222
|
permissionMode: this.options.permissionMode,
|
|
216
223
|
};
|
|
217
224
|
}
|
|
225
|
+
toolCall(toolCallId) {
|
|
226
|
+
const existing = this.activeToolCalls.get(toolCallId);
|
|
227
|
+
if (existing)
|
|
228
|
+
return existing;
|
|
229
|
+
const created = { lifecycle: false, permissions: new Map() };
|
|
230
|
+
this.activeToolCalls.set(toolCallId, created);
|
|
231
|
+
return created;
|
|
232
|
+
}
|
|
233
|
+
reserveTool(toolCallId) {
|
|
234
|
+
if (toolCallId)
|
|
235
|
+
this.toolCall(toolCallId).lifecycle = true;
|
|
236
|
+
}
|
|
237
|
+
reservePermission(toolCallId, permissionId) {
|
|
238
|
+
if (toolCallId)
|
|
239
|
+
this.toolCall(toolCallId).permissions.set(permissionId, 'pending');
|
|
240
|
+
}
|
|
241
|
+
allowPermission(toolCallId, permissionId) {
|
|
242
|
+
if (!toolCallId)
|
|
243
|
+
return;
|
|
244
|
+
const permission = this.activeToolCalls.get(toolCallId)?.permissions;
|
|
245
|
+
if (permission?.has(permissionId))
|
|
246
|
+
permission.set(permissionId, 'allowed');
|
|
247
|
+
}
|
|
248
|
+
releasePermission(toolCallId, permissionId) {
|
|
249
|
+
const call = toolCallId && this.activeToolCalls.get(toolCallId);
|
|
250
|
+
if (!call || !call.permissions.delete(permissionId))
|
|
251
|
+
return;
|
|
252
|
+
this.releaseToolIfIdle(toolCallId, call);
|
|
253
|
+
}
|
|
254
|
+
releaseTool(toolCallId) {
|
|
255
|
+
const call = toolCallId && this.activeToolCalls.get(toolCallId);
|
|
256
|
+
if (!call)
|
|
257
|
+
return;
|
|
258
|
+
call.lifecycle = false;
|
|
259
|
+
// Terminal tool evidence consumes permissions already granted for this
|
|
260
|
+
// call, but never a separate request that is still awaiting a decision.
|
|
261
|
+
for (const [permissionId, state] of call.permissions)
|
|
262
|
+
if (state === 'allowed')
|
|
263
|
+
call.permissions.delete(permissionId);
|
|
264
|
+
this.releaseToolIfIdle(toolCallId, call);
|
|
265
|
+
}
|
|
266
|
+
releaseToolIfIdle(toolCallId, call) {
|
|
267
|
+
if (call.lifecycle || call.permissions.size > 0)
|
|
268
|
+
return;
|
|
269
|
+
if (!this.activeToolCalls.delete(toolCallId) || this.activeToolCalls.size > 0)
|
|
270
|
+
return;
|
|
271
|
+
for (const notify of [...this.toolBoundaryWaiters])
|
|
272
|
+
notify();
|
|
273
|
+
}
|
|
274
|
+
releaseAllTools() {
|
|
275
|
+
if (this.activeToolCalls.size === 0)
|
|
276
|
+
return;
|
|
277
|
+
this.activeToolCalls.clear();
|
|
278
|
+
for (const notify of [...this.toolBoundaryWaiters])
|
|
279
|
+
notify();
|
|
280
|
+
}
|
|
281
|
+
waitForToolBoundary(timeoutMs) {
|
|
282
|
+
if (this.activeToolCalls.size === 0)
|
|
283
|
+
return Promise.resolve(true);
|
|
284
|
+
return new Promise(resolve => {
|
|
285
|
+
let settled = false;
|
|
286
|
+
const finish = (atBoundary) => {
|
|
287
|
+
if (settled)
|
|
288
|
+
return;
|
|
289
|
+
settled = true;
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
this.toolBoundaryWaiters.delete(check);
|
|
292
|
+
resolve(atBoundary);
|
|
293
|
+
};
|
|
294
|
+
const check = () => {
|
|
295
|
+
if (this.activeToolCalls.size === 0 || !this.isAlive())
|
|
296
|
+
finish(this.activeToolCalls.size === 0);
|
|
297
|
+
};
|
|
298
|
+
const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
|
|
299
|
+
timer.unref?.();
|
|
300
|
+
this.toolBoundaryWaiters.add(check);
|
|
301
|
+
// Close the subscribe/check race without guessing about elapsed time.
|
|
302
|
+
check();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
recordAfterToolDelivery(state, activeToolCount, waitedMs) {
|
|
306
|
+
this.events.emit('monitor_delivery', {
|
|
307
|
+
status: state, monitorPolicy: 'after_tool', activeToolCount, waitedMs,
|
|
308
|
+
});
|
|
309
|
+
this.conversation.appendSafe({
|
|
310
|
+
kind: 'monitor.delivery', sessionGeneration: this.sessionGeneration,
|
|
311
|
+
acpSessionId: this.sessionId, source: 'fleet_monitor',
|
|
312
|
+
payload: { policy: 'after_tool', state, activeToolCount, waitedMs },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Monitor-only safe-boundary delivery. Steering is the interruption: this
|
|
317
|
+
* path never calls session/cancel and never resolves a pending permission.
|
|
318
|
+
*/
|
|
319
|
+
async submitPromptAfterTool(text, options = {}) {
|
|
320
|
+
if (this.closing || !this.isAlive())
|
|
321
|
+
return turnResult(false, 'failed', this.lastError ?? 'ACP session is closing');
|
|
322
|
+
const startedAt = Date.now();
|
|
323
|
+
const initialToolCount = this.activeToolCalls.size;
|
|
324
|
+
if (!this.steeringSupported) {
|
|
325
|
+
this.recordAfterToolDelivery('unsupported', initialToolCount, 0);
|
|
326
|
+
const result = await this.submitPrompt(text, { ...options, interrupt: false, steer: false });
|
|
327
|
+
return {
|
|
328
|
+
...result,
|
|
329
|
+
safeBoundary: { state: 'unsupported', waitedMs: 0, activeToolCount: initialToolCount },
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (initialToolCount === 0) {
|
|
333
|
+
this.recordAfterToolDelivery('direct', 0, 0);
|
|
334
|
+
const result = await this.steerPrompt(text);
|
|
335
|
+
return { ...result, safeBoundary: { state: 'direct', waitedMs: 0, activeToolCount: 0 } };
|
|
336
|
+
}
|
|
337
|
+
this.recordAfterToolDelivery('deferred', initialToolCount, 0);
|
|
338
|
+
const timeoutMs = this.options.afterToolBoundaryTimeoutMs ?? AFTER_TOOL_BOUNDARY_TIMEOUT_MS;
|
|
339
|
+
const deadline = startedAt + timeoutMs;
|
|
340
|
+
let atBoundary = false;
|
|
341
|
+
// Re-check after every wake: another authenticated tool event may have
|
|
342
|
+
// arrived before this continuation ran. Only an empty tracked set is safe.
|
|
343
|
+
while (this.activeToolCalls.size > 0) {
|
|
344
|
+
const remaining = deadline - Date.now();
|
|
345
|
+
if (remaining <= 0 || !(await this.waitForToolBoundary(remaining)))
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
atBoundary = this.activeToolCalls.size === 0;
|
|
349
|
+
if (this.closing || !this.isAlive())
|
|
350
|
+
return turnResult(false, 'failed', this.lastError ?? 'ACP session closed during after_tool wait');
|
|
351
|
+
const waitedMs = Math.max(0, Date.now() - startedAt);
|
|
352
|
+
const state = atBoundary ? 'after_tool' : 'timeout';
|
|
353
|
+
const remainingToolCount = this.activeToolCalls.size;
|
|
354
|
+
this.recordAfterToolDelivery(state, remainingToolCount, waitedMs);
|
|
355
|
+
const result = await this.steerPrompt(text);
|
|
356
|
+
return {
|
|
357
|
+
...result,
|
|
358
|
+
safeBoundary: { state, waitedMs, activeToolCount: remainingToolCount },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
218
361
|
/**
|
|
219
362
|
* Accept responsibility for a prompt, then return. The turn itself may run
|
|
220
363
|
* for minutes behind other queued turns; making an interactive caller wait
|
|
@@ -365,6 +508,10 @@ export class AcpSession {
|
|
|
365
508
|
clearTimeout(pending.expiry);
|
|
366
509
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
367
510
|
const decision = chosen.kind.startsWith('reject') ? 'denied' : 'allowed';
|
|
511
|
+
if (decision === 'allowed')
|
|
512
|
+
this.allowPermission(pending.toolCallId, permissionId);
|
|
513
|
+
else
|
|
514
|
+
this.releasePermission(pending.toolCallId, permissionId);
|
|
368
515
|
this.events.emit('permission', {
|
|
369
516
|
turnId: this.activeTurn?.id,
|
|
370
517
|
origin: this.activeTurn?.origin,
|
|
@@ -381,7 +528,7 @@ export class AcpSession {
|
|
|
381
528
|
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
382
529
|
payload: { decision, decisionSource: 'manual', optionId },
|
|
383
530
|
});
|
|
384
|
-
this.readiness = 'running';
|
|
531
|
+
this.readiness = this.pendingPermissions.size > 0 ? 'awaiting_permission' : 'running';
|
|
385
532
|
return true;
|
|
386
533
|
}
|
|
387
534
|
/**
|
|
@@ -446,11 +593,12 @@ export class AcpSession {
|
|
|
446
593
|
? { outcome: { outcome: 'selected', optionId: rejectOption.optionId } }
|
|
447
594
|
: { outcome: { outcome: 'cancelled' } });
|
|
448
595
|
const settled = decision === 'denied' && !rejectOption ? 'cancelled' : decision;
|
|
596
|
+
this.releasePermission(pending.toolCallId, permissionId);
|
|
449
597
|
this.events.emit('permission', {
|
|
450
598
|
turnId: this.activeTurn?.id,
|
|
451
599
|
origin: this.activeTurn?.origin,
|
|
452
600
|
permissionId,
|
|
453
|
-
toolCallId: pending.
|
|
601
|
+
toolCallId: pending.eventToolCallId,
|
|
454
602
|
status: 'completed',
|
|
455
603
|
decision: settled === 'expired' ? 'cancelled' : settled,
|
|
456
604
|
decisionSource: 'automatic',
|
|
@@ -462,7 +610,7 @@ export class AcpSession {
|
|
|
462
610
|
kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
|
|
463
611
|
acpSessionId: this.sessionId, permissionId,
|
|
464
612
|
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
465
|
-
toolCallId: pending.
|
|
613
|
+
toolCallId: pending.eventToolCallId,
|
|
466
614
|
payload: {
|
|
467
615
|
decision: settled, decisionSource: 'automatic',
|
|
468
616
|
...(policy ? { policy } : {}), reason,
|
|
@@ -476,6 +624,7 @@ export class AcpSession {
|
|
|
476
624
|
return this.exit;
|
|
477
625
|
}
|
|
478
626
|
async close() {
|
|
627
|
+
this.closing = true;
|
|
479
628
|
if (this.cancelEscalation)
|
|
480
629
|
clearTimeout(this.cancelEscalation);
|
|
481
630
|
this.cancelEscalation = undefined;
|
|
@@ -484,6 +633,7 @@ export class AcpSession {
|
|
|
484
633
|
this.controllerGrace = undefined;
|
|
485
634
|
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
486
635
|
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
636
|
+
this.releaseAllTools();
|
|
487
637
|
if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
|
|
488
638
|
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
|
|
489
639
|
}
|
|
@@ -627,6 +777,7 @@ export class AcpSession {
|
|
|
627
777
|
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
628
778
|
}
|
|
629
779
|
finally {
|
|
780
|
+
this.releaseAllTools();
|
|
630
781
|
if (this.activeTurn?.id === turnId) {
|
|
631
782
|
if (this.cancelEscalation)
|
|
632
783
|
clearTimeout(this.cancelEscalation);
|
|
@@ -667,9 +818,19 @@ export class AcpSession {
|
|
|
667
818
|
}
|
|
668
819
|
return undefined;
|
|
669
820
|
};
|
|
821
|
+
const toolCallId = params.toolCall.toolCallId;
|
|
822
|
+
const permissionId = randomUUID();
|
|
823
|
+
// Permission is part of the tool lifecycle. Reserve before any policy or
|
|
824
|
+
// human decision so a monitor wake cannot slip between request and answer.
|
|
825
|
+
this.reservePermission(toolCallId, permissionId);
|
|
670
826
|
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
671
827
|
const option = choose(['allow_always', 'allow_once']);
|
|
672
|
-
|
|
828
|
+
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
|
|
829
|
+
if (option)
|
|
830
|
+
this.allowPermission(toolCallId, permissionId);
|
|
831
|
+
else
|
|
832
|
+
this.releasePermission(toolCallId, permissionId);
|
|
833
|
+
return Promise.resolve(response);
|
|
673
834
|
}
|
|
674
835
|
// A live grace window still counts as attended: the controller may be
|
|
675
836
|
// mid-reconnect, and denying instantly is exactly what the grace prevents.
|
|
@@ -680,11 +841,12 @@ export class AcpSession {
|
|
|
680
841
|
// a decision no human made, so one unattended denial would silently disable
|
|
681
842
|
// the tool for the rest of the session.
|
|
682
843
|
const option = choose(['reject_once', 'reject_always']);
|
|
683
|
-
|
|
844
|
+
const response = this.settleAutomatically(params, option, 'denied', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended
|
|
684
845
|
? 'no controller is attached, so the request cannot be shown to anyone'
|
|
685
|
-
: 'the role denies every permission request by policy')
|
|
846
|
+
: 'the role denies every permission request by policy');
|
|
847
|
+
this.releasePermission(toolCallId, permissionId);
|
|
848
|
+
return Promise.resolve(response);
|
|
686
849
|
}
|
|
687
|
-
const permissionId = randomUUID();
|
|
688
850
|
const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
|
|
689
851
|
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
690
852
|
this.readiness = 'awaiting_permission';
|
|
@@ -723,8 +885,8 @@ export class AcpSession {
|
|
|
723
885
|
return new Promise(resolve => {
|
|
724
886
|
const pending = {
|
|
725
887
|
options: params.options, resolve,
|
|
726
|
-
toolCallId
|
|
727
|
-
|
|
888
|
+
toolCallId,
|
|
889
|
+
eventToolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : toolCallId,
|
|
728
890
|
};
|
|
729
891
|
pending.expiry = setTimeout(() => {
|
|
730
892
|
this.settlePendingAutomatically(permissionId, pending, 'expired', undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
|
|
@@ -832,6 +994,10 @@ export class AcpSession {
|
|
|
832
994
|
title: scheduled ? 'scheduled-loop tool' : update.title,
|
|
833
995
|
status: update.status,
|
|
834
996
|
});
|
|
997
|
+
if (TERMINAL_TOOL_STATUSES.has(update.status ?? ''))
|
|
998
|
+
this.releaseTool(update.toolCallId);
|
|
999
|
+
else
|
|
1000
|
+
this.reserveTool(update.toolCallId);
|
|
835
1001
|
break;
|
|
836
1002
|
case 'tool_call_update':
|
|
837
1003
|
this.events.emit('tool_update', {
|
|
@@ -841,6 +1007,10 @@ export class AcpSession {
|
|
|
841
1007
|
title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
|
|
842
1008
|
status: update.status ?? undefined,
|
|
843
1009
|
});
|
|
1010
|
+
if (TERMINAL_TOOL_STATUSES.has(update.status ?? ''))
|
|
1011
|
+
this.releaseTool(update.toolCallId);
|
|
1012
|
+
else if (update.status !== undefined)
|
|
1013
|
+
this.reserveTool(update.toolCallId);
|
|
844
1014
|
break;
|
|
845
1015
|
default:
|
|
846
1016
|
break;
|
|
@@ -864,7 +1034,10 @@ export class AcpSession {
|
|
|
864
1034
|
}
|
|
865
1035
|
/** Normalize every ACP update losslessly into the durable ledger. */
|
|
866
1036
|
recordConversationUpdate(update, scheduled, commentary = false) {
|
|
867
|
-
const normalized = normalizeSessionUpdate(update, scheduled ? {
|
|
1037
|
+
const normalized = normalizeSessionUpdate(update, scheduled ? {
|
|
1038
|
+
redactText: SCHEDULED_LOOP_REDACTION,
|
|
1039
|
+
redactToolCallId: 'scheduled-loop-tool',
|
|
1040
|
+
}
|
|
868
1041
|
: commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
|
|
869
1042
|
this.conversation.appendSafe({
|
|
870
1043
|
kind: normalized.kind,
|
|
@@ -27,6 +27,11 @@ export declare class RoleTurnArbiter implements SessionHandle {
|
|
|
27
27
|
private track;
|
|
28
28
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
29
29
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Do not hold `exclusive` while ACP waits for a tool boundary: permission
|
|
32
|
+
* answers and explicit interrupts must remain able to pass immediately.
|
|
33
|
+
*/
|
|
34
|
+
submitPromptAfterTool(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
30
35
|
tryScheduled(text: string, origin: Extract<PromptOrigin, {
|
|
31
36
|
kind: 'scheduled-loop';
|
|
32
37
|
}>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
|
package/dist/session/arbiter.js
CHANGED
|
@@ -32,6 +32,14 @@ export class RoleTurnArbiter {
|
|
|
32
32
|
async submitPrompt(text, options = {}) {
|
|
33
33
|
return (await this.queuePrompt(text, options)).completion;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Do not hold `exclusive` while ACP waits for a tool boundary: permission
|
|
37
|
+
* answers and explicit interrupts must remain able to pass immediately.
|
|
38
|
+
*/
|
|
39
|
+
submitPromptAfterTool(text, options = {}) {
|
|
40
|
+
return this.session.submitPromptAfterTool?.(text, options)
|
|
41
|
+
?? this.session.submitPrompt(text, { ...options, interrupt: false, steer: false });
|
|
42
|
+
}
|
|
35
43
|
async tryScheduled(text, origin, beforeQueue) {
|
|
36
44
|
// Give owner/console/I/O callbacks already ready in this event-loop turn a
|
|
37
45
|
// chance to claim the arbiter first. Scheduled work is best-effort; humans
|
|
@@ -23,6 +23,12 @@ export interface NormalizeOptions {
|
|
|
23
23
|
* digest. Used for scheduled-loop turns whose output must not be retained.
|
|
24
24
|
*/
|
|
25
25
|
redactText?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Replace ACP tool-call IDs in both the normalized event correlation field
|
|
28
|
+
* and payload. The caller may still use the original update for in-memory
|
|
29
|
+
* lifecycle tracking without persisting its private identifier.
|
|
30
|
+
*/
|
|
31
|
+
redactToolCallId?: string;
|
|
26
32
|
}
|
|
27
33
|
export interface NormalizedUpdate {
|
|
28
34
|
kind: ConversationEventKind;
|
|
@@ -203,9 +203,10 @@ function normalizeToolContent(raw, redact) {
|
|
|
203
203
|
}
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
|
-
function toolUpsert(update, snapshot,
|
|
206
|
+
function toolUpsert(update, snapshot, options) {
|
|
207
|
+
const redact = options.redactText;
|
|
207
208
|
const payload = {
|
|
208
|
-
toolCallId: asString(update.toolCallId) ?? '',
|
|
209
|
+
toolCallId: options.redactToolCallId ?? asString(update.toolCallId) ?? '',
|
|
209
210
|
snapshot,
|
|
210
211
|
};
|
|
211
212
|
if (asString(update.title) !== undefined)
|
|
@@ -271,7 +272,7 @@ export function normalizeSessionUpdate(update, options = {}) {
|
|
|
271
272
|
}
|
|
272
273
|
case 'tool_call':
|
|
273
274
|
case 'tool_call_update': {
|
|
274
|
-
const payload = toolUpsert(raw, raw.sessionUpdate === 'tool_call',
|
|
275
|
+
const payload = toolUpsert(raw, raw.sessionUpdate === 'tool_call', options);
|
|
275
276
|
return withMeta({
|
|
276
277
|
kind: 'tool.upsert', payload,
|
|
277
278
|
...(payload.toolCallId ? { toolCallId: payload.toolCallId } : {}),
|
|
@@ -7,7 +7,7 @@ import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.
|
|
|
7
7
|
* file touches the wire: ACP updates are reduced into these shapes by the
|
|
8
8
|
* normalizer, and the store (phase 1) assigns `seq`/`eventId`/timestamps.
|
|
9
9
|
*/
|
|
10
|
-
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
10
|
+
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
11
11
|
/** A well-formed ACP update this version cannot represent. Bounded, never a crash. */
|
|
12
12
|
| 'unsupported';
|
|
13
13
|
/** Where a conversation record came from. Typed provenance, never prompt text. */
|
|
@@ -178,6 +178,13 @@ export interface PermissionResolvedPayload {
|
|
|
178
178
|
policy?: string;
|
|
179
179
|
reason?: string;
|
|
180
180
|
}
|
|
181
|
+
/** Body-free monitor evidence. Tool ids, titles, output, and wake text are never stored. */
|
|
182
|
+
export interface MonitorDeliveryPayload {
|
|
183
|
+
policy: 'after_tool';
|
|
184
|
+
state: 'deferred' | 'direct' | 'after_tool' | 'timeout' | 'unsupported';
|
|
185
|
+
activeToolCount: number;
|
|
186
|
+
waitedMs: number;
|
|
187
|
+
}
|
|
181
188
|
export interface TurnStatePayload {
|
|
182
189
|
state: 'queued' | 'running' | 'awaiting_permission' | 'interrupt_requested';
|
|
183
190
|
}
|
|
@@ -201,7 +208,7 @@ export interface BoundedJson {
|
|
|
201
208
|
digest?: string;
|
|
202
209
|
redacted?: true;
|
|
203
210
|
}
|
|
204
|
-
export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | SessionLifecyclePayload | ErrorPayload;
|
|
211
|
+
export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload;
|
|
205
212
|
export interface ConversationEventV1 {
|
|
206
213
|
schemaVersion: 1;
|
|
207
214
|
roleId: string;
|
package/dist/session/types.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ export interface TurnResult {
|
|
|
49
49
|
cancellationSource?: TurnCancellationSource;
|
|
50
50
|
/** Final assistant text captured structurally by a backend, when available. */
|
|
51
51
|
output?: string;
|
|
52
|
+
/** Body-free monitor safe-boundary disposition, when this was an after_tool wake. */
|
|
53
|
+
safeBoundary?: {
|
|
54
|
+
state: 'direct' | 'after_tool' | 'timeout' | 'unsupported';
|
|
55
|
+
waitedMs: number;
|
|
56
|
+
activeToolCount: number;
|
|
57
|
+
};
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* Why a control operation failed. The distinctions exist because collapsing
|
|
@@ -137,7 +143,7 @@ export interface SessionSnapshot {
|
|
|
137
143
|
nativeMode: string;
|
|
138
144
|
};
|
|
139
145
|
}
|
|
140
|
-
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
|
|
146
|
+
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
141
147
|
/** What a settled permission request resolved to. */
|
|
142
148
|
export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
|
|
143
149
|
export interface SessionEvent {
|
|
@@ -178,6 +184,10 @@ export interface SessionEvent {
|
|
|
178
184
|
reason?: string;
|
|
179
185
|
/** The option actually selected, when one was. */
|
|
180
186
|
optionId?: string;
|
|
187
|
+
/** Body-free evidence for monitor safe-boundary delivery. */
|
|
188
|
+
monitorPolicy?: 'after_tool';
|
|
189
|
+
activeToolCount?: number;
|
|
190
|
+
waitedMs?: number;
|
|
181
191
|
}
|
|
182
192
|
export interface ConversationHandlePage {
|
|
183
193
|
events: ConversationEventV1[];
|
|
@@ -206,6 +216,8 @@ export interface SessionHandle {
|
|
|
206
216
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
207
217
|
/** Queue a prompt and wait for its terminal result. */
|
|
208
218
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
219
|
+
/** Monitor-only ACP safe-boundary delivery. Never implies human/control cancellation. */
|
|
220
|
+
submitPromptAfterTool?(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
209
221
|
interrupt(source?: TurnCancellationSource): Promise<void>;
|
|
210
222
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
211
223
|
/** Generation-bound browser decision; stale/settled/invalid all fail closed. */
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, } from 'node:fs';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
|
-
import { stringify } from 'yaml';
|
|
5
4
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
6
5
|
import { loadConfig } from '../config.js';
|
|
7
6
|
import { parseFleetDocument } from '../config-yaml.js';
|
|
8
7
|
import { defaultConfigPath } from '../paths.js';
|
|
9
8
|
import { FleetError } from '../application/errors.js';
|
|
9
|
+
import { redactSourceSecrets, renderModelOntoSource } from './yaml-document-edit.js';
|
|
10
10
|
export const REDACTED_ENV_VALUE = '__OURS_FLEET_SECRET_REDACTED__';
|
|
11
11
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
12
|
+
const DIFF_CONTEXT_LINES = 3;
|
|
12
13
|
const emptyReport = () => ({ ok: true, checks: [] });
|
|
13
14
|
export class FleetConfigService {
|
|
14
15
|
path;
|
|
@@ -32,13 +33,13 @@ export class FleetConfigService {
|
|
|
32
33
|
this.assertRevision(baseRevision, currentSource);
|
|
33
34
|
const current = parseFleetDocument(this.path, currentSource, 'strict').value;
|
|
34
35
|
const restored = restoreRedactions(assertModel(model), current);
|
|
35
|
-
const
|
|
36
|
-
const candidate = this.validateCandidate(
|
|
37
|
-
const
|
|
36
|
+
const nextSource = render(currentSource, restored);
|
|
37
|
+
const candidate = this.validateCandidate(nextSource);
|
|
38
|
+
const redactedNext = redactModel(restored);
|
|
38
39
|
const preflight = await this.preflight(candidate.path).finally(candidate.remove);
|
|
39
40
|
return {
|
|
40
41
|
valid: true, revision: digest(currentSource), normalizedModel: redactedNext.model,
|
|
41
|
-
diff:
|
|
42
|
+
diff: sourceDiff(currentSource, nextSource),
|
|
42
43
|
redactions: redactedNext.paths,
|
|
43
44
|
impact: restartImpact(current, restored), preflight,
|
|
44
45
|
};
|
|
@@ -49,13 +50,12 @@ export class FleetConfigService {
|
|
|
49
50
|
this.assertRevision(baseRevision, currentSource);
|
|
50
51
|
const current = parseFleetDocument(this.path, currentSource, 'strict').value;
|
|
51
52
|
const restored = restoreRedactions(assertModel(model), current);
|
|
52
|
-
const nextSource =
|
|
53
|
+
const nextSource = render(currentSource, restored);
|
|
53
54
|
const candidate = this.validateCandidate(nextSource);
|
|
54
55
|
const preflight = await this.preflight(candidate.path).finally(candidate.remove);
|
|
55
56
|
// The lock coordinates trusted web/agent writers. An operator's editor does
|
|
56
57
|
// not take it, so re-check immediately before replacement as well.
|
|
57
58
|
this.assertRevision(baseRevision, this.readSource());
|
|
58
|
-
const redactedCurrent = redactModel(current);
|
|
59
59
|
const redactedNext = redactModel(restored);
|
|
60
60
|
let backup;
|
|
61
61
|
if (existsSync(this.path)) {
|
|
@@ -67,7 +67,7 @@ export class FleetConfigService {
|
|
|
67
67
|
return {
|
|
68
68
|
saved: true, valid: true, revision: digest(currentSource), newRevision: digest(nextSource),
|
|
69
69
|
normalizedModel: redactedNext.model,
|
|
70
|
-
diff:
|
|
70
|
+
diff: sourceDiff(currentSource, nextSource),
|
|
71
71
|
redactions: redactedNext.paths, impact: restartImpact(current, restored), preflight,
|
|
72
72
|
backup,
|
|
73
73
|
};
|
|
@@ -110,8 +110,17 @@ function assertModel(value) {
|
|
|
110
110
|
throw new FleetError('invalid_request', 'model must be a JSON object');
|
|
111
111
|
return structuredClone(value);
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Apply the edited model onto the user's own document rather than re-serializing
|
|
115
|
+
* it, so comments, key order and formatting outside the edit survive the save.
|
|
116
|
+
*/
|
|
117
|
+
function render(currentSource, model) {
|
|
118
|
+
try {
|
|
119
|
+
return renderModelOntoSource(currentSource, model);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new FleetError('invalid_request', error.message);
|
|
123
|
+
}
|
|
115
124
|
}
|
|
116
125
|
function digest(source) {
|
|
117
126
|
return createHash('sha256').update(source).digest('hex');
|
|
@@ -173,12 +182,39 @@ function restoreRedactions(next, current) {
|
|
|
173
182
|
}
|
|
174
183
|
return next;
|
|
175
184
|
}
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Unified-style diff of the real file text with env secrets masked. Common
|
|
187
|
+
* leading/trailing lines are trimmed, so a surgical edit reviews as a small
|
|
188
|
+
* hunk instead of the whole configuration twice.
|
|
189
|
+
*/
|
|
190
|
+
function sourceDiff(beforeSource, afterSource) {
|
|
191
|
+
const before = splitLines(redactSourceSecrets(beforeSource, REDACTED_ENV_VALUE));
|
|
192
|
+
const after = splitLines(redactSourceSecrets(afterSource, REDACTED_ENV_VALUE));
|
|
193
|
+
let head = 0;
|
|
194
|
+
while (head < before.length && head < after.length && before[head] === after[head])
|
|
195
|
+
head += 1;
|
|
196
|
+
let tail = 0;
|
|
197
|
+
while (tail < before.length - head && tail < after.length - head
|
|
198
|
+
&& before[before.length - 1 - tail] === after[after.length - 1 - tail])
|
|
199
|
+
tail += 1;
|
|
200
|
+
if (head === before.length && head === after.length)
|
|
178
201
|
return '';
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
202
|
+
const start = Math.max(0, head - DIFF_CONTEXT_LINES);
|
|
203
|
+
const trailing = Math.min(DIFF_CONTEXT_LINES, tail);
|
|
204
|
+
const beforeSpan = before.length - tail + trailing - start;
|
|
205
|
+
const afterSpan = after.length - tail + trailing - start;
|
|
206
|
+
return [
|
|
207
|
+
'--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
|
|
208
|
+
`@@ -${start + 1},${beforeSpan} +${start + 1},${afterSpan} @@`,
|
|
209
|
+
...before.slice(start, head).map(line => ` ${line}`),
|
|
210
|
+
...before.slice(head, before.length - tail).map(line => `-${line}`),
|
|
211
|
+
...after.slice(head, after.length - tail).map(line => `+${line}`),
|
|
212
|
+
...before.slice(before.length - tail, before.length - tail + trailing).map(line => ` ${line}`),
|
|
213
|
+
'',
|
|
214
|
+
].join('\n');
|
|
215
|
+
}
|
|
216
|
+
function splitLines(source) {
|
|
217
|
+
return source.replace(/\n$/, '').split('\n');
|
|
182
218
|
}
|
|
183
219
|
function objectKeys(value) {
|
|
184
220
|
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
|
package/dist/web/runtime.js
CHANGED
|
@@ -19,7 +19,9 @@ import { AuditSink } from './audit.js';
|
|
|
19
19
|
import { FleetEventBus } from './events.js';
|
|
20
20
|
import { buildWebServer } from './server.js';
|
|
21
21
|
import { FleetConfigService } from './fleet-config-service.js';
|
|
22
|
-
import {
|
|
22
|
+
import { mergeTopology } from './topology-model.js';
|
|
23
|
+
import { TopologyDraftStore } from './topology-draft-store.js';
|
|
24
|
+
import { TopologyPromoteService } from './topology-promote.js';
|
|
23
25
|
import { doctor } from '../doctor.js';
|
|
24
26
|
import { TerminalBridgeManager } from './terminal/bridge.js';
|
|
25
27
|
import { acquireWebServerLock } from './lock.js';
|
|
@@ -148,11 +150,16 @@ export async function startWebConsole(options) {
|
|
|
148
150
|
configPath: options.configPath,
|
|
149
151
|
preflight: path => doctor({ configPath: path, yamlMode: 'strict' }),
|
|
150
152
|
});
|
|
153
|
+
const topologyDrafts = new TopologyDraftStore({ dir: webDir });
|
|
154
|
+
const readTopology = async () => mergeTopology(loadConfig(options.configPath), await query.list(), topologyDrafts.read());
|
|
155
|
+
const topologyPromote = new TopologyPromoteService({
|
|
156
|
+
drafts: topologyDrafts, configuration, topology: readTopology,
|
|
157
|
+
});
|
|
151
158
|
let server;
|
|
152
159
|
try {
|
|
153
160
|
server = await buildWebServer({
|
|
154
161
|
query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
|
|
155
|
-
topology:
|
|
162
|
+
topology: readTopology, topologyDrafts, topologyPromote,
|
|
156
163
|
terminalUpgrade: terminalAvailable
|
|
157
164
|
? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
|
|
158
165
|
: undefined,
|
package/dist/web/server.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { AuditSink } from './audit.js';
|
|
|
11
11
|
import { WebAuth } from './auth.js';
|
|
12
12
|
import { FleetEventBus } from './events.js';
|
|
13
13
|
import type { FleetConfigService } from './fleet-config-service.js';
|
|
14
|
-
import type {
|
|
14
|
+
import type { MergedTopology } from './topology-model.js';
|
|
15
|
+
import type { TopologyDraftStore } from './topology-draft-store.js';
|
|
16
|
+
import type { TopologyPromoteService } from './topology-promote.js';
|
|
15
17
|
import type { RoleRemovalService } from '../application/role-removal-service.js';
|
|
16
18
|
export interface WebServices {
|
|
17
19
|
query: FleetQueryService;
|
|
@@ -24,7 +26,9 @@ export interface WebServices {
|
|
|
24
26
|
events?: FleetEventBus;
|
|
25
27
|
watchdogs?: WatchdogQueryService;
|
|
26
28
|
configuration?: FleetConfigService;
|
|
27
|
-
topology?: () => Promise<
|
|
29
|
+
topology?: () => Promise<MergedTopology>;
|
|
30
|
+
topologyDrafts?: TopologyDraftStore;
|
|
31
|
+
topologyPromote?: TopologyPromoteService;
|
|
28
32
|
removal?: RoleRemovalService;
|
|
29
33
|
terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
|
|
30
34
|
}
|