@ours.network/fleet 0.19.0-nightly.8 → 1.0.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 +96 -11
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +103 -10
- package/dist/build-info.json +5 -5
- package/dist/cli.js +41 -11
- package/dist/config.d.ts +21 -7
- package/dist/config.js +32 -18
- package/dist/creation.d.ts +1 -1
- package/dist/docs.d.ts +2 -2
- package/dist/docs.js +145 -24
- package/dist/doctor.js +10 -10
- package/dist/duration.js +1 -1
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +201 -8
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +72 -13
- package/dist/harness/types.d.ts +57 -6
- package/dist/isolation/bubblewrap.d.ts +4 -4
- package/dist/isolation/bubblewrap.js +4 -4
- package/dist/isolation/policy.d.ts +1 -1
- package/dist/isolation/policy.js +2 -2
- package/dist/isolation/registry.d.ts +3 -3
- package/dist/isolation/registry.js +5 -5
- package/dist/isolation/resources.d.ts +1 -1
- package/dist/isolation/resources.js +1 -1
- package/dist/isolation/types.d.ts +3 -3
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.d.ts +2 -2
- package/dist/monitor.js +4 -4
- package/dist/ops.d.ts +2 -2
- package/dist/ops.js +9 -9
- package/dist/owner-channel/channel.d.ts +17 -0
- package/dist/owner-channel/channel.js +104 -23
- package/dist/owner-channel/commands.d.ts +9 -0
- package/dist/owner-channel/commands.js +226 -99
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/rooms-tasks/cli.js +733 -221
- package/dist/rooms-tasks/close.d.ts +35 -0
- package/dist/rooms-tasks/close.js +182 -0
- package/dist/rooms-tasks/config.js +8 -5
- package/dist/rooms-tasks/cowork-adapter.d.ts +22 -0
- package/dist/rooms-tasks/cowork-adapter.js +116 -0
- package/dist/rooms-tasks/external-worker.d.ts +2 -0
- package/dist/rooms-tasks/external-worker.js +40 -0
- package/dist/rooms-tasks/index.d.ts +2 -0
- package/dist/rooms-tasks/index.js +2 -0
- package/dist/rooms-tasks/markdown.d.ts +49 -0
- package/dist/rooms-tasks/markdown.js +206 -0
- package/dist/rooms-tasks/member-startup.d.ts +51 -0
- package/dist/rooms-tasks/member-startup.js +151 -0
- package/dist/rooms-tasks/provision.d.ts +8 -0
- package/dist/rooms-tasks/provision.js +363 -97
- package/dist/rooms-tasks/room-state.d.ts +13 -1
- package/dist/rooms-tasks/room-state.js +149 -1
- package/dist/rooms-tasks/task-state.d.ts +13 -2
- package/dist/rooms-tasks/task-state.js +131 -3
- package/dist/rooms-tasks/terminal.d.ts +23 -0
- package/dist/rooms-tasks/terminal.js +56 -0
- package/dist/rooms-tasks/types.d.ts +109 -4
- package/dist/rooms-tasks/types.js +1 -1
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +252 -100
- package/dist/session/acp.d.ts +106 -2
- package/dist/session/acp.js +217 -14
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/conversation-normalizer.d.ts +7 -1
- package/dist/session/conversation-normalizer.js +154 -11
- package/dist/session/conversation-store.js +1 -1
- package/dist/session/conversation-types.d.ts +25 -6
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.d.ts +4 -2
- package/dist/spawn.js +39 -26
- package/dist/supervisor/launchd.d.ts +2 -2
- package/dist/supervisor/launchd.js +4 -4
- package/dist/supervisor/systemd.js +4 -4
- package/dist/supervisor/types.d.ts +1 -1
- package/dist/temp-lifecycle.d.ts +14 -2
- package/dist/temp-lifecycle.js +90 -0
- package/dist/watchdog/alerts.d.ts +2 -2
- package/dist/watchdog/alerts.js +2 -2
- package/dist/watchdog/briefing.d.ts +5 -5
- package/dist/watchdog/briefing.js +13 -6
- package/dist/watchdog/config.js +1 -1
- package/dist/watchdog/query.d.ts +2 -2
- package/dist/watchdog/query.js +2 -2
- package/dist/watchdog/report.js +2 -2
- package/dist/watchdog/run.d.ts +2 -2
- package/dist/watchdog/run.js +6 -6
- package/dist/watchdog/scheduler.d.ts +7 -7
- package/dist/watchdog/scheduler.js +10 -10
- package/dist/watchdog/service.d.ts +4 -4
- package/dist/watchdog/service.js +5 -6
- package/dist/watchdog/store.d.ts +4 -4
- package/dist/watchdog/store.js +4 -4
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +1 -1
- package/dist/web/topology-promote.js +1 -1
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -1
package/dist/harness/codex.js
CHANGED
|
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
|
|
|
5
5
|
import { realExec } from '../exec.js';
|
|
6
6
|
import { registerAdapter } from './registry.js';
|
|
7
7
|
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveBundledAcpAgent, } from './acp-agent.js';
|
|
9
9
|
const OPTION_KEYS = [
|
|
10
10
|
'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
|
|
11
11
|
'monitor',
|
|
@@ -51,9 +51,7 @@ function sandboxMode(role) {
|
|
|
51
51
|
throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
|
|
52
52
|
return s;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
function acpAgentMode(role) {
|
|
56
|
-
const sandbox = sandboxMode(role);
|
|
54
|
+
function modeForSandbox(sandbox) {
|
|
57
55
|
if (sandbox === 'read-only')
|
|
58
56
|
return 'read-only';
|
|
59
57
|
if (sandbox === 'workspace-write')
|
|
@@ -62,6 +60,26 @@ function acpAgentMode(role) {
|
|
|
62
60
|
return 'agent-full-access';
|
|
63
61
|
return undefined;
|
|
64
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the coupled Codex ACP mode.
|
|
65
|
+
*
|
|
66
|
+
* The portable approval contract owns the default mode selection: `allow`
|
|
67
|
+
* means the adapter's fully non-interactive yolo preset and `auto` means its
|
|
68
|
+
* ordinary agent preset. This intentionally means that Codex ACP cannot retain
|
|
69
|
+
* an independent neutral filesystem posture for those two modes. An explicit
|
|
70
|
+
* native sandbox remains authoritative and selects its corresponding preset.
|
|
71
|
+
*/
|
|
72
|
+
function acpAgentMode(role) {
|
|
73
|
+
const explicitSandbox = role.harness_options?.sandbox;
|
|
74
|
+
if (explicitSandbox != null)
|
|
75
|
+
return modeForSandbox(sandboxMode(role));
|
|
76
|
+
if (role.permissions?.approval === 'allow')
|
|
77
|
+
return 'agent-full-access';
|
|
78
|
+
if (role.permissions?.approval === 'auto')
|
|
79
|
+
return 'agent';
|
|
80
|
+
const sandbox = sandboxMode(role);
|
|
81
|
+
return modeForSandbox(sandbox);
|
|
82
|
+
}
|
|
65
83
|
function acpModePermissions(mode) {
|
|
66
84
|
if (mode === 'read-only')
|
|
67
85
|
return { approval: 'on-request', sandbox: 'read-only' };
|
|
@@ -69,6 +87,10 @@ function acpModePermissions(mode) {
|
|
|
69
87
|
return { approval: 'never', sandbox: 'danger-full-access' };
|
|
70
88
|
return { approval: 'on-request', sandbox: 'workspace-write' };
|
|
71
89
|
}
|
|
90
|
+
/** The sandbox Codex will actually receive from the selected coupled ACP mode. */
|
|
91
|
+
function acpRuntimeSandbox(role) {
|
|
92
|
+
return acpModePermissions(acpAgentMode(role)).sandbox;
|
|
93
|
+
}
|
|
72
94
|
function fleetModeForApproval(nativeMode) {
|
|
73
95
|
if (nativeMode === 'never')
|
|
74
96
|
return 'allow';
|
|
@@ -102,6 +124,18 @@ function launcherMode(role) {
|
|
|
102
124
|
function bundledCodexAcp() {
|
|
103
125
|
return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
|
|
104
126
|
}
|
|
127
|
+
/** Bind launch argv and metadata provenance to one already-completed resolution. */
|
|
128
|
+
export function codexAcpLaunchForResolution(resolution) {
|
|
129
|
+
const permissionMetadataSource = resolution.bundled
|
|
130
|
+
&& resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
131
|
+
&& resolution.manifestPath !== undefined
|
|
132
|
+
? 'codex-acp'
|
|
133
|
+
: undefined;
|
|
134
|
+
return {
|
|
135
|
+
argv: [...resolution.argv],
|
|
136
|
+
...(permissionMetadataSource ? { permissionMetadataSource } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
105
139
|
function canOverrideBundledAcpApproval() {
|
|
106
140
|
const resolution = bundledCodexAcp();
|
|
107
141
|
return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
@@ -147,7 +181,7 @@ function codexAcpEnvironment(role, dirs) {
|
|
|
147
181
|
return {
|
|
148
182
|
CODEX_PATH: command,
|
|
149
183
|
[CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
|
|
150
|
-
[CODEX_PROXY_SANDBOX_ENV]:
|
|
184
|
+
[CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
|
|
151
185
|
[CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
|
|
152
186
|
...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
|
|
153
187
|
};
|
|
@@ -275,7 +309,7 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
275
309
|
return errs;
|
|
276
310
|
},
|
|
277
311
|
async prepareSession(role, dirs) {
|
|
278
|
-
// Per-role harness runtime home
|
|
312
|
+
// Per-role harness runtime home; harmless for un-isolated roles.
|
|
279
313
|
// Only a role that declares `isolation:` gets a sandbox, and only a
|
|
280
314
|
// sandbox needs this directory to exist before entry.
|
|
281
315
|
if (role.isolation)
|
|
@@ -285,7 +319,16 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
285
319
|
if (requested === 'ours-codex' && !hasOursCodex)
|
|
286
320
|
throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
|
|
287
321
|
const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
|
|
288
|
-
return {
|
|
322
|
+
return {
|
|
323
|
+
argv: [],
|
|
324
|
+
// OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
|
|
325
|
+
// claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
|
|
326
|
+
// role with an ours identity, not just claude-code: a seed that works on one
|
|
327
|
+
// harness and silently does nothing on the other is the same class of defect
|
|
328
|
+
// as a config key that only works on one session type.
|
|
329
|
+
env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
|
|
330
|
+
command,
|
|
331
|
+
};
|
|
289
332
|
},
|
|
290
333
|
buildLaunch(role, mode, _s, prep) {
|
|
291
334
|
const stateDir = roleStateDir(role);
|
|
@@ -299,17 +342,30 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
299
342
|
},
|
|
300
343
|
buildAcpLaunch(role, prep) {
|
|
301
344
|
const configured = role.session_options?.acp?.command;
|
|
345
|
+
// Resolve once: both argv and permission-metadata provenance must describe
|
|
346
|
+
// the same artifact. A bare PATH fallback is launchable for compatibility,
|
|
347
|
+
// but is never authenticated for protected-MCP auto-approval.
|
|
348
|
+
const resolved = configured == null
|
|
349
|
+
? codexAcpLaunchForResolution(bundledCodexAcp())
|
|
350
|
+
: undefined;
|
|
302
351
|
const argv = Array.isArray(configured)
|
|
303
352
|
? [...configured]
|
|
304
353
|
: typeof configured === 'string'
|
|
305
354
|
? ['sh', '-c', configured]
|
|
306
|
-
:
|
|
355
|
+
: resolved.argv;
|
|
307
356
|
const initialMode = acpAgentMode(role);
|
|
308
357
|
return {
|
|
309
358
|
argv,
|
|
310
359
|
env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
|
|
360
|
+
...(resolved?.permissionMetadataSource
|
|
361
|
+
? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
|
|
311
362
|
};
|
|
312
363
|
},
|
|
364
|
+
// INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
|
|
365
|
+
// keeps resumed/loaded sessions and live status on the identical mode.
|
|
366
|
+
acpPermissionModeId(role) {
|
|
367
|
+
return acpAgentMode(role);
|
|
368
|
+
},
|
|
313
369
|
isolationPaths(role, _dirs) {
|
|
314
370
|
const codexHome = join(home(), '.codex');
|
|
315
371
|
const profile = role.harness_options?.profile;
|
|
@@ -362,7 +418,9 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
362
418
|
const mode = acpAgentMode(role) ?? 'agent';
|
|
363
419
|
const configured = role.session_options?.acp?.command;
|
|
364
420
|
const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
|
|
365
|
-
const actual = overrideAvailable
|
|
421
|
+
const actual = overrideAvailable
|
|
422
|
+
? { approval, sandbox: acpRuntimeSandbox(role) }
|
|
423
|
+
: acpModePermissions(mode);
|
|
366
424
|
const exact = actual.approval === approval && actual.sandbox === sandbox;
|
|
367
425
|
return {
|
|
368
426
|
...translated,
|
|
@@ -372,10 +430,9 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
372
430
|
? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
|
|
373
431
|
+ `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
|
|
374
432
|
+ `sandbox=${actual.sandbox}`
|
|
375
|
-
: `
|
|
376
|
-
+ `sandbox=${actual.sandbox}
|
|
377
|
-
+ `
|
|
378
|
-
+ `approval=${approval} sandbox=${sandbox}`],
|
|
433
|
+
: `Codex ACP mode '${mode}' couples approval and filesystem as `
|
|
434
|
+
+ `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
|
|
435
|
+
+ `represent approval=${approval} sandbox=${sandbox}`],
|
|
379
436
|
capabilities: codexCapabilities(actual.approval, actual.sandbox),
|
|
380
437
|
};
|
|
381
438
|
}
|
|
@@ -411,6 +468,8 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
411
468
|
currentIdentityTool: 'current_identity',
|
|
412
469
|
sendTool: 'send_message',
|
|
413
470
|
getMessagesTool: 'get_messages',
|
|
471
|
+
listHistoryTool: 'list_history',
|
|
472
|
+
getHistoryItemTool: 'get_history_item',
|
|
414
473
|
monitorInstruction: (id, configuredRole) => {
|
|
415
474
|
const consented = configuredRole?.harness_options?.monitor === true;
|
|
416
475
|
const consent = consented
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
1
2
|
import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
|
|
2
3
|
export interface PrereqCheck {
|
|
3
4
|
name: string;
|
|
@@ -21,14 +22,39 @@ export interface SessionPrep {
|
|
|
21
22
|
env: Record<string, string>;
|
|
22
23
|
/** Optional launcher selected after runtime prerequisite probing. */
|
|
23
24
|
command?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The settings overlay prepareSession wrote, if it wrote one.
|
|
27
|
+
*
|
|
28
|
+
* The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
|
|
29
|
+
* takes no flags, so it needs the PATH rather than the flag. Recorded here so
|
|
30
|
+
* the two deliveries read one value instead of each re-deriving the filename.
|
|
31
|
+
*/
|
|
32
|
+
settingsOverlay?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
|
|
35
|
+
* if the role declared any. Same reason as `settingsOverlay`: the tmux launch
|
|
36
|
+
* passes the file, the ACP launch has to send the servers themselves.
|
|
37
|
+
*/
|
|
38
|
+
mcpConfigFile?: string;
|
|
24
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* One MCP server as ACP's `session/new` declares it.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
|
|
44
|
+
* goes onto the wire unchanged, so a hand-written near-copy would compile while
|
|
45
|
+
* being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
|
|
46
|
+
* variant is the one with no `type` field at all. Aliasing it also keeps
|
|
47
|
+
* `session/new`'s response type inferable, which a structural stand-in silently
|
|
48
|
+
* broke (every field of the result degraded to `unknown`).
|
|
49
|
+
*/
|
|
50
|
+
export type AcpMcpServer = McpServer;
|
|
25
51
|
export interface Launch {
|
|
26
52
|
argv: string[];
|
|
27
53
|
env: Record<string, string>;
|
|
28
54
|
}
|
|
29
|
-
export interface AcpLaunch {
|
|
30
|
-
argv
|
|
31
|
-
|
|
55
|
+
export interface AcpLaunch extends Launch {
|
|
56
|
+
/** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
|
|
57
|
+
permissionMetadataSource?: 'codex-acp';
|
|
32
58
|
}
|
|
33
59
|
/**
|
|
34
60
|
* The result of expressing neutral `permissions:` in a harness's own terms.
|
|
@@ -67,6 +93,8 @@ export interface BriefingVocab {
|
|
|
67
93
|
currentIdentityTool: string;
|
|
68
94
|
sendTool: string;
|
|
69
95
|
getMessagesTool: string;
|
|
96
|
+
listHistoryTool: string;
|
|
97
|
+
getHistoryItemTool: string;
|
|
70
98
|
monitorInstruction(identity: string, role?: ResolvedRole): string;
|
|
71
99
|
/** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
|
|
72
100
|
supervisedWakeNote(identity: string, role?: ResolvedRole): string;
|
|
@@ -74,7 +102,7 @@ export interface BriefingVocab {
|
|
|
74
102
|
restartPrompt(identity: string, worklogPath: string, role?: ResolvedRole): string;
|
|
75
103
|
}
|
|
76
104
|
/**
|
|
77
|
-
* How a harness's host state splits for sandboxing
|
|
105
|
+
* How a harness's host state splits for sandboxing. `home` is the
|
|
78
106
|
* directory the CLI treats as its own and whose RUNTIME state must be per-role;
|
|
79
107
|
* `shared` are the credential, instruction and configuration paths that stay
|
|
80
108
|
* shared and become read-only inside the sandbox.
|
|
@@ -95,7 +123,13 @@ export interface HarnessAdapter {
|
|
|
95
123
|
id: string;
|
|
96
124
|
supportsResume: boolean;
|
|
97
125
|
checkPrereqs(): Promise<PrereqReport>;
|
|
98
|
-
|
|
126
|
+
/**
|
|
127
|
+
* `role` is the SESSION-AWARE half: some harness options can only be honoured
|
|
128
|
+
* on some session types, and an option that is silently dropped is worse than
|
|
129
|
+
* one that is refused. Optional so an adapter that has nothing session-specific
|
|
130
|
+
* to say keeps its one-argument implementation.
|
|
131
|
+
*/
|
|
132
|
+
validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
|
|
99
133
|
prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
|
|
100
134
|
buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
|
|
101
135
|
buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
|
|
@@ -105,6 +139,23 @@ export interface HarnessAdapter {
|
|
|
105
139
|
* agent's default. Omit for a harness whose ACP agent has no modes.
|
|
106
140
|
*/
|
|
107
141
|
acpPermissionModeId?(role: ResolvedRole): string | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* The MCP servers this role declares, for the `mcpServers` array of ACP's
|
|
144
|
+
* `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
|
|
145
|
+
* configuration alone, which is what fleet has always sent.
|
|
146
|
+
*/
|
|
147
|
+
acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
|
|
148
|
+
/**
|
|
149
|
+
* Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
|
|
150
|
+
* a flag reaches an ACP agent that accepts no flags.
|
|
151
|
+
*
|
|
152
|
+
* ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
|
|
153
|
+
* so what an adapter puts here is only honoured by the agent it was written
|
|
154
|
+
* for. An adapter must therefore return nothing for an ACP command it did not
|
|
155
|
+
* choose, and the options that depend on it must be refused at validation for
|
|
156
|
+
* such a role rather than sent and silently ignored.
|
|
157
|
+
*/
|
|
158
|
+
acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
|
|
108
159
|
/** Effective portable policy and harness-native approval mode after native overrides win. */
|
|
109
160
|
effectivePermissionMode?(role: ResolvedRole): {
|
|
110
161
|
fleetMode: FleetPermissionMode;
|
|
@@ -127,7 +178,7 @@ export interface HarnessAdapter {
|
|
|
127
178
|
nativePermissionOverrides(options: unknown): Record<string, unknown>;
|
|
128
179
|
/**
|
|
129
180
|
* Host paths this harness needs inside a sandbox, split into a per-role
|
|
130
|
-
* writable home and shared read-only credentials/config
|
|
181
|
+
* writable home and shared read-only credentials/config. Omit for a
|
|
131
182
|
* harness with no host state of its own.
|
|
132
183
|
*/
|
|
133
184
|
isolationPaths?(role: ResolvedRole, dirs: RoleDirs): HarnessIsolationPaths;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Exec } from '../exec.js';
|
|
2
2
|
import type { IsolationBackend, NetworkMode } from './types.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* keeps the host network so ours messaging
|
|
6
|
-
*
|
|
7
|
-
* a loopback forwarder is
|
|
4
|
+
* Only `deny` unshares the network namespace. `broker`
|
|
5
|
+
* keeps the host network so ours messaging through a loopback TCP daemon keeps
|
|
6
|
+
* working; hardening `broker` to `--unshare-net` plus
|
|
7
|
+
* a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
|
|
8
8
|
*/
|
|
9
9
|
export declare function unsharesNet(network: NetworkMode): boolean;
|
|
10
10
|
export declare function makeBubblewrapBackend(exec?: Exec): IsolationBackend;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { realExec } from '../exec.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* keeps the host network so ours messaging
|
|
5
|
-
*
|
|
6
|
-
* a loopback forwarder is
|
|
3
|
+
* Only `deny` unshares the network namespace. `broker`
|
|
4
|
+
* keeps the host network so ours messaging through a loopback TCP daemon keeps
|
|
5
|
+
* working; hardening `broker` to `--unshare-net` plus
|
|
6
|
+
* a loopback forwarder is not implemented. `allow`/`allowlist` also keep host net.
|
|
7
7
|
*/
|
|
8
8
|
export function unsharesNet(network) {
|
|
9
9
|
return network === 'deny';
|
|
@@ -23,7 +23,7 @@ export declare function mountConflict(mount: string, forbidden: string): 'exact'
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function validateIsolationConfig(raw: unknown): string[];
|
|
25
25
|
/**
|
|
26
|
-
* Where a role's per-role harness runtime state lives
|
|
26
|
+
* Where a role's per-role harness runtime state lives. Under the agent's
|
|
27
27
|
* own state directory, so it is covered by the state dir's existing lifecycle
|
|
28
28
|
* and by the forbidden-path exception, and is never shared with a peer.
|
|
29
29
|
*/
|
package/dist/isolation/policy.js
CHANGED
|
@@ -105,7 +105,7 @@ export function validateIsolationConfig(raw) {
|
|
|
105
105
|
return problems;
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
|
-
* Where a role's per-role harness runtime state lives
|
|
108
|
+
* Where a role's per-role harness runtime state lives. Under the agent's
|
|
109
109
|
* own state directory, so it is covered by the state dir's existing lifecycle
|
|
110
110
|
* and by the forbidden-path exception, and is never shared with a peer.
|
|
111
111
|
*/
|
|
@@ -147,7 +147,7 @@ export function resolveIsolation(cfg, ctx) {
|
|
|
147
147
|
addRw(stateDir);
|
|
148
148
|
addRw(runCwd);
|
|
149
149
|
if (ctx.harnessHome && ctx.harnessRuntimeDir) {
|
|
150
|
-
// The harness home is backed by a PER-ROLE directory
|
|
150
|
+
// The harness home is backed by a PER-ROLE directory: the agent gets a
|
|
151
151
|
// writable home for its sessions, caches and history, and anything a future
|
|
152
152
|
// CLI version writes lands there too. The shared credentials, global
|
|
153
153
|
// instructions and configuration are then layered back read-only, so they
|
|
@@ -11,12 +11,12 @@ export interface Selection {
|
|
|
11
11
|
detail: string;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Pick the isolation backend for a resolved policy, honouring
|
|
15
|
-
*
|
|
14
|
+
* Pick the isolation backend for a resolved policy, honouring rootless,
|
|
15
|
+
* bwrap-first `auto` and the `on_unavailable` degradation policy.
|
|
16
16
|
*
|
|
17
17
|
* - `none` → the identity backend.
|
|
18
18
|
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
19
|
-
* - `podman` → not implemented
|
|
19
|
+
* - `podman` → not implemented ⇒ treated as unavailable.
|
|
20
20
|
* - `auto` → bwrap if available, else degrade/refuse.
|
|
21
21
|
*
|
|
22
22
|
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
@@ -4,12 +4,12 @@ import { makeNoneBackend } from './none.js';
|
|
|
4
4
|
export { makeBubblewrapBackend, unsharesNet } from './bubblewrap.js';
|
|
5
5
|
export { makeNoneBackend } from './none.js';
|
|
6
6
|
/**
|
|
7
|
-
* Pick the isolation backend for a resolved policy, honouring
|
|
8
|
-
*
|
|
7
|
+
* Pick the isolation backend for a resolved policy, honouring rootless,
|
|
8
|
+
* bwrap-first `auto` and the `on_unavailable` degradation policy.
|
|
9
9
|
*
|
|
10
10
|
* - `none` → the identity backend.
|
|
11
11
|
* - `bubblewrap` → bwrap if available, else degrade/refuse per on_unavailable.
|
|
12
|
-
* - `podman` → not implemented
|
|
12
|
+
* - `podman` → not implemented ⇒ treated as unavailable.
|
|
13
13
|
* - `auto` → bwrap if available, else degrade/refuse.
|
|
14
14
|
*
|
|
15
15
|
* On `on_unavailable: strict` with nothing available, throws (fail closed).
|
|
@@ -20,9 +20,9 @@ export async function selectIsolationBackend(policy, exec = realExec) {
|
|
|
20
20
|
const candidates = [];
|
|
21
21
|
if (policy.backend === 'auto' || policy.backend === 'bubblewrap')
|
|
22
22
|
candidates.push(makeBubblewrapBackend(exec));
|
|
23
|
-
// podman
|
|
23
|
+
// podman has no candidate yet, so it falls through to on_unavailable.
|
|
24
24
|
let lastDetail = policy.backend === 'podman'
|
|
25
|
-
? 'podman backend not implemented
|
|
25
|
+
? 'podman backend is not implemented'
|
|
26
26
|
: 'no isolation backend available';
|
|
27
27
|
for (const b of candidates) {
|
|
28
28
|
const a = await b.available();
|
|
@@ -5,7 +5,7 @@ export interface ResourceArgs {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
8
|
-
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap
|
|
8
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
|
|
9
9
|
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
10
10
|
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
11
11
|
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
/**
|
|
3
3
|
* Build the `systemd-run --user --scope -p … --` prefix that caps the pane's
|
|
4
|
-
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap
|
|
4
|
+
* cgroup-v2 scope. Composed OUTSIDE the sandbox wrap: a tmux pane is
|
|
5
5
|
* a child of a tmux SERVER rather than of the role's own runner process, so the
|
|
6
6
|
* only reliable per-agent limit is a transient scope at the pane itself. (Since
|
|
7
7
|
* #32 that server is per role rather than fleet-wide, which is what keeps one
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Isolation backend selector. `auto` probes bubblewrap
|
|
1
|
+
/** Isolation backend selector. `auto` probes bubblewrap; `none` disables wrapping. */
|
|
2
2
|
export type IsolationBackendId = 'auto' | 'bubblewrap' | 'podman' | 'none';
|
|
3
3
|
/** What to do when the requested backend is unavailable. */
|
|
4
4
|
export type OnUnavailable = 'warn' | 'strict';
|
|
@@ -38,7 +38,7 @@ export interface Mount {
|
|
|
38
38
|
mode: 'ro' | 'rw';
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Runtime facts the pure resolver needs to compute the durable mount set
|
|
41
|
+
* Runtime facts the pure resolver needs to compute the durable mount set:
|
|
42
42
|
* the agent's state dir, its working dir, the fleet user's home, and (if the ours
|
|
43
43
|
* broker exposes one) a unix-socket endpoint to bind in.
|
|
44
44
|
*/
|
|
@@ -52,7 +52,7 @@ export interface WrapContext {
|
|
|
52
52
|
additionalWriteDirs?: string[];
|
|
53
53
|
/**
|
|
54
54
|
* The harness's home directory on the host (`~/.claude`, `~/.codex`). Mounted
|
|
55
|
-
* from `harnessRuntimeDir` so the agent's own runtime state is per-role
|
|
55
|
+
* from `harnessRuntimeDir` so the agent's own runtime state is per-role.
|
|
56
56
|
*/
|
|
57
57
|
harnessHome?: string;
|
|
58
58
|
/** Per-role writable directory backing `harnessHome` inside the sandbox. */
|
package/dist/loops/manager.d.ts
CHANGED
|
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
68
68
|
private armAbandon;
|
|
69
69
|
private finish;
|
|
70
70
|
private advance;
|
|
71
|
+
/**
|
|
72
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
73
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
74
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
75
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
76
|
+
* was reading the log at the time.
|
|
77
|
+
*/
|
|
71
78
|
private skipMissed;
|
|
72
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
81
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
82
|
+
* runs the tick late; this path used to drop anything already due however
|
|
83
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
84
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
85
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
86
|
+
*
|
|
87
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
88
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
89
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
90
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
91
|
+
*
|
|
92
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
93
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
94
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
95
|
+
*/
|
|
96
|
+
private skipRestartBacklog;
|
|
73
97
|
/**
|
|
74
98
|
* A run the store could not record is dropped, not retried: the cursor has
|
|
75
99
|
* already moved, so this can never become a busy loop, and the outage is
|
|
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
86
110
|
* until the process was restarted.
|
|
87
111
|
*/
|
|
88
112
|
private recover;
|
|
113
|
+
/**
|
|
114
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
115
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
116
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
117
|
+
*/
|
|
89
118
|
private envelope;
|
|
90
119
|
}
|
package/dist/loops/manager.js
CHANGED
|
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
|
|
|
39
39
|
}
|
|
40
40
|
start() {
|
|
41
41
|
if (!this.store.fresh)
|
|
42
|
-
this.
|
|
42
|
+
this.skipRestartBacklog();
|
|
43
43
|
this.schedule();
|
|
44
44
|
}
|
|
45
45
|
async stop() {
|
|
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
|
|
|
143
143
|
async attempt(definition, state, scheduledAt) {
|
|
144
144
|
const runId = `sl_${randomUUID()}`;
|
|
145
145
|
const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
|
|
146
|
-
|
|
146
|
+
// The gap is read here and cleared only if the turn is actually admitted:
|
|
147
|
+
// an attempt that ends `skipped_busy` or `unavailable` reported it to
|
|
148
|
+
// nobody, so it has to still be there for the attempt that succeeds.
|
|
149
|
+
const gap = state.missedGap;
|
|
150
|
+
const prompt = this.envelope(definition, runId, scheduledAt, gap);
|
|
147
151
|
let claimed = false;
|
|
148
152
|
const result = await this.arbiter.tryScheduled(prompt, origin, () => {
|
|
149
153
|
claimed = true;
|
|
154
|
+
state.missedGap = null;
|
|
150
155
|
state.activeRunId = runId;
|
|
151
156
|
state.lastRunId = runId;
|
|
152
157
|
state.lastStartedAt = new Date(this.deps.now()).toISOString();
|
|
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
|
|
|
265
270
|
state.nextScheduledAt = new Date(next).toISOString();
|
|
266
271
|
state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
|
|
267
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
275
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
276
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
277
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
278
|
+
* was reading the log at the time.
|
|
279
|
+
*/
|
|
268
280
|
skipMissed(definition, state, now) {
|
|
281
|
+
const from = state.nextScheduledAt;
|
|
269
282
|
let missed = 0;
|
|
270
283
|
while (Date.parse(state.nextDueAt) <= now) {
|
|
271
284
|
this.advance(definition, state);
|
|
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
|
|
|
275
288
|
state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
|
|
276
289
|
state.lastOutcome = 'skipped_missed';
|
|
277
290
|
state.lastFinishedAt = new Date(now).toISOString();
|
|
291
|
+
// Successive outages before any run lands merge into one gap: the earliest
|
|
292
|
+
// start wins, so the window always spans the whole silence.
|
|
293
|
+
const previous = state.missedGap;
|
|
294
|
+
state.missedGap = {
|
|
295
|
+
count: increment(previous?.count ?? 0, missed),
|
|
296
|
+
fromAt: previous?.fromAt ?? from,
|
|
297
|
+
throughAt: state.lastScheduledAt ?? from,
|
|
298
|
+
detectedAt: new Date(now).toISOString(),
|
|
299
|
+
};
|
|
278
300
|
this.store.persist();
|
|
279
|
-
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`
|
|
301
|
+
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
|
|
302
|
+
+ `gap=${from}..${state.missedGap.throughAt} `
|
|
303
|
+
+ `unreported=${state.missedGap.count}`);
|
|
280
304
|
}
|
|
281
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
307
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
308
|
+
* runs the tick late; this path used to drop anything already due however
|
|
309
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
310
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
311
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
312
|
+
*
|
|
313
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
314
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
315
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
316
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
317
|
+
*
|
|
318
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
319
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
320
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
321
|
+
*/
|
|
322
|
+
skipRestartBacklog() {
|
|
282
323
|
const now = this.deps.now();
|
|
283
324
|
for (const definition of this.definitions.values()) {
|
|
284
325
|
const state = this.store.state.loops[definition.name];
|
|
285
|
-
if (definition.enabled && !state.operatorDisabled
|
|
326
|
+
if (definition.enabled && !state.operatorDisabled
|
|
327
|
+
&& now >= Date.parse(state.nextDueAt) + definition.intervalMs)
|
|
286
328
|
this.skipMissed(definition, state, now);
|
|
287
329
|
}
|
|
288
330
|
}
|
|
@@ -340,17 +382,38 @@ export class ScheduledLoopManager {
|
|
|
340
382
|
this.deps.clearTimer(this.timer);
|
|
341
383
|
this.arm(backoffMs(this.pollFailures));
|
|
342
384
|
}
|
|
343
|
-
|
|
385
|
+
/**
|
|
386
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
387
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
388
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
389
|
+
*/
|
|
390
|
+
envelope(definition, runId, scheduledAt, gap) {
|
|
391
|
+
const lateBy = Math.max(0, this.deps.now() - scheduledAt);
|
|
344
392
|
return [
|
|
345
393
|
'[fleet-loop]',
|
|
346
394
|
`loop: ${definition.name}`,
|
|
347
395
|
`run: ${runId}`,
|
|
348
396
|
`scheduled_at: ${new Date(scheduledAt).toISOString()}`,
|
|
397
|
+
...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
|
|
398
|
+
...(gap ? [
|
|
399
|
+
`missed_occurrences: ${gap.count}`,
|
|
400
|
+
`missed_window: ${gap.fromAt}..${gap.throughAt}`,
|
|
401
|
+
`missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
|
|
402
|
+
] : []),
|
|
349
403
|
'origin: local-trusted-config',
|
|
350
404
|
'',
|
|
351
405
|
'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
|
|
352
406
|
'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
|
|
353
407
|
'configured policy and an existing authenticated proactive-report route authorize a material report.',
|
|
408
|
+
// Same single route as the owner-request prompt, and for the same reason.
|
|
409
|
+
'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
|
|
410
|
+
'identity if this role has one, otherwise directly to the contact who should receive it.',
|
|
411
|
+
'A file written anywhere else is not delivered and nothing will report that it was not.',
|
|
412
|
+
...(gap ? ['',
|
|
413
|
+
'This loop did not run for the window above: those occurrences were coalesced away while the role',
|
|
414
|
+
'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
|
|
415
|
+
'on — it is the record of your own outage, and no later pass will be told about it.',
|
|
416
|
+
] : []),
|
|
354
417
|
'',
|
|
355
418
|
definition.prompt,
|
|
356
419
|
].join('\n');
|
package/dist/loops/state.d.ts
CHANGED
|
@@ -8,11 +8,29 @@ export interface LoopCounts {
|
|
|
8
8
|
skippedBusy: number;
|
|
9
9
|
skippedMissed: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A coalesced run of occurrences that were never submitted, held until a run
|
|
13
|
+
* actually starts and can be told about it. Without it a dropped occurrence
|
|
14
|
+
* survives only as a counter, which says how many were lost but never when or
|
|
15
|
+
* for how long — and an oversight role cannot report an outage it cannot date.
|
|
16
|
+
*/
|
|
17
|
+
export interface LoopMissedGap {
|
|
18
|
+
/** Occurrences coalesced away, summed across every skip since the last run. */
|
|
19
|
+
count: number;
|
|
20
|
+
/** Nominal time of the earliest occurrence in the gap. */
|
|
21
|
+
fromAt: string;
|
|
22
|
+
/** Nominal time of the latest occurrence in the gap. */
|
|
23
|
+
throughAt: string;
|
|
24
|
+
/** When the manager noticed — the end of the outage, not of the last skip. */
|
|
25
|
+
detectedAt: string;
|
|
26
|
+
}
|
|
11
27
|
export interface LoopRuntimeState {
|
|
12
28
|
definitionHash: string;
|
|
13
29
|
promptHash: string;
|
|
14
30
|
enabled: boolean;
|
|
15
31
|
operatorDisabled: boolean;
|
|
32
|
+
/** Unreported gap, cleared by the first run that carries it. */
|
|
33
|
+
missedGap: LoopMissedGap | null;
|
|
16
34
|
nextScheduledAt: string;
|
|
17
35
|
nextDueAt: string;
|
|
18
36
|
lastScheduledAt: string | null;
|