@ours.network/fleet 0.18.0-nightly.5 → 0.18.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 +101 -25
- 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.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +113 -27
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +28 -17
- package/dist/owner-channel/channel.js +249 -158
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- 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 -3
- package/dist/owner-channel/ours-client.d.ts +0 -141
- package/dist/owner-channel/ours-client.js +0 -225
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,7 @@ export interface BriefingVocab {
|
|
|
67
93
|
currentIdentityTool: string;
|
|
68
94
|
sendTool: string;
|
|
69
95
|
getMessagesTool: string;
|
|
96
|
+
watchCommand(identity: string): string;
|
|
70
97
|
monitorInstruction(identity: string, role?: ResolvedRole): string;
|
|
71
98
|
/** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
|
|
72
99
|
supervisedWakeNote(identity: string, role?: ResolvedRole): string;
|
|
@@ -95,7 +122,13 @@ export interface HarnessAdapter {
|
|
|
95
122
|
id: string;
|
|
96
123
|
supportsResume: boolean;
|
|
97
124
|
checkPrereqs(): Promise<PrereqReport>;
|
|
98
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `role` is the SESSION-AWARE half: some harness options can only be honoured
|
|
127
|
+
* on some session types, and an option that is silently dropped is worse than
|
|
128
|
+
* one that is refused. Optional so an adapter that has nothing session-specific
|
|
129
|
+
* to say keeps its one-argument implementation.
|
|
130
|
+
*/
|
|
131
|
+
validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
|
|
99
132
|
prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
|
|
100
133
|
buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
|
|
101
134
|
buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
|
|
@@ -105,6 +138,23 @@ export interface HarnessAdapter {
|
|
|
105
138
|
* agent's default. Omit for a harness whose ACP agent has no modes.
|
|
106
139
|
*/
|
|
107
140
|
acpPermissionModeId?(role: ResolvedRole): string | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* The MCP servers this role declares, for the `mcpServers` array of ACP's
|
|
143
|
+
* `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
|
|
144
|
+
* configuration alone, which is what fleet has always sent.
|
|
145
|
+
*/
|
|
146
|
+
acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
|
|
147
|
+
/**
|
|
148
|
+
* Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
|
|
149
|
+
* a flag reaches an ACP agent that accepts no flags.
|
|
150
|
+
*
|
|
151
|
+
* ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
|
|
152
|
+
* so what an adapter puts here is only honoured by the agent it was written
|
|
153
|
+
* for. An adapter must therefore return nothing for an ACP command it did not
|
|
154
|
+
* choose, and the options that depend on it must be refused at validation for
|
|
155
|
+
* such a role rather than sent and silently ignored.
|
|
156
|
+
*/
|
|
157
|
+
acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
|
|
108
158
|
/** Effective portable policy and harness-native approval mode after native overrides win. */
|
|
109
159
|
effectivePermissionMode?(role: ResolvedRole): {
|
|
110
160
|
fleetMode: FleetPermissionMode;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Exec } from './exec.js';
|
|
2
|
+
export declare const HARNESS_PLUGIN_IDS: readonly ["codex", "claude-code"];
|
|
3
|
+
export type HarnessPluginId = (typeof HARNESS_PLUGIN_IDS)[number];
|
|
4
|
+
export type HarnessPluginChannel = 'stable' | 'nightly';
|
|
5
|
+
export interface HarnessPluginConfig {
|
|
6
|
+
plugin_channel: HarnessPluginChannel;
|
|
7
|
+
}
|
|
8
|
+
export type HarnessPluginConfigs = Record<HarnessPluginId, HarnessPluginConfig>;
|
|
9
|
+
export interface HarnessPluginLock {
|
|
10
|
+
schemaVersion: 1;
|
|
11
|
+
harness: HarnessPluginId;
|
|
12
|
+
channel: HarnessPluginChannel;
|
|
13
|
+
distTag: 'latest' | 'nightly';
|
|
14
|
+
package: string;
|
|
15
|
+
version: string;
|
|
16
|
+
registry: 'https://registry.npmjs.org';
|
|
17
|
+
resolvedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export interface HarnessPluginInstallResult {
|
|
20
|
+
lock: HarnessPluginLock;
|
|
21
|
+
lockPath: string;
|
|
22
|
+
marketplacePath: string;
|
|
23
|
+
resolved: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveHarnessPluginConfigs(raw: unknown, file?: string): HarnessPluginConfigs;
|
|
26
|
+
export declare const harnessPluginRoot: (harness: HarnessPluginId) => string;
|
|
27
|
+
export declare const harnessPluginLockPath: (harness: HarnessPluginId) => string;
|
|
28
|
+
export declare const harnessPluginMarketplaceRoot: (harness: HarnessPluginId) => string;
|
|
29
|
+
export declare const harnessPluginMarketplacePath: (harness: HarnessPluginId) => string;
|
|
30
|
+
export declare function readHarnessPluginLock(harness: HarnessPluginId): HarnessPluginLock | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Re-materialize the generated marketplace from the persisted exact lock.
|
|
33
|
+
* This is the ONLY ordinary startup/reconciliation path: it performs no exec,
|
|
34
|
+
* no network call, and cannot observe a moving npm dist-tag.
|
|
35
|
+
*/
|
|
36
|
+
export declare function restoreLockedHarnessMarketplace(harness: HarnessPluginId, expectedChannel?: HarnessPluginChannel): HarnessPluginLock | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Explicit install/update transaction.
|
|
39
|
+
*
|
|
40
|
+
* install reuses an existing same-channel lock (deterministic repair/reinstall);
|
|
41
|
+
* update always resolves the requested channel once and advances the lock.
|
|
42
|
+
*/
|
|
43
|
+
export declare function installHarnessPlugin(harness: HarnessPluginId, channel: HarnessPluginChannel, options?: {
|
|
44
|
+
update?: boolean;
|
|
45
|
+
exec?: Exec;
|
|
46
|
+
now?: () => Date;
|
|
47
|
+
}): Promise<HarnessPluginInstallResult>;
|
|
48
|
+
export declare function selectedHarnessPluginIds(values: string[]): HarnessPluginId[];
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
|
+
import { realExec } from './exec.js';
|
|
5
|
+
import { stateRoot } from './paths.js';
|
|
6
|
+
export const HARNESS_PLUGIN_IDS = ['codex', 'claude-code'];
|
|
7
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
8
|
+
const SPECS = {
|
|
9
|
+
codex: {
|
|
10
|
+
package: '@ours.network/codex',
|
|
11
|
+
marketplaceName: 'ours-fleet-codex-lock',
|
|
12
|
+
marketplaceManifest: '.agents/plugins/marketplace.json',
|
|
13
|
+
executable: 'codex',
|
|
14
|
+
},
|
|
15
|
+
'claude-code': {
|
|
16
|
+
package: '@ours.network/claude-code',
|
|
17
|
+
marketplaceName: 'ours-fleet-claude-lock',
|
|
18
|
+
marketplaceManifest: '.claude-plugin/marketplace.json',
|
|
19
|
+
executable: 'claude',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
23
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
export function resolveHarnessPluginConfigs(raw, file = 'fleet.yaml') {
|
|
25
|
+
const resolved = {
|
|
26
|
+
codex: { plugin_channel: 'stable' },
|
|
27
|
+
'claude-code': { plugin_channel: 'stable' },
|
|
28
|
+
};
|
|
29
|
+
if (raw === undefined)
|
|
30
|
+
return resolved;
|
|
31
|
+
if (!isRecord(raw))
|
|
32
|
+
throw new Error(`${file}: harnesses must be a map`);
|
|
33
|
+
const unknown = Object.keys(raw).filter(key => !HARNESS_PLUGIN_IDS.includes(key));
|
|
34
|
+
if (unknown.length)
|
|
35
|
+
throw new Error(`${file}: harnesses has unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
|
|
36
|
+
for (const harness of HARNESS_PLUGIN_IDS) {
|
|
37
|
+
const value = raw[harness];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
throw new Error(`${file}: harnesses.${harness} must be a map`);
|
|
42
|
+
const bad = Object.keys(value).filter(key => key !== 'plugin_channel');
|
|
43
|
+
if (bad.length)
|
|
44
|
+
throw new Error(`${file}: harnesses.${harness} has unknown key(s) ${bad.join(', ')}; allowed: plugin_channel`);
|
|
45
|
+
const channel = value.plugin_channel;
|
|
46
|
+
if (channel !== 'stable' && channel !== 'nightly')
|
|
47
|
+
throw new Error(`${file}: harnesses.${harness}.plugin_channel must be one of: stable, nightly`);
|
|
48
|
+
resolved[harness] = { plugin_channel: channel };
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
export const harnessPluginRoot = (harness) => join(stateRoot(), 'harness-plugins', harness);
|
|
53
|
+
export const harnessPluginLockPath = (harness) => join(harnessPluginRoot(harness), 'plugin-lock.json');
|
|
54
|
+
export const harnessPluginMarketplaceRoot = (harness) => join(harnessPluginRoot(harness), 'marketplace');
|
|
55
|
+
export const harnessPluginMarketplacePath = (harness) => join(harnessPluginMarketplaceRoot(harness), SPECS[harness].marketplaceManifest);
|
|
56
|
+
function validateLock(value, harness, path) {
|
|
57
|
+
const spec = SPECS[harness];
|
|
58
|
+
const fail = (detail) => {
|
|
59
|
+
throw new Error(`invalid harness plugin lock ${path}: ${detail}; run \`ours-fleet plugins update ${harness}\``);
|
|
60
|
+
};
|
|
61
|
+
if (!isRecord(value))
|
|
62
|
+
return fail('expected a JSON object');
|
|
63
|
+
if (value.schemaVersion !== 1)
|
|
64
|
+
fail('unsupported schemaVersion');
|
|
65
|
+
if (value.harness !== harness)
|
|
66
|
+
fail(`harness must be '${harness}'`);
|
|
67
|
+
if (value.channel !== 'stable' && value.channel !== 'nightly')
|
|
68
|
+
fail('channel must be stable or nightly');
|
|
69
|
+
const expectedTag = value.channel === 'stable' ? 'latest' : 'nightly';
|
|
70
|
+
if (value.distTag !== expectedTag)
|
|
71
|
+
fail(`distTag must be '${expectedTag}' for channel '${value.channel}'`);
|
|
72
|
+
if (value.package !== spec.package)
|
|
73
|
+
fail(`package must be '${spec.package}'`);
|
|
74
|
+
if (typeof value.version !== 'string' || !EXACT_SEMVER.test(value.version))
|
|
75
|
+
return fail('version must be an exact semver');
|
|
76
|
+
const version = value.version;
|
|
77
|
+
if (value.channel === 'stable' && version.includes('-'))
|
|
78
|
+
fail('stable channel resolved to a prerelease');
|
|
79
|
+
if (value.channel === 'nightly' && !version.includes('-nightly.'))
|
|
80
|
+
fail('nightly channel did not resolve to a -nightly.N prerelease');
|
|
81
|
+
if (value.registry !== REGISTRY)
|
|
82
|
+
fail(`registry must be '${REGISTRY}'`);
|
|
83
|
+
if (typeof value.resolvedAt !== 'string' || !Number.isFinite(Date.parse(value.resolvedAt)))
|
|
84
|
+
fail('resolvedAt must be an ISO timestamp');
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
export function readHarnessPluginLock(harness) {
|
|
88
|
+
const path = harnessPluginLockPath(harness);
|
|
89
|
+
if (!existsSync(path))
|
|
90
|
+
return undefined;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new Error(`invalid harness plugin lock ${path}: ${error.message}; `
|
|
97
|
+
+ `run \`ours-fleet plugins update ${harness}\``);
|
|
98
|
+
}
|
|
99
|
+
return validateLock(parsed, harness, path);
|
|
100
|
+
}
|
|
101
|
+
function marketplaceDocument(lock) {
|
|
102
|
+
const spec = SPECS[lock.harness];
|
|
103
|
+
const source = lock.harness === 'codex'
|
|
104
|
+
? { source: 'npm', package: lock.package, version: lock.version, registry: lock.registry }
|
|
105
|
+
: { source: 'npm', package: lock.package, version: lock.version };
|
|
106
|
+
if (lock.harness === 'codex') {
|
|
107
|
+
return {
|
|
108
|
+
name: spec.marketplaceName,
|
|
109
|
+
interface: { displayName: `ours.network (${lock.channel}, locked ${lock.version})` },
|
|
110
|
+
plugins: [{
|
|
111
|
+
name: 'ours', source,
|
|
112
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
113
|
+
category: 'Productivity',
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
$schema: 'https://json.schemastore.org/claude-code-marketplace.json',
|
|
119
|
+
name: spec.marketplaceName,
|
|
120
|
+
owner: { name: 'Adapt Framework Solutions Ltd', url: 'https://ours.network' },
|
|
121
|
+
description: `Generated by ours-fleet; @ours.network/claude-code is locked to ${lock.version}.`,
|
|
122
|
+
plugins: [{
|
|
123
|
+
name: 'ours', source,
|
|
124
|
+
description: 'Secure ours.network messaging for Claude Code.',
|
|
125
|
+
}],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function writeMarketplace(lock) {
|
|
129
|
+
const path = harnessPluginMarketplacePath(lock.harness);
|
|
130
|
+
replaceFileAtomically(path, `${JSON.stringify(marketplaceDocument(lock), null, 2)}\n`, 0o644);
|
|
131
|
+
return path;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Re-materialize the generated marketplace from the persisted exact lock.
|
|
135
|
+
* This is the ONLY ordinary startup/reconciliation path: it performs no exec,
|
|
136
|
+
* no network call, and cannot observe a moving npm dist-tag.
|
|
137
|
+
*/
|
|
138
|
+
export function restoreLockedHarnessMarketplace(harness, expectedChannel) {
|
|
139
|
+
const lock = readHarnessPluginLock(harness);
|
|
140
|
+
if (!lock) {
|
|
141
|
+
if (expectedChannel === 'nightly')
|
|
142
|
+
throw new Error(`harness '${harness}' requests nightly but has no exact lock; `
|
|
143
|
+
+ `run \`ours-fleet plugins install ${harness}\``);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (expectedChannel && lock.channel !== expectedChannel)
|
|
147
|
+
throw new Error(`harness '${harness}' requests ${expectedChannel} but its exact lock is ${lock.channel} `
|
|
148
|
+
+ `(${lock.version}); run \`ours-fleet plugins update ${harness}\``);
|
|
149
|
+
if (lock)
|
|
150
|
+
writeMarketplace(lock);
|
|
151
|
+
return lock;
|
|
152
|
+
}
|
|
153
|
+
async function checked(exec, command, args, action) {
|
|
154
|
+
const result = await exec(command, args);
|
|
155
|
+
if (result.code !== 0)
|
|
156
|
+
throw new Error(`${action} failed (${command} ${args.join(' ')}): ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function parseJson(output, action) {
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(output);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new Error(`${action} returned invalid JSON: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function resolveExactVersion(harness, channel, exec, now) {
|
|
168
|
+
const spec = SPECS[harness];
|
|
169
|
+
const distTag = channel === 'stable' ? 'latest' : 'nightly';
|
|
170
|
+
const result = await checked(exec, 'npm', ['view', `${spec.package}@${distTag}`, 'version', '--json'], `resolve ${spec.package} ${distTag}`);
|
|
171
|
+
const version = parseJson(result.stdout, `npm view ${spec.package}@${distTag}`);
|
|
172
|
+
if (typeof version !== 'string' || !EXACT_SEMVER.test(version))
|
|
173
|
+
throw new Error(`npm view ${spec.package}@${distTag} did not return one exact semver`);
|
|
174
|
+
if (channel === 'stable' && version.includes('-'))
|
|
175
|
+
throw new Error(`refusing stable ${spec.package}: npm latest resolved to prerelease ${version}`);
|
|
176
|
+
if (channel === 'nightly' && !version.includes('-nightly.'))
|
|
177
|
+
throw new Error(`refusing nightly ${spec.package}: npm nightly resolved to ${version}`);
|
|
178
|
+
return {
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
harness,
|
|
181
|
+
channel,
|
|
182
|
+
distTag,
|
|
183
|
+
package: spec.package,
|
|
184
|
+
version,
|
|
185
|
+
registry: REGISTRY,
|
|
186
|
+
resolvedAt: now().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function marketplaceEntries(harness, output) {
|
|
190
|
+
const parsed = parseJson(output, `${SPECS[harness].executable} plugin marketplace list`);
|
|
191
|
+
if (harness === 'codex') {
|
|
192
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.marketplaces))
|
|
193
|
+
throw new Error('codex plugin marketplace list returned an unexpected shape');
|
|
194
|
+
return parsed.marketplaces.filter(isRecord);
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(parsed))
|
|
197
|
+
throw new Error('claude plugin marketplace list returned an unexpected shape');
|
|
198
|
+
return parsed.filter(isRecord);
|
|
199
|
+
}
|
|
200
|
+
async function ensureMarketplaceRegistered(harness, exec) {
|
|
201
|
+
const spec = SPECS[harness];
|
|
202
|
+
const root = harnessPluginMarketplaceRoot(harness);
|
|
203
|
+
const result = await checked(exec, spec.executable, ['plugin', 'marketplace', 'list', '--json'], `list ${harness} marketplaces`);
|
|
204
|
+
const existing = marketplaceEntries(harness, result.stdout)
|
|
205
|
+
.find(entry => entry.name === spec.marketplaceName);
|
|
206
|
+
if (existing) {
|
|
207
|
+
if (harness === 'codex') {
|
|
208
|
+
const declared = isRecord(existing.marketplaceSource)
|
|
209
|
+
? existing.marketplaceSource.source : existing.root;
|
|
210
|
+
if (typeof declared !== 'string' || resolve(declared) !== resolve(root))
|
|
211
|
+
throw new Error(`marketplace '${spec.marketplaceName}' already exists but does not point to ${root}; `
|
|
212
|
+
+ 'refusing to replace an unrelated marketplace');
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Claude copies marketplace contents into its cache, but retains the
|
|
216
|
+
// source kind in list output. Refresh that copy after each lock update.
|
|
217
|
+
if (existing.source !== 'directory')
|
|
218
|
+
throw new Error(`marketplace '${spec.marketplaceName}' already exists but is not a local directory; `
|
|
219
|
+
+ 'refusing to replace an unrelated marketplace');
|
|
220
|
+
await checked(exec, 'claude', ['plugin', 'marketplace', 'update', spec.marketplaceName], `refresh ${harness} locked marketplace`);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const args = harness === 'codex'
|
|
225
|
+
? ['plugin', 'marketplace', 'add', root, '--json']
|
|
226
|
+
: ['plugin', 'marketplace', 'add', root, '--scope', 'user'];
|
|
227
|
+
await checked(exec, spec.executable, args, `register ${harness} locked marketplace`);
|
|
228
|
+
}
|
|
229
|
+
async function removeLegacyPluginSelections(harness, exec) {
|
|
230
|
+
const spec = SPECS[harness];
|
|
231
|
+
const result = await checked(exec, spec.executable, ['plugin', 'list', '--json'], `list ${harness} plugins`);
|
|
232
|
+
const parsed = parseJson(result.stdout, `${spec.executable} plugin list`);
|
|
233
|
+
if (harness === 'codex' && (!isRecord(parsed) || !Array.isArray(parsed.installed)))
|
|
234
|
+
throw new Error('codex plugin list returned an unexpected shape');
|
|
235
|
+
if (harness === 'claude-code' && !Array.isArray(parsed))
|
|
236
|
+
throw new Error('claude plugin list returned an unexpected shape');
|
|
237
|
+
const entries = (harness === 'codex'
|
|
238
|
+
? parsed.installed
|
|
239
|
+
: parsed).filter(isRecord);
|
|
240
|
+
const ids = new Set(entries
|
|
241
|
+
.map(entry => harness === 'codex' ? entry.pluginId : entry.id)
|
|
242
|
+
.filter((id) => typeof id === 'string'));
|
|
243
|
+
const legacy = harness === 'codex'
|
|
244
|
+
? ['ours@ours-codex-marketplace', 'ours-fleet@ours-codex-marketplace']
|
|
245
|
+
: ['ours@ours', 'ours@ours.network'];
|
|
246
|
+
for (const selector of legacy.filter(id => ids.has(id))) {
|
|
247
|
+
const args = harness === 'codex'
|
|
248
|
+
? ['plugin', 'remove', selector, '--json']
|
|
249
|
+
: ['plugin', 'uninstall', selector, '--scope', 'user', '--keep-data'];
|
|
250
|
+
await checked(exec, spec.executable, args, `remove legacy moving selection ${selector}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function installFromLock(lock, exec) {
|
|
254
|
+
const spec = SPECS[lock.harness];
|
|
255
|
+
// The Codex package also owns the ours-codex launcher. Pin that executable to
|
|
256
|
+
// the same exact artifact as the local marketplace; never invoke its broad
|
|
257
|
+
// installer, which may select daemon/SDK packages outside this feature.
|
|
258
|
+
if (lock.harness === 'codex') {
|
|
259
|
+
await checked(exec, 'npm', ['install', '--global', `${lock.package}@${lock.version}`], `install ${lock.package}@${lock.version}`);
|
|
260
|
+
}
|
|
261
|
+
await ensureMarketplaceRegistered(lock.harness, exec);
|
|
262
|
+
const selector = `ours@${spec.marketplaceName}`;
|
|
263
|
+
const args = lock.harness === 'codex'
|
|
264
|
+
? ['plugin', 'add', selector, '--json']
|
|
265
|
+
: ['plugin', 'install', selector, '--scope', 'user'];
|
|
266
|
+
await checked(exec, spec.executable, args, `install ${selector}`);
|
|
267
|
+
// The same plugin under an older Git marketplace is a distinct harness
|
|
268
|
+
// selection and can remain enabled beside the lock. Remove only the known
|
|
269
|
+
// ours selectors, after the exact local selection is installed successfully.
|
|
270
|
+
await removeLegacyPluginSelections(lock.harness, exec);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Explicit install/update transaction.
|
|
274
|
+
*
|
|
275
|
+
* install reuses an existing same-channel lock (deterministic repair/reinstall);
|
|
276
|
+
* update always resolves the requested channel once and advances the lock.
|
|
277
|
+
*/
|
|
278
|
+
export async function installHarnessPlugin(harness, channel, options = {}) {
|
|
279
|
+
const exec = options.exec ?? realExec;
|
|
280
|
+
const root = harnessPluginRoot(harness);
|
|
281
|
+
return withFileLock(`${root}.lock`, async () => {
|
|
282
|
+
const existing = readHarnessPluginLock(harness);
|
|
283
|
+
const mustResolve = options.update === true || !existing || existing.channel !== channel;
|
|
284
|
+
const lock = mustResolve
|
|
285
|
+
? await resolveExactVersion(harness, channel, exec, options.now ?? (() => new Date()))
|
|
286
|
+
: existing;
|
|
287
|
+
if (mustResolve)
|
|
288
|
+
replaceFileAtomically(harnessPluginLockPath(harness), `${JSON.stringify(lock, null, 2)}\n`);
|
|
289
|
+
// Publish the lock first. If installation is interrupted, every retry uses
|
|
290
|
+
// this same exact version; no half-finished transaction can observe a newer
|
|
291
|
+
// dist-tag on its own.
|
|
292
|
+
const marketplacePath = writeMarketplace(lock);
|
|
293
|
+
await installFromLock(lock, exec);
|
|
294
|
+
return {
|
|
295
|
+
lock,
|
|
296
|
+
lockPath: harnessPluginLockPath(harness),
|
|
297
|
+
marketplacePath,
|
|
298
|
+
resolved: mustResolve,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
export function selectedHarnessPluginIds(values) {
|
|
303
|
+
if (!values.length)
|
|
304
|
+
return [...HARNESS_PLUGIN_IDS];
|
|
305
|
+
const unknown = values.filter(value => !HARNESS_PLUGIN_IDS.includes(value));
|
|
306
|
+
if (unknown.length)
|
|
307
|
+
throw new Error(`unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
|
|
308
|
+
return [...new Set(values)];
|
|
309
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,3 +28,5 @@ export { doctor } from './doctor.js';
|
|
|
28
28
|
export { runOnce, runTemp } from './runner.js';
|
|
29
29
|
export { Tmux } from './tmux.js';
|
|
30
30
|
export { VERSION } from './version.js';
|
|
31
|
+
export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
|
|
32
|
+
export type { HarnessPluginId, HarnessPluginChannel, HarnessPluginConfig, HarnessPluginConfigs, HarnessPluginLock, HarnessPluginInstallResult, } from './harness-plugins.js';
|
package/dist/index.js
CHANGED
|
@@ -23,3 +23,4 @@ export { doctor } from './doctor.js';
|
|
|
23
23
|
export { runOnce, runTemp } from './runner.js';
|
|
24
24
|
export { Tmux } from './tmux.js';
|
|
25
25
|
export { VERSION } from './version.js';
|
|
26
|
+
export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
|
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;
|