@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.47
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 +52 -0
- package/dist/application/role-removal-service.js +2 -0
- package/dist/application/session-control.d.ts +12 -0
- package/dist/application/session-control.js +19 -1
- package/dist/build-info.json +4 -4
- package/dist/cli.js +73 -12
- package/dist/notifications/fleet-producer.d.ts +1 -0
- package/dist/notifications/fleet-producer.js +82 -3
- package/dist/notifications/outbox.d.ts +4 -0
- package/dist/notifications/outbox.js +9 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +2 -0
- package/dist/session/acp.d.ts +2 -0
- package/dist/session/acp.js +10 -0
- package/dist/session/arbiter.d.ts +2 -0
- package/dist/session/arbiter.js +8 -0
- package/dist/session/codex-app-server.d.ts +2 -0
- package/dist/session/codex-app-server.js +10 -0
- package/dist/session/control.d.ts +3 -1
- package/dist/session/control.js +26 -2
- package/dist/session/conversation-types.d.ts +2 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/tunnel-setup-input.d.ts +11 -0
- package/dist/tunnel-setup-input.js +42 -0
- package/dist/web/server.js +83 -2
- package/dist/web/workspace-devices.d.ts +2 -0
- package/dist/web/workspace-devices.js +13 -0
- package/dist/workspace-enrollment.d.ts +30 -2
- package/dist/workspace-enrollment.js +205 -12
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/live-voice.md +13 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1856,6 +1856,58 @@ pipe with a 32KiB limit and refuses a terminal. Legacy v1 payloads, which contai
|
|
|
1856
1856
|
long-lived scoped credentials, are accepted only through file/stdin, never as
|
|
1857
1857
|
command arguments. Use only one payload transport.
|
|
1858
1858
|
|
|
1859
|
+
When this host already has a registration, Fleet inspects the one-use grant without
|
|
1860
|
+
consuming it. The same workspace reruns without a deletion prompt or retirement.
|
|
1861
|
+
An account-origin change for that workspace requires `--migrate-app-origin`; a
|
|
1862
|
+
changed server identity is refused. An older App without inspection support falls
|
|
1863
|
+
back to asking before redemption. An unavailable inspection service preserves the
|
|
1864
|
+
grant and setup. Fleet verifies the redeemed destination against the inspection.
|
|
1865
|
+
|
|
1866
|
+
For another workspace, setup describes the existing workspace, tunnel hostname and
|
|
1867
|
+
account origin and asks `[y/N]`. No answer or `no` preserves the setup and grant; a
|
|
1868
|
+
noninteractive caller must pass `--replace-registration`, otherwise it exits with
|
|
1869
|
+
an error. A legacy binding without a server identity must rerun its own workspace
|
|
1870
|
+
first. The original Human root signs retirement. Fleet waits for the account's
|
|
1871
|
+
durable cleanup receipt before stopping the old service and revoking its linked
|
|
1872
|
+
devices. If a migrated database has no old registration, the signed command confirms
|
|
1873
|
+
absence; the service has no old provider resources to delete. Installation identities,
|
|
1874
|
+
profile, agents, history, stable host ID and local pairing are preserved.
|
|
1875
|
+
|
|
1876
|
+
Replacement recovery is saved in an owned private `workspace/replacement.json`
|
|
1877
|
+
after root, host, service and server-contact preconditions pass. The App must
|
|
1878
|
+
explicitly support deadline-enforced retirement; an older App without that
|
|
1879
|
+
capability preserves the existing setup. `setup-tunnel --resume` retries its nonce
|
|
1880
|
+
and saved operation deadline (24 hours less a five-minute clock margin).
|
|
1881
|
+
If the enrollment challenge expires, obtain a fresh App command for the same target
|
|
1882
|
+
and pass `--replace-registration`; completed retirement is retained. The old server
|
|
1883
|
+
identity is pinned: only its own invitation can reconnect it.
|
|
1884
|
+
|
|
1885
|
+
`setup-tunnel --abandon-replacement` removes the private recovery payload only when
|
|
1886
|
+
a fresh private receipt confirms no deletion and the operation was never dispatched
|
|
1887
|
+
or was terminally rejected. A lost transport response remains uncertain. Expired
|
|
1888
|
+
operations require a fresh receipt proving the old binding still exists after the
|
|
1889
|
+
deadline plus five minutes and an explicit server guarantee that the deadline is enforced;
|
|
1890
|
+
older recovery records without that guarantee must be resumed. Abandoning preserves
|
|
1891
|
+
the existing binding, connector and installation data. A terminally rejected operation
|
|
1892
|
+
requires abandonment and a new App command, rather than reusing its nonce.
|
|
1893
|
+
After that deadline, a fresh command for the same target can also resume with a
|
|
1894
|
+
new operation nonce after checking the enforced expiry. This confirms signed
|
|
1895
|
+
absence if an old successful receipt was lost and later pruned.
|
|
1896
|
+
|
|
1897
|
+
If the App accepted the successor proof before Fleet wrote its local binding,
|
|
1898
|
+
a fresh command for that target retains the original private connector from the
|
|
1899
|
+
recovery record. If that successor cannot be recovered, remove it in the App,
|
|
1900
|
+
then use `setup-tunnel --discard-retired-replacement` and a fresh App command.
|
|
1901
|
+
Discard is allowed only after retirement and local cleanup, while the old local
|
|
1902
|
+
binding remains. It removes only the recovery record. This host has no working
|
|
1903
|
+
registration until the new setup completes; the old service was stopped and its
|
|
1904
|
+
linked devices revoked.
|
|
1905
|
+
|
|
1906
|
+
Renewal commands contain no existing live connector credential. Fleet keeps the
|
|
1907
|
+
current private connector byte for byte, and accepts renewal only for its recorded
|
|
1908
|
+
workspace, server, origin and tunnel hostname. Restore a missing or unsafe connector,
|
|
1909
|
+
or remove and re-create that workspace in the App before setting it up again.
|
|
1910
|
+
|
|
1859
1911
|
After service and signed tunnel-target confirmation, setup prints both a QR and
|
|
1860
1912
|
ordinary private single-use connection code. The App checks readiness automatically
|
|
1861
1913
|
and immediately opens the code field. If setup or code output fails after binding,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retireNotificationTarget } from '../notifications/target-cleanup.js';
|
|
1
2
|
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, join } from 'node:path';
|
|
3
4
|
import { loadConfig, ROLE_NAME_RE } from '../config.js';
|
|
@@ -87,6 +88,7 @@ export class RoleRemovalService {
|
|
|
87
88
|
await this.options.ops.backend.uninstall(preview.role);
|
|
88
89
|
if (existsSync(sourceState))
|
|
89
90
|
renameSync(sourceState, join(recoveryPath, 'orphan-state'));
|
|
91
|
+
await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(preview.role)}`).catch(() => { });
|
|
90
92
|
this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
|
|
91
93
|
}
|
|
92
94
|
return { ...preview, removed: true, recoveryPath };
|
|
@@ -55,8 +55,14 @@ export interface RoleSessionControl {
|
|
|
55
55
|
text: string;
|
|
56
56
|
actorBrowserSession: string;
|
|
57
57
|
expectedSessionGeneration?: string;
|
|
58
|
+
requireIdle?: boolean;
|
|
58
59
|
source: 'owner_admin_console';
|
|
59
60
|
}): Promise<PromptReceipt>;
|
|
61
|
+
interruptPromptV2?(request: {
|
|
62
|
+
commandId: string;
|
|
63
|
+
expectedSessionGeneration: string;
|
|
64
|
+
promptId: string;
|
|
65
|
+
}): Promise<InterruptReceipt>;
|
|
60
66
|
interruptV2?(commandId: string): Promise<InterruptReceipt & {
|
|
61
67
|
commandId: string;
|
|
62
68
|
}>;
|
|
@@ -109,8 +115,14 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
|
|
|
109
115
|
text: string;
|
|
110
116
|
actorBrowserSession: string;
|
|
111
117
|
expectedSessionGeneration?: string;
|
|
118
|
+
requireIdle?: boolean;
|
|
112
119
|
source: 'owner_admin_console';
|
|
113
120
|
}): Promise<PromptReceipt>;
|
|
121
|
+
interruptPromptV2(request: {
|
|
122
|
+
commandId: string;
|
|
123
|
+
expectedSessionGeneration: string;
|
|
124
|
+
promptId: string;
|
|
125
|
+
}): Promise<InterruptReceipt>;
|
|
114
126
|
interruptV2(commandId: string): Promise<InterruptReceipt & {
|
|
115
127
|
commandId: string;
|
|
116
128
|
}>;
|
|
@@ -106,6 +106,9 @@ export class RoleSessionControlAdapter {
|
|
|
106
106
|
// A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
|
|
107
107
|
// support it. Older supervisors do not know that command and never answer, so check the generation here
|
|
108
108
|
// and submit an ordinary prompt; its command id keeps retries idempotent either way.
|
|
109
|
+
const features = request.requireIdle ? (await this.describe()).features : undefined;
|
|
110
|
+
if (request.requireIdle && (!request.expectedSessionGeneration || !features?.includes('idle_bound_prompts')))
|
|
111
|
+
throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
|
|
109
112
|
let command = 'submit_prompt_v2';
|
|
110
113
|
if (request.expectedSessionGeneration) {
|
|
111
114
|
if ((await this.describe()).features.includes('generation_bound_prompts'))
|
|
@@ -119,18 +122,33 @@ export class RoleSessionControlAdapter {
|
|
|
119
122
|
try {
|
|
120
123
|
return await this.call(command, {
|
|
121
124
|
commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
|
|
122
|
-
source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
|
|
125
|
+
source: request.source, expectedSessionGeneration: request.expectedSessionGeneration, requireIdle: request.requireIdle,
|
|
123
126
|
});
|
|
124
127
|
}
|
|
125
128
|
catch (error) {
|
|
126
129
|
const fleetError = normalizeError(error);
|
|
127
130
|
if (fleetError.message.includes('session_changed:'))
|
|
128
131
|
throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
|
|
132
|
+
if (fleetError.message.includes('voice_busy:'))
|
|
133
|
+
throw new FleetError('conflict', 'Agent is already working. Wait for the current turn.');
|
|
129
134
|
if (fleetError.message.includes('idempotency_conflict'))
|
|
130
135
|
throw new FleetError('idempotency_conflict', 'this command id was already used with a different prompt body');
|
|
131
136
|
throw fleetError;
|
|
132
137
|
}
|
|
133
138
|
}
|
|
139
|
+
async interruptPromptV2(request) {
|
|
140
|
+
if (!(await this.describe()).features.includes('targeted_voice_interrupt'))
|
|
141
|
+
throw new FleetError('capability_unavailable', 'Live voice requires an updated supervisor.');
|
|
142
|
+
try {
|
|
143
|
+
return await this.call('interrupt_voice_v2', request);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const failure = normalizeError(error);
|
|
147
|
+
if (failure.message.includes('session_changed:'))
|
|
148
|
+
throw new FleetError('stale_state', 'Agent session changed.');
|
|
149
|
+
throw failure;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
134
152
|
async interruptV2(commandId) {
|
|
135
153
|
const receipt = await this.call('interrupt_v2', { commandId });
|
|
136
154
|
return {
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.47",
|
|
3
|
+
"buildId": "bbc0b3f7e8a7",
|
|
4
|
+
"commit": "5a8f562be01ca289722c0504f70281f2ce6d64e1",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-05T15:15:27.375Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"managed-cli.setup-v1",
|
|
9
9
|
"workspace.enroll.preserve-profile-v1",
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
|
|
2
3
|
import { completeTunnelSetup } from './tunnel-setup-completion.js';
|
|
3
|
-
import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup } from './tunnel-setup-input.js';
|
|
4
|
+
import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
|
|
4
5
|
import { ensureMinimalSetup } from './minimal-setup.js';
|
|
5
|
-
import { confirmWorkspaceTarget } from './workspace-enrollment.js';
|
|
6
|
+
import { confirmWorkspaceTarget, checkWorkspaceConnector } from './workspace-enrollment.js';
|
|
6
7
|
import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
|
|
7
8
|
import { availableWebPort } from './web/available-port.js';
|
|
8
9
|
import QRCode from 'qrcode';
|
|
@@ -1609,9 +1610,23 @@ webCommand.command('uninstall').description('stop and uninstall the owner web se
|
|
|
1609
1610
|
});
|
|
1610
1611
|
async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
|
|
1611
1612
|
try {
|
|
1613
|
+
if (opts.discardRetiredReplacement) {
|
|
1614
|
+
if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration || opts.abandonReplacement)
|
|
1615
|
+
throw Error('--discard-retired-replacement takes no payload or other setup options');
|
|
1616
|
+
discardRetiredWorkspaceReplacement();
|
|
1617
|
+
process.stdout.write('Retired replacement recovery discarded. This host has no working registration: the old service was stopped and linked devices revoked. After removing the failed successor in the App, use a fresh command to complete a new registration. Installation data and the old local binding are preserved.\n');
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (opts.abandonReplacement) {
|
|
1621
|
+
if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration)
|
|
1622
|
+
throw Error('--abandon-replacement takes no payload or other setup options');
|
|
1623
|
+
await abandonWorkspaceReplacement();
|
|
1624
|
+
process.stdout.write('Unsent or rejected replacement abandoned; existing local setup preserved. Obtain a fresh App command for another setup.\n');
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1612
1627
|
if (setupTunnel && opts.check && opts.resume)
|
|
1613
1628
|
throw Error('Use either --check or --resume');
|
|
1614
|
-
if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin))
|
|
1629
|
+
if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin || opts.replaceRegistration))
|
|
1615
1630
|
throw Error('--check and --resume take no payload or migration flag');
|
|
1616
1631
|
if (setupTunnel && opts.check) {
|
|
1617
1632
|
await setupTunnelPreflight();
|
|
@@ -1621,10 +1636,22 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
|
|
|
1621
1636
|
}
|
|
1622
1637
|
let pending;
|
|
1623
1638
|
if (setupTunnel && opts.resume) {
|
|
1624
|
-
|
|
1625
|
-
if (
|
|
1626
|
-
|
|
1627
|
-
|
|
1639
|
+
const replacement = readWorkspaceReplacement();
|
|
1640
|
+
if (replacement) {
|
|
1641
|
+
if (opts.configuration && resolvePath(opts.configuration) !== replacement.configuration)
|
|
1642
|
+
throw Error('Resume replacement with its original configuration');
|
|
1643
|
+
await setupTunnelPreflight();
|
|
1644
|
+
const result = await finishWorkspaceReplacement(replacement);
|
|
1645
|
+
pending = { appOrigin: replacement.payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: replacement.payload.challenge, configuration: replacement.configuration };
|
|
1646
|
+
savePendingTunnelSetup(pending);
|
|
1647
|
+
clearWorkspaceReplacement();
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
pending = readPendingTunnelSetup();
|
|
1651
|
+
if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
|
|
1652
|
+
throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
|
|
1653
|
+
await setupTunnelPreflight();
|
|
1654
|
+
}
|
|
1628
1655
|
}
|
|
1629
1656
|
else {
|
|
1630
1657
|
if (argument === undefined && !opts.file && !opts.stdin)
|
|
@@ -1634,18 +1661,49 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
|
|
|
1634
1661
|
if (opts.stdin && process.stdin.isTTY)
|
|
1635
1662
|
throw Error('--stdin requires a pipe; use an owned private --file instead');
|
|
1636
1663
|
const input = setupTunnel ? (argument !== undefined ? readTunnelSetupArgument(argument) : opts.stdin ? await readTunnelSetupStdin() : readTunnelSetupFile(opts.file)) : undefined;
|
|
1664
|
+
const previous = setupTunnel ? readWorkspaceBinding() : undefined;
|
|
1665
|
+
const replacement = setupTunnel ? readWorkspaceReplacement() : undefined;
|
|
1666
|
+
if (replacement && !opts.replaceRegistration)
|
|
1667
|
+
throw Error('An unfinished replacement exists. Run setup-tunnel --resume, use a fresh command for the same target with --replace-registration, or --abandon-replacement when retirement was never sent or definitively rejected. If retirement and cleanup completed but the successor cannot be recovered, remove the successor in the App and use --discard-retired-replacement');
|
|
1668
|
+
if (replacement && resolvePath(opts.configuration ?? defaultConfigPath()) !== replacement.configuration)
|
|
1669
|
+
throw Error('Replacement requires its original configuration');
|
|
1670
|
+
const target = previous && !replacement ? await inspectTunnelSetup(input) : undefined;
|
|
1671
|
+
const different = previous && !replacement && (!target || requiresWorkspaceReplacement(previous, target, opts.migrateAppOrigin));
|
|
1672
|
+
if (different && !(await confirmWorkspaceReplacement(previous, opts.replaceRegistration))) {
|
|
1673
|
+
if (!process.stdin.isTTY)
|
|
1674
|
+
throw Error('Replacement confirmation required; existing setup and grant preserved');
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1637
1677
|
if (setupTunnel) {
|
|
1638
1678
|
await setupTunnelPreflight();
|
|
1639
1679
|
await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
|
|
1640
1680
|
}
|
|
1641
|
-
|
|
1681
|
+
let payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
|
|
1682
|
+
verifyTunnelSetupTarget(target, payload);
|
|
1683
|
+
const replace = previous && !replacement && requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, serverCid: payload.serverCid, appOrigin: payload.appOrigin }, opts.migrateAppOrigin);
|
|
1684
|
+
if (replacement) {
|
|
1685
|
+
refreshWorkspaceReplacement(replacement, payload, opts.configuration ?? defaultConfigPath());
|
|
1686
|
+
payload = replacement.payload;
|
|
1687
|
+
}
|
|
1688
|
+
checkWorkspaceConnector(payload);
|
|
1642
1689
|
if (setupTunnel)
|
|
1643
1690
|
await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
|
|
1644
|
-
|
|
1691
|
+
let result;
|
|
1692
|
+
if (replacement) {
|
|
1693
|
+
result = await finishWorkspaceReplacement(replacement);
|
|
1694
|
+
}
|
|
1695
|
+
else if (replace) {
|
|
1696
|
+
const record = await beginWorkspaceReplacement(previous, payload, opts.configuration ?? defaultConfigPath());
|
|
1697
|
+
result = await finishWorkspaceReplacement(record);
|
|
1698
|
+
}
|
|
1699
|
+
else
|
|
1700
|
+
result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
|
|
1645
1701
|
pending = { appOrigin: payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: payload.challenge, configuration: resolvePath(opts.configuration ?? defaultConfigPath()) };
|
|
1646
|
-
//
|
|
1647
|
-
if (setupTunnel)
|
|
1702
|
+
// Keep both the replacement capability and the proof until the next recovery record is durable.
|
|
1703
|
+
if (setupTunnel) {
|
|
1648
1704
|
savePendingTunnelSetup(pending);
|
|
1705
|
+
clearWorkspaceReplacement();
|
|
1706
|
+
}
|
|
1649
1707
|
}
|
|
1650
1708
|
try {
|
|
1651
1709
|
await finishWorkspaceService(pending, opts.resume ? pending.configuration : opts.configuration, setupTunnel);
|
|
@@ -1739,7 +1797,10 @@ cOpt(program.command('setup-tunnel').argument('[payload]', 'minimal one-use v2 A
|
|
|
1739
1797
|
.option('--file <path>', 'owned private App grant or legacy payload file (chmod 600)')
|
|
1740
1798
|
.option('--stdin', 'read the private App grant or legacy payload from a pipe')
|
|
1741
1799
|
.option('--check', 'only verify host prerequisites; changes nothing and needs no payload')
|
|
1742
|
-
.option('--resume', 'finish
|
|
1800
|
+
.option('--resume', 'finish an interrupted replacement or setup; needs no new payload')
|
|
1801
|
+
.option('--discard-retired-replacement', 'forget a failed successor after retirement and local cleanup; remove it in the App first')
|
|
1802
|
+
.option('--abandon-replacement', 'discard an unsent or definitively rejected replacement after checking its private receipt')
|
|
1803
|
+
.option('--replace-registration', 'confirm retirement of the existing account registration when the new one differs')
|
|
1743
1804
|
.option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
|
|
1744
1805
|
.action(async (payload, opts) => workspaceEnrollmentCommand(opts, true, payload));
|
|
1745
1806
|
program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
|
|
@@ -10,6 +10,7 @@ export declare class FleetNotificationProducer {
|
|
|
10
10
|
private readonly queues;
|
|
11
11
|
private readonly timer;
|
|
12
12
|
private readonly existing;
|
|
13
|
+
private cleanup?;
|
|
13
14
|
constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
|
|
14
15
|
private names;
|
|
15
16
|
poll(): void;
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { roomsDir } from '../rooms-tasks/room-state.js';
|
|
2
|
+
import { drainNotificationTargetCleanup } from './target-cleanup.js';
|
|
3
|
+
import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { createHash } from 'node:crypto';
|
|
4
6
|
import { NotificationOutbox } from './outbox.js';
|
|
7
|
+
/** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
|
|
8
|
+
function taskRoleAssociations() {
|
|
9
|
+
const dir = roomsDir(), result = new Map();
|
|
10
|
+
let files;
|
|
11
|
+
try {
|
|
12
|
+
files = readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT')
|
|
16
|
+
return result;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
for (const name of files.filter(name => name.endsWith('.json'))) {
|
|
20
|
+
const file = join(dir, name), stat = lstatSync(file);
|
|
21
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
|
|
22
|
+
throw Error('Unreadable room association');
|
|
23
|
+
const room = JSON.parse(readFileSync(file, 'utf8'));
|
|
24
|
+
if (!room || typeof room.room_id !== 'string' || !Array.isArray(room.member_seats))
|
|
25
|
+
throw Error('Invalid room association');
|
|
26
|
+
if (room.task_id === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof room.task_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(room.task_id))
|
|
29
|
+
throw Error('Invalid task association');
|
|
30
|
+
for (const seat of room.member_seats) {
|
|
31
|
+
if (typeof seat.role_name !== 'string')
|
|
32
|
+
throw Error('Invalid member association');
|
|
33
|
+
if (result.has(seat.role_name) && result.get(seat.role_name) !== room.task_id)
|
|
34
|
+
throw Error('Ambiguous task association');
|
|
35
|
+
result.set(seat.role_name, room.task_id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
5
40
|
/** Read durable supervisor ledgers without restarting agents or attaching a
|
|
6
41
|
* conversation controller. Each tick reads at most 256 KiB per role, from its
|
|
7
42
|
* checkpoint; it never loads a complete transcript or requests model history. */
|
|
@@ -13,6 +48,7 @@ export class FleetNotificationProducer {
|
|
|
13
48
|
queues = new Map();
|
|
14
49
|
timer;
|
|
15
50
|
existing = new Set();
|
|
51
|
+
cleanup;
|
|
16
52
|
constructor(roots, stateDir, config, warn = () => { }) {
|
|
17
53
|
this.roots = roots;
|
|
18
54
|
this.stateDir = stateDir;
|
|
@@ -34,6 +70,49 @@ export class FleetNotificationProducer {
|
|
|
34
70
|
}
|
|
35
71
|
}
|
|
36
72
|
poll() {
|
|
73
|
+
this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
|
|
74
|
+
const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
|
|
75
|
+
if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
|
|
76
|
+
return false;
|
|
77
|
+
// A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
|
|
78
|
+
for (const root of this.roots) {
|
|
79
|
+
try {
|
|
80
|
+
lstatSync(join(root, name));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const root of this.roots) {
|
|
89
|
+
const roleDir = join(root, name), queue = this.queues.get(roleDir);
|
|
90
|
+
if (queue) {
|
|
91
|
+
await queue.retire();
|
|
92
|
+
this.queues.delete(roleDir);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const file = join(this.stateDir, createHash('sha256').update(roleDir).digest('hex') + '.json');
|
|
96
|
+
if (existsSync(file)) {
|
|
97
|
+
const queue = new NotificationOutbox(file, this.config, async () => { throw Error('retired target'); });
|
|
98
|
+
try {
|
|
99
|
+
await queue.retire();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await queue.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
} }).catch(() => { }).finally(() => { this.cleanup = undefined; });
|
|
109
|
+
let taskRoles;
|
|
110
|
+
try {
|
|
111
|
+
taskRoles = taskRoleAssociations();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
this.warn('Notification task association unavailable; delivering unclassified events');
|
|
115
|
+
}
|
|
37
116
|
for (const root of this.roots)
|
|
38
117
|
for (const roleId of this.names(root)) {
|
|
39
118
|
const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
|
|
@@ -108,7 +187,7 @@ export class FleetNotificationProducer {
|
|
|
108
187
|
if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
|
|
109
188
|
current.messages[prompt] = event.messageId;
|
|
110
189
|
if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
|
|
111
|
-
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
|
|
190
|
+
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
|
|
112
191
|
break;
|
|
113
192
|
current.prompts = current.prompts.filter(p => p !== prompt);
|
|
114
193
|
delete current.messages[prompt];
|
|
@@ -125,5 +204,5 @@ export class FleetNotificationProducer {
|
|
|
125
204
|
}
|
|
126
205
|
}
|
|
127
206
|
async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
|
|
128
|
-
async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
|
|
207
|
+
async close() { clearInterval(this.timer); await this.cleanup; await Promise.all([...this.queues.values()].map(q => q.close())); }
|
|
129
208
|
}
|
|
@@ -3,6 +3,7 @@ export interface NotificationInput {
|
|
|
3
3
|
title: string;
|
|
4
4
|
body: string;
|
|
5
5
|
url: string;
|
|
6
|
+
taskId?: string | null;
|
|
6
7
|
}
|
|
7
8
|
export interface ProducerConfig {
|
|
8
9
|
/** Service base URL. With a gateway credential it is the server gateway's notifications route. */
|
|
@@ -29,6 +30,7 @@ export declare class NotificationOutbox {
|
|
|
29
30
|
cursor?: number;
|
|
30
31
|
checkpoint?: unknown;
|
|
31
32
|
private running?;
|
|
33
|
+
private retired;
|
|
32
34
|
private timer;
|
|
33
35
|
constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
|
|
34
36
|
private persist;
|
|
@@ -37,6 +39,8 @@ export declare class NotificationOutbox {
|
|
|
37
39
|
enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
|
|
38
40
|
drain(): Promise<void>;
|
|
39
41
|
private drainOnce;
|
|
42
|
+
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
43
|
+
retire(): Promise<void>;
|
|
40
44
|
close(): Promise<void>;
|
|
41
45
|
}
|
|
42
46
|
export {};
|
|
@@ -66,6 +66,7 @@ export class NotificationOutbox {
|
|
|
66
66
|
cursor;
|
|
67
67
|
checkpoint;
|
|
68
68
|
running;
|
|
69
|
+
retired = false;
|
|
69
70
|
timer;
|
|
70
71
|
constructor(file, config, project, warn = () => { }) {
|
|
71
72
|
this.file = file;
|
|
@@ -106,6 +107,8 @@ export class NotificationOutbox {
|
|
|
106
107
|
advance(cursor) { this.persist(this.entries, cursor); }
|
|
107
108
|
saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
|
|
108
109
|
enqueue(id, value, cursor = this.cursor) {
|
|
110
|
+
if (this.retired)
|
|
111
|
+
return false;
|
|
109
112
|
if (this.entries.some(e => e.id === id)) {
|
|
110
113
|
if (cursor !== this.cursor)
|
|
111
114
|
this.advance(cursor);
|
|
@@ -117,10 +120,14 @@ export class NotificationOutbox {
|
|
|
117
120
|
return true;
|
|
118
121
|
}
|
|
119
122
|
drain() {
|
|
123
|
+
if (this.retired)
|
|
124
|
+
return Promise.resolve();
|
|
120
125
|
return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
|
|
121
126
|
}
|
|
122
127
|
async drainOnce() {
|
|
123
128
|
for (const entry of [...this.entries].slice(0, 32)) {
|
|
129
|
+
if (this.retired)
|
|
130
|
+
break;
|
|
124
131
|
try {
|
|
125
132
|
const value = entry.payload ?? await this.project(entry.value, entry.id);
|
|
126
133
|
// Freeze the canonical projection before any network attempt. A crash
|
|
@@ -145,5 +152,7 @@ export class NotificationOutbox {
|
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
}
|
|
155
|
+
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
156
|
+
async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist([]); }
|
|
148
157
|
async close() { clearInterval(this.timer); await this.running; }
|
|
149
158
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ProducerConfig } from './outbox.js';
|
|
2
|
+
/** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
|
|
3
|
+
export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
|
|
4
|
+
/** Persisted retries also run from the ordinary web notification producer after restart. */
|
|
5
|
+
export declare function drainNotificationTargetCleanup(config: ProducerConfig, dir?: string, request?: typeof fetch, options?: {
|
|
6
|
+
retain?: boolean;
|
|
7
|
+
beforeDelete?: (url: string) => Promise<boolean>;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { producerConfig } from './outbox.js';
|
|
7
|
+
const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
|
|
8
|
+
/** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
|
|
9
|
+
export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
|
|
10
|
+
if (!config)
|
|
11
|
+
return;
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
replaceFileAtomically(join(dir, createHash('sha256').update(url).digest('hex') + '.json'), JSON.stringify({ url }) + '\n', 0o600);
|
|
14
|
+
await drainNotificationTargetCleanup(config, dir, request, { retain: true });
|
|
15
|
+
}
|
|
16
|
+
/** Persisted retries also run from the ordinary web notification producer after restart. */
|
|
17
|
+
export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
return;
|
|
20
|
+
for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
|
|
21
|
+
try {
|
|
22
|
+
const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
|
|
24
|
+
continue;
|
|
25
|
+
if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
|
|
26
|
+
rmSync(path, { force: true });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const response = await request(config.origin + '/api/v1/delete-target', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
30
|
+
headers: { ...(config.gatewayCredential ? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token } : { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' }, body: JSON.stringify(value) });
|
|
31
|
+
await response.body?.cancel();
|
|
32
|
+
if (!response.ok)
|
|
33
|
+
break;
|
|
34
|
+
if (!options.retain)
|
|
35
|
+
rmSync(path, { force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
break; /* No credentials or response bodies are included in logs. */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retireNotificationTarget } from './notifications/target-cleanup.js';
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID, createHash } from 'node:crypto';
|
|
@@ -257,6 +258,7 @@ export async function rmRole(cfg, name, deps) {
|
|
|
257
258
|
rmSync(agentDir(name), { recursive: true, force: true });
|
|
258
259
|
if (generatedSource && existsSync(role.sourceFile)) {
|
|
259
260
|
unlinkSync(role.sourceFile);
|
|
261
|
+
await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
|
|
260
262
|
deps.log(`removed ${role.sourceFile}`);
|
|
261
263
|
}
|
|
262
264
|
deps.log(`removed '${name}' (its ours identity is left intact)`);
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -272,6 +272,8 @@ export declare class AcpSession implements AgentSession {
|
|
|
272
272
|
* (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
|
|
273
273
|
* still owes an undelivered message a replay.
|
|
274
274
|
*/
|
|
275
|
+
readonly liveVoiceSupported = true;
|
|
276
|
+
interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
|
|
275
277
|
interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
|
|
276
278
|
private cancelActive;
|
|
277
279
|
/**
|
package/dist/session/acp.js
CHANGED
|
@@ -763,6 +763,8 @@ export class AcpSession {
|
|
|
763
763
|
return existing;
|
|
764
764
|
if (command.expectedSessionGeneration !== undefined && command.expectedSessionGeneration !== this.conversationSnapshot().sessionGeneration)
|
|
765
765
|
throw new Error('session_changed: Agent session changed. Record a new voice message.');
|
|
766
|
+
if (command.requireIdle && (this.queueDepth > 0 || this.conversationSnapshot().readiness !== 'idle'))
|
|
767
|
+
throw new Error('voice_busy: Agent is already working. Wait for the current turn.');
|
|
766
768
|
const queued = await this.queuePrompt(command.text, {
|
|
767
769
|
origin: { kind: 'owner-admin-console', commandId: command.commandId },
|
|
768
770
|
actor: { browserSession: command.actorBrowserSession },
|
|
@@ -795,6 +797,14 @@ export class AcpSession {
|
|
|
795
797
|
* (`queuePrompt({ interrupt: true })`) needs the typed error, because only it
|
|
796
798
|
* still owes an undelivered message a replay.
|
|
797
799
|
*/
|
|
800
|
+
liveVoiceSupported = true;
|
|
801
|
+
async interruptPrompt(generation, promptId) {
|
|
802
|
+
if (generation !== this.sessionGeneration)
|
|
803
|
+
throw new Error('session_changed: Agent session changed.');
|
|
804
|
+
if (this.activeTurn?.id !== promptId)
|
|
805
|
+
return { state: 'settled' };
|
|
806
|
+
return this.interrupt('local-console');
|
|
807
|
+
}
|
|
798
808
|
async interrupt(source = 'local-console') {
|
|
799
809
|
try {
|
|
800
810
|
await this.cancelActive(source);
|
|
@@ -75,6 +75,8 @@ export declare class RoleTurnArbiter implements AgentSession {
|
|
|
75
75
|
conversationSnapshot(): ConversationSnapshot;
|
|
76
76
|
subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
|
|
77
77
|
submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
|
|
78
|
+
get liveVoiceSupported(): boolean;
|
|
79
|
+
interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
|
|
78
80
|
interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
|
|
79
81
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
80
82
|
respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
|
package/dist/session/arbiter.js
CHANGED
|
@@ -177,6 +177,14 @@ export class RoleTurnArbiter {
|
|
|
177
177
|
return this.exclusive(() => { if (this.retiring)
|
|
178
178
|
throw new Error('Chat is closing'); return this.session.submitPromptBrowser(command); });
|
|
179
179
|
}
|
|
180
|
+
get liveVoiceSupported() { return this.session.liveVoiceSupported === true; }
|
|
181
|
+
interruptPrompt(generation, promptId) {
|
|
182
|
+
return this.exclusive(async () => {
|
|
183
|
+
if (!this.session.interruptPrompt)
|
|
184
|
+
throw new Error('targeted voice interrupt is unavailable');
|
|
185
|
+
return interruptOutcome(await this.session.interruptPrompt(generation, promptId));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
180
188
|
interrupt(source = 'local-console') {
|
|
181
189
|
return this.exclusive(async () => interruptOutcome(await this.session.interrupt(source)));
|
|
182
190
|
}
|
|
@@ -69,6 +69,8 @@ export declare class CodexAppServerSession implements AgentSession {
|
|
|
69
69
|
snapshot(): SessionSnapshot;
|
|
70
70
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
71
71
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
72
|
+
readonly liveVoiceSupported = true;
|
|
73
|
+
interruptPrompt(generation: string, promptId: string): Promise<InterruptOutcome>;
|
|
72
74
|
interrupt(source?: TurnCancellationSource): Promise<InterruptOutcome>;
|
|
73
75
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
74
76
|
respondPermissionV2(permissionId: string, optionId: string, sessionGeneration: string): 'accepted' | 'stale';
|