@phnx-labs/agents-cli 1.22.114 → 1.22.116
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/CHANGELOG.md +245 -0
- package/README.md +22 -102
- package/dist/browser.js +0 -0
- package/dist/cli/command-registry.js +0 -7
- package/dist/commands/accounts.d.ts +3 -77
- package/dist/commands/accounts.js +13 -366
- package/dist/commands/audit.js +1 -1
- package/dist/commands/auth.js +0 -2
- package/dist/commands/browser.d.ts +53 -0
- package/dist/commands/browser.js +210 -9
- package/dist/commands/doctor.d.ts +2 -3
- package/dist/commands/doctor.js +0 -11
- package/dist/commands/events.d.ts +1 -3
- package/dist/commands/events.js +4 -7
- package/dist/commands/exec.d.ts +0 -1
- package/dist/commands/exec.js +214 -148
- package/dist/commands/feed-watch.js +52 -8
- package/dist/commands/feed.js +65 -14
- package/dist/commands/focus.js +1 -1
- package/dist/commands/go.d.ts +0 -17
- package/dist/commands/go.js +2 -19
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mcp.js +8 -83
- package/dist/commands/memory.js +4 -47
- package/dist/commands/message.js +4 -4
- package/dist/commands/plugins.js +1 -93
- package/dist/commands/repo.js +0 -44
- package/dist/commands/resume.js +4 -1
- package/dist/commands/secrets-passthrough.js +2 -2
- package/dist/commands/send.d.ts +4 -4
- package/dist/commands/send.js +6 -51
- package/dist/commands/sessions-backup-setup.js +1 -1
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +88 -7
- package/dist/commands/sessions-resume.js +0 -1
- package/dist/commands/sessions-share.d.ts +5 -7
- package/dist/commands/sessions-share.js +98 -49
- package/dist/commands/sessions.d.ts +21 -2
- package/dist/commands/sessions.js +158 -11
- package/dist/commands/setup-browser.js +18 -2
- package/dist/commands/setup-computer.js +20 -6
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-secrets.js +24 -4
- package/dist/commands/setup-term.d.ts +24 -0
- package/dist/commands/setup-term.js +70 -0
- package/dist/commands/setup-terminal.d.ts +3 -0
- package/dist/commands/setup-terminal.js +22 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +29 -11
- package/dist/commands/skills.js +0 -8
- package/dist/commands/ssh.d.ts +6 -0
- package/dist/commands/ssh.js +93 -302
- package/dist/commands/sync.js +14 -5
- package/dist/commands/teams.js +1 -1
- package/dist/commands/traces.js +1 -1
- package/dist/lib/accounting/rotate.d.ts +63 -1
- package/dist/lib/accounting/rotate.js +56 -0
- package/dist/lib/accounts/add.d.ts +0 -5
- package/dist/lib/accounts/add.js +3 -9
- package/dist/lib/accounts/slots.js +32 -2
- package/dist/lib/answer-router.d.ts +11 -2
- package/dist/lib/answer-router.js +26 -2
- package/dist/lib/artifacts-client.d.ts +20 -0
- package/dist/lib/artifacts-client.js +46 -0
- package/dist/lib/auth-mint.d.ts +5 -4
- package/dist/lib/auth-mint.js +6 -5
- package/dist/lib/browser/drivers/arc.d.ts +1 -1
- package/dist/lib/browser/runtime-state.d.ts +55 -0
- package/dist/lib/browser/runtime-state.js +99 -18
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +229 -37
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/cli-resources.js +3 -1
- package/dist/lib/cloud/dispatch.js +1 -1
- package/dist/lib/cloudflare/creds.d.ts +10 -0
- package/dist/lib/cloudflare/creds.js +46 -0
- package/dist/lib/cloudflare/provision.d.ts +35 -0
- package/dist/lib/cloudflare/provision.js +144 -0
- package/dist/lib/computer/sessions-list.d.ts +55 -0
- package/dist/lib/computer/sessions-list.js +168 -1
- package/dist/lib/config-keys.d.ts +1 -1
- package/dist/lib/config-keys.js +5 -0
- package/dist/lib/daemon/daemon.js +8 -1
- package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
- package/dist/lib/daemon/feed-stream-service.js +40 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.js +61 -0
- package/dist/lib/devices/connect.d.ts +49 -5
- package/dist/lib/devices/connect.js +169 -21
- package/dist/lib/devices/doctor-findings.js +2 -6
- package/dist/lib/feed/answer.d.ts +153 -4
- package/dist/lib/feed/answer.js +716 -105
- package/dist/lib/feed/envelope.d.ts +79 -0
- package/dist/lib/feed/envelope.js +23 -0
- package/dist/lib/feed/events.d.ts +6 -0
- package/dist/lib/feed/events.js +8 -0
- package/dist/lib/feed/feed.d.ts +61 -1
- package/dist/lib/feed/feed.js +226 -14
- package/dist/lib/feed/hub-server.d.ts +141 -0
- package/dist/lib/feed/hub-server.js +586 -0
- package/dist/lib/feed/hub.d.ts +95 -0
- package/dist/lib/feed/hub.js +255 -0
- package/dist/lib/feed/pr-status.d.ts +8 -0
- package/dist/lib/feed/pr-status.js +9 -1
- package/dist/lib/feed/tool-activity.d.ts +108 -0
- package/dist/lib/feed/tool-activity.js +313 -0
- package/dist/lib/feed/tools.d.ts +198 -0
- package/dist/lib/feed/tools.js +265 -0
- package/dist/lib/feed/watch.d.ts +50 -50
- package/dist/lib/feed/watch.js +147 -16
- package/dist/lib/feed-outcome.d.ts +1 -1
- package/dist/lib/feed-outcome.js +9 -2
- package/dist/lib/feed-policy.js +9 -3
- package/dist/lib/fleet/auth-sync.d.ts +2 -55
- package/dist/lib/fleet/auth-sync.js +2 -89
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/git.d.ts +0 -16
- package/dist/lib/git.js +0 -58
- package/dist/lib/harness-auth-capabilities.js +7 -2
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +20 -1
- package/dist/lib/hosts/dispatch.js +52 -30
- package/dist/lib/hosts/remote-cmd.d.ts +72 -1
- package/dist/lib/hosts/remote-cmd.js +151 -10
- package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
- package/dist/lib/mailbox.d.ts +12 -0
- package/dist/lib/mailbox.js +16 -2
- package/dist/lib/mcp.js +17 -11
- package/dist/lib/menubar/snapshot.d.ts +51 -0
- package/dist/lib/menubar/snapshot.js +42 -3
- package/dist/lib/open-url.js +2 -2
- package/dist/lib/probe.d.ts +4 -1
- package/dist/lib/probe.js +5 -2
- package/dist/lib/projects.d.ts +23 -0
- package/dist/lib/projects.js +78 -0
- package/dist/lib/pwsh.d.ts +33 -0
- package/dist/lib/pwsh.js +56 -0
- package/dist/lib/redact.d.ts +8 -0
- package/dist/lib/redact.js +11 -0
- package/dist/lib/refresh.d.ts +6 -2
- package/dist/lib/refresh.js +92 -72
- package/dist/lib/secrets-cli.d.ts +11 -0
- package/dist/lib/secrets-cli.js +30 -0
- package/dist/lib/secrets-client.js +3 -2
- package/dist/lib/session/active.d.ts +1 -0
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/db.d.ts +67 -3
- package/dist/lib/session/db.js +381 -126
- package/dist/lib/session/detached.d.ts +7 -0
- package/dist/lib/session/detached.js +29 -0
- package/dist/lib/session/prompt.d.ts +23 -7
- package/dist/lib/session/prompt.js +46 -8
- package/dist/lib/session/remote/peer-stream.d.ts +24 -2
- package/dist/lib/session/remote/peer-stream.js +33 -6
- package/dist/lib/session/remote/remote-list.d.ts +20 -0
- package/dist/lib/session/remote/remote-list.js +22 -6
- package/dist/lib/session/remote/watch.d.ts +12 -0
- package/dist/lib/session/remote/watch.js +9 -0
- package/dist/lib/session/remote-preview-cache.d.ts +29 -0
- package/dist/lib/session/remote-preview-cache.js +373 -0
- package/dist/lib/session/sync/backend.d.ts +3 -3
- package/dist/lib/session/sync/backend.js +3 -3
- package/dist/lib/session/sync/provision.d.ts +1 -1
- package/dist/lib/session/sync/provision.js +2 -2
- package/dist/lib/session/tail.d.ts +50 -0
- package/dist/lib/session/tail.js +219 -0
- package/dist/lib/sessions-client.js +0 -3
- package/dist/lib/setup-tool-install.d.ts +3 -0
- package/dist/lib/setup-tool-install.js +27 -0
- package/dist/lib/setup-tool-status.d.ts +22 -0
- package/dist/lib/setup-tool-status.js +220 -0
- package/dist/lib/share-runtime.d.ts +11 -0
- package/dist/lib/share-runtime.js +63 -0
- package/dist/lib/signin-badge.d.ts +19 -4
- package/dist/lib/signin-badge.js +29 -11
- package/dist/lib/smart-launch.d.ts +1 -5
- package/dist/lib/smart-launch.js +3 -11
- package/dist/lib/ssh-exec.d.ts +44 -0
- package/dist/lib/ssh-exec.js +119 -0
- package/dist/lib/startup/command-registry.d.ts +6 -4
- package/dist/lib/startup/command-registry.js +10 -7
- package/dist/lib/state.js +2 -2
- package/dist/lib/storage/selection.d.ts +2 -2
- package/dist/lib/storage/selection.js +2 -2
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +18 -10
- package/dist/lib/term-driver.d.ts +24 -0
- package/dist/lib/term-driver.js +36 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/index.js +1 -1
- package/dist/lib/terminal/inject.d.ts +38 -0
- package/dist/lib/terminal/inject.js +55 -9
- package/dist/lib/terminal/transport.d.ts +15 -5
- package/dist/lib/terminal/transport.js +61 -11
- package/dist/lib/traces/backend.d.ts +1 -2
- package/dist/lib/traces/backend.js +1 -2
- package/dist/lib/traces/provision.d.ts +1 -1
- package/dist/lib/traces/provision.js +2 -2
- package/dist/lib/types.d.ts +8 -6
- package/package.json +2 -3
- package/dist/commands/artifacts-setup.d.ts +0 -53
- package/dist/commands/artifacts-setup.js +0 -161
- package/dist/commands/artifacts.d.ts +0 -18
- package/dist/commands/artifacts.js +0 -58
- package/dist/commands/attach.d.ts +0 -12
- package/dist/commands/attach.js +0 -86
- package/dist/commands/auth-mint.d.ts +0 -12
- package/dist/commands/auth-mint.js +0 -108
- package/dist/commands/reconnect.d.ts +0 -46
- package/dist/commands/reconnect.js +0 -115
- package/dist/commands/share.d.ts +0 -293
- package/dist/commands/share.js +0 -1424
- package/dist/lib/fleet/remote-login.d.ts +0 -170
- package/dist/lib/fleet/remote-login.js +0 -568
- package/dist/lib/share/analytics.d.ts +0 -13
- package/dist/lib/share/analytics.js +0 -45
- package/dist/lib/share/backend.d.ts +0 -120
- package/dist/lib/share/backend.js +0 -176
- package/dist/lib/share/capture.d.ts +0 -31
- package/dist/lib/share/capture.js +0 -174
- package/dist/lib/share/config.d.ts +0 -72
- package/dist/lib/share/config.js +0 -211
- package/dist/lib/share/delete.d.ts +0 -123
- package/dist/lib/share/delete.js +0 -173
- package/dist/lib/share/html.d.ts +0 -20
- package/dist/lib/share/html.js +0 -88
- package/dist/lib/share/http-error.d.ts +0 -53
- package/dist/lib/share/http-error.js +0 -65
- package/dist/lib/share/og.d.ts +0 -26
- package/dist/lib/share/og.js +0 -84
- package/dist/lib/share/provision.d.ts +0 -127
- package/dist/lib/share/provision.js +0 -285
- package/dist/lib/share/publish.d.ts +0 -379
- package/dist/lib/share/publish.js +0 -818
- package/dist/lib/share/worker-template.d.ts +0 -27
- package/dist/lib/share/worker-template.js +0 -2424
- package/dist/lib/storage/index.d.ts +0 -14
- package/dist/lib/storage/index.js +0 -14
- package/dist/lib/storage/visibility.d.ts +0 -82
- package/dist/lib/storage/visibility.js +0 -99
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/** Bounded, single-owner preview fetch with a durable requester cache and cross-process coalescing. */
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
import { normalizeHost } from '../machine-id.js';
|
|
7
|
+
import { getCacheDir } from '../state.js';
|
|
8
|
+
import { isCompleteSessionId } from './discover.js';
|
|
9
|
+
import { fetchPeerPreviewEnvelope, } from './remote/remote-list.js';
|
|
10
|
+
import { readRemotePreviewCache, writeRemotePreviewCacheFailure, writeRemotePreviewCacheSuccess, withSessionDBTimeout, REMOTE_PREVIEW_ENVELOPE_MAX_BYTES, } from './db.js';
|
|
11
|
+
/** How long a successful fetch is served with zero SSH on an ordinary call. */
|
|
12
|
+
const REMOTE_PREVIEW_FRESH_MS = 45_000;
|
|
13
|
+
/** Exponential backoff after a failed fetch, capped, so a persistently
|
|
14
|
+
* unreachable/erroring peer is not re-dialed on every call. */
|
|
15
|
+
function nextAttemptBackoffMs(consecutiveFailures) {
|
|
16
|
+
const capped = Math.min(consecutiveFailures, 6);
|
|
17
|
+
return Math.min(30_000 * 2 ** (capped - 1), 30 * 60_000);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Overall interactive budget for a call that must actually dial the peer:
|
|
21
|
+
* lease-wait + SSH attempt. A caller that provides its own `timeoutMs` still
|
|
22
|
+
* has this outer ceiling applied, since the lease-wait time is additive to it.
|
|
23
|
+
*/
|
|
24
|
+
const REMOTE_PREVIEW_TOTAL_DEADLINE_MS = 10_000;
|
|
25
|
+
const CACHE_BUSY_TIMEOUT_MS = 250;
|
|
26
|
+
/** Default SSH attempt bound for this fast path specifically — tighter than
|
|
27
|
+
* the general-purpose picker's `PEER_PREVIEW_TIMEOUT_MS` (15s), which is
|
|
28
|
+
* tuned for an interactive "peek" the user is already waiting on, not a
|
|
29
|
+
* budget-conscious automated caller. */
|
|
30
|
+
const REMOTE_PREVIEW_SSH_TIMEOUT_MS = 8_000;
|
|
31
|
+
function cacheKey(device, sessionId) {
|
|
32
|
+
return `${device}::${sessionId}`;
|
|
33
|
+
}
|
|
34
|
+
const defaultDeps = { fetchEnvelope: fetchPeerPreviewEnvelope };
|
|
35
|
+
export async function getRemoteSessionPreview(sessionId, device, opts = {}, deps = defaultDeps) {
|
|
36
|
+
if (!isCompleteSessionId(sessionId)) {
|
|
37
|
+
return {
|
|
38
|
+
cache: {
|
|
39
|
+
source: 'cache', fetchedAt: null, stale: false, state: 'invalid-id',
|
|
40
|
+
device: normalizeHost(device),
|
|
41
|
+
reason: 'sessions preview --device requires a full session id (bare UUID, session_<uuid>, or ses_<ulid>) for the durable-cache fast path',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const normalizedDevice = normalizeHost(device);
|
|
46
|
+
try {
|
|
47
|
+
return await fetchOrServe(sessionId, normalizedDevice, opts, deps);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return noCacheOutcome(normalizedDevice, 'The session preview cache is unavailable. Try again.');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Cap on a caller-supplied `--revision` value: it's an opaque cursor, never
|
|
54
|
+
* free text, so this is a sanity bound, not a format check. */
|
|
55
|
+
const REVISION_MAX_CHARS = 128;
|
|
56
|
+
function isValidRevision(revision) {
|
|
57
|
+
return revision.length > 0 && revision.length <= REVISION_MAX_CHARS;
|
|
58
|
+
}
|
|
59
|
+
async function fetchOrServe(sessionId, device, opts, deps) {
|
|
60
|
+
const deadlineAt = Date.now() + REMOTE_PREVIEW_TOTAL_DEADLINE_MS;
|
|
61
|
+
const accessCache = (operation) => withSessionDBTimeout(Math.max(0, Math.min(CACHE_BUSY_TIMEOUT_MS, deadlineAt - Date.now())), operation);
|
|
62
|
+
const now = opts.now ?? Date.now();
|
|
63
|
+
const readCache = () => {
|
|
64
|
+
const stored = accessCache(() => readRemotePreviewCache(device, sessionId));
|
|
65
|
+
return stored?.ok && !validateEnvelope(stored.envelope, sessionId, device).ok
|
|
66
|
+
? { ...stored, ok: false, envelope: undefined } : stored;
|
|
67
|
+
};
|
|
68
|
+
const cached = readCache();
|
|
69
|
+
const recordFailure = (reason) => {
|
|
70
|
+
try {
|
|
71
|
+
accessCache(() => writeRemotePreviewCacheFailure(device, sessionId, reason, nextAttemptBackoffMs, opts.now ?? Date.now()));
|
|
72
|
+
}
|
|
73
|
+
catch { /* The response still carries the failure and any previously read content. */ }
|
|
74
|
+
};
|
|
75
|
+
const revision = opts.revision !== undefined && isValidRevision(opts.revision) ? opts.revision : undefined;
|
|
76
|
+
if (!opts.refresh && cached?.ok && cached.consecutiveFailures === 0) {
|
|
77
|
+
const revisionConfirmedUnchanged = revision !== undefined
|
|
78
|
+
&& cached.lastCallerRevision !== undefined
|
|
79
|
+
&& revision === cached.lastCallerRevision;
|
|
80
|
+
// A revision was supplied and there is no prior recorded caller revision,
|
|
81
|
+
// or it DIFFERS: skip the TTL bypass below (activity-driven invalidation)
|
|
82
|
+
// and fall through to a real attempt, still subject to backoff. No
|
|
83
|
+
// revision supplied at all: fall back to the plain TTL freshness window,
|
|
84
|
+
// unchanged from before `--revision` existed.
|
|
85
|
+
const age = now - cached.fetchedAt;
|
|
86
|
+
const withinFreshWindow = revision === undefined && age >= 0 && age < REMOTE_PREVIEW_FRESH_MS;
|
|
87
|
+
if (revisionConfirmedUnchanged || withinFreshWindow) {
|
|
88
|
+
if (validateEnvelope(cached.envelope, sessionId, device).ok)
|
|
89
|
+
return freshFromCache(cached, device);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!opts.refresh && cached && now < cached.nextAttemptAt) {
|
|
93
|
+
// Still inside the negative-backoff window from a recent failure: honor
|
|
94
|
+
// it rather than dialing again, and serve whatever the cache holds.
|
|
95
|
+
const reason = cached.failureReason ?? 'peer unreachable';
|
|
96
|
+
return cached.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
|
|
97
|
+
}
|
|
98
|
+
// From here on we are actually about to dial the peer. This is the
|
|
99
|
+
// realistic duplication case (one CLI process per user action, e.g. the
|
|
100
|
+
// Menu worker shelling out a fresh `agents sessions preview` per click), so
|
|
101
|
+
// this takes a bounded, OS-visible lease keyed on (device, sessionId) —
|
|
102
|
+
// never an in-process-only guard, which cannot help two separate processes.
|
|
103
|
+
const beforeFetchedAt = cached?.fetchedAt ?? 0;
|
|
104
|
+
const timeoutMs = opts.timeoutMs ?? REMOTE_PREVIEW_SSH_TIMEOUT_MS;
|
|
105
|
+
const priorConsecutiveFailures = cached?.consecutiveFailures ?? 0;
|
|
106
|
+
const attempt = async (remainingMs) => {
|
|
107
|
+
const afterLease = readCache();
|
|
108
|
+
if (afterLease?.ok && afterLease.consecutiveFailures === 0 && afterLease.fetchedAt > beforeFetchedAt) {
|
|
109
|
+
// Another process completed a SUCCESSFUL fetch for this exact
|
|
110
|
+
// (device, id) while we waited for the lease — reuse it. True under
|
|
111
|
+
// --refresh too: refresh means "current data now", and data fetched a
|
|
112
|
+
// moment ago while we queued already IS current.
|
|
113
|
+
return freshFromCache(afterLease, device);
|
|
114
|
+
}
|
|
115
|
+
if (!opts.refresh && afterLease && now < afterLease.nextAttemptAt) {
|
|
116
|
+
// Another process just recorded a FAILURE (and its backoff) while we
|
|
117
|
+
// waited: honor it rather than firing a second attempt right behind it.
|
|
118
|
+
return afterLease.ok
|
|
119
|
+
? staleFromCache(afterLease, device, afterLease.failureReason ?? 'peer unreachable')
|
|
120
|
+
: noCacheOutcome(device, afterLease.failureReason ?? 'peer unreachable');
|
|
121
|
+
}
|
|
122
|
+
if (opts.refresh && afterLease
|
|
123
|
+
&& afterLease.consecutiveFailures > priorConsecutiveFailures) {
|
|
124
|
+
// Even under --refresh (which otherwise ignores backoff): another
|
|
125
|
+
// process's OWN refresh attempt just failed while we waited for the
|
|
126
|
+
// lease. Reuse that failure rather than immediately trying a third
|
|
127
|
+
// time — "explicit retry" still means at most one attempt per genuinely
|
|
128
|
+
// concurrent request, not one per caller.
|
|
129
|
+
const reason = afterLease.failureReason ?? 'peer unreachable';
|
|
130
|
+
return afterLease.ok ? staleFromCache(afterLease, device, reason) : noCacheOutcome(device, reason);
|
|
131
|
+
}
|
|
132
|
+
const fetchBudget = Math.min(timeoutMs, remainingMs, deadlineAt - Date.now() - CACHE_BUSY_TIMEOUT_MS - 100);
|
|
133
|
+
if (fetchBudget <= 0)
|
|
134
|
+
return cached?.ok
|
|
135
|
+
? staleFromCache(cached, device, 'another request for this session was already in flight')
|
|
136
|
+
: noCacheOutcome(device, 'another request for this session was already in flight');
|
|
137
|
+
const result = await deps.fetchEnvelope(sessionId, device, fetchBudget);
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
const reason = describeFailure(result);
|
|
140
|
+
recordFailure(reason);
|
|
141
|
+
if (cached?.ok)
|
|
142
|
+
return staleFromCache(cached, device, reason);
|
|
143
|
+
return noCacheOutcome(device, reason);
|
|
144
|
+
}
|
|
145
|
+
const validation = validateEnvelope(result.envelope, sessionId, device);
|
|
146
|
+
if (!validation.ok) {
|
|
147
|
+
// A peer answered, but the payload doesn't check out (wrong session id,
|
|
148
|
+
// wrong schema version, malformed shape — a version-skewed or
|
|
149
|
+
// misbehaving peer). Never cache it under this id/device key, and never
|
|
150
|
+
// pass it through as if it were a genuine success.
|
|
151
|
+
recordFailure(validation.reason);
|
|
152
|
+
if (cached?.ok)
|
|
153
|
+
return staleFromCache(cached, device, validation.reason);
|
|
154
|
+
return noCacheOutcome(device, validation.reason);
|
|
155
|
+
}
|
|
156
|
+
const envelopeBytes = Buffer.byteLength(JSON.stringify(result.envelope), 'utf8');
|
|
157
|
+
if (envelopeBytes > REMOTE_PREVIEW_ENVELOPE_MAX_BYTES) {
|
|
158
|
+
// The live fetch genuinely succeeded, but the payload is too large to
|
|
159
|
+
// persist AND too large to hand back unbounded. Never silently pass an
|
|
160
|
+
// oversized blob through as "fresh" — degrade to the last-good stale
|
|
161
|
+
// copy (explicitly labeled) when one exists, else an explicit bounded
|
|
162
|
+
// error, and do not cache the oversized response either way.
|
|
163
|
+
const reason = `peer response was ${envelopeBytes} bytes, over the ${REMOTE_PREVIEW_ENVELOPE_MAX_BYTES}-byte bounded-cache limit`;
|
|
164
|
+
recordFailure(reason);
|
|
165
|
+
if (cached?.ok)
|
|
166
|
+
return staleFromCache(cached, device, reason);
|
|
167
|
+
return noCacheOutcome(device, reason);
|
|
168
|
+
}
|
|
169
|
+
const fetchedAt = Math.max(opts.now ?? Date.now(), (afterLease?.fetchedAt ?? 0) + 1);
|
|
170
|
+
let persistenceReason = null;
|
|
171
|
+
try {
|
|
172
|
+
accessCache(() => writeRemotePreviewCacheSuccess(device, sessionId, result.envelope, fetchedAt, revision));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
persistenceReason = 'Session details loaded, but could not be saved for offline use.';
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
envelope: result.envelope,
|
|
179
|
+
cache: { source: 'live', fetchedAt, stale: false, state: 'fresh', device, reason: persistenceReason },
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
return await withBoundedRemoteLease(device, sessionId, timeoutMs, deadlineAt, attempt, () => {
|
|
184
|
+
// Could not acquire the lease within the interactive budget (another
|
|
185
|
+
// process is holding it, presumably mid-fetch, longer than our deadline).
|
|
186
|
+
// Degrade to whatever the cache holds rather than piling on a second SSH
|
|
187
|
+
// attempt — bounded wait, no serial redial, no unbounded queueing.
|
|
188
|
+
const current = cached;
|
|
189
|
+
if (current?.ok)
|
|
190
|
+
return staleFromCache(current, device, 'another request for this session was already in flight');
|
|
191
|
+
return noCacheOutcome(device, 'another request for this session was already in flight and did not finish in time');
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
const reason = 'The session preview cache is unavailable. Try again.';
|
|
196
|
+
return cached?.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Validate a peer's response before it is trusted at all: schema-version 1,
|
|
201
|
+
* a `session` object present, its `id` an EXACT non-empty string match for
|
|
202
|
+
* the id we asked for (never missing/null/numeric/mismatched), and — when the
|
|
203
|
+
* peer names its own machine — that name must agree with the device we
|
|
204
|
+
* actually dialed. Without these checks a version-skewed, misbehaving, or
|
|
205
|
+
* misconfigured peer's response could be cached under the WRONG key (this
|
|
206
|
+
* caller's requested sessionId) or attributed to the wrong owning device,
|
|
207
|
+
* poisoning a later read for a completely different session/device pair.
|
|
208
|
+
*/
|
|
209
|
+
function validateEnvelope(envelope, sessionId, device) {
|
|
210
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
211
|
+
const text = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
212
|
+
const string = (value) => typeof value === 'string';
|
|
213
|
+
const number = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
214
|
+
const integer = (value) => Number.isSafeInteger(value);
|
|
215
|
+
const boolean = (value) => typeof value === 'boolean';
|
|
216
|
+
const shape = (fields) => value => object(value)
|
|
217
|
+
&& Object.entries(fields).every(([key, check]) => value[key] == null || check(value[key]));
|
|
218
|
+
const array = (check) => value => Array.isArray(value) && value.every(check);
|
|
219
|
+
const artifact = shape({ path: string, basename: string, bucket: string });
|
|
220
|
+
const request = shape({ kind: string, text: string, headline: string, turns: integer });
|
|
221
|
+
const step = shape({ text: string, at: string, source: string, live: boolean, now: string,
|
|
222
|
+
mix: value => object(value) && Object.values(value).every(integer) });
|
|
223
|
+
const timeline = shape({ tools: integer, failed: integer, blocked: integer, spanMs: number,
|
|
224
|
+
state: string, reason: string, steps: array(step), earlier: shape({ steps: integer, tools: integer, failed: integer }) });
|
|
225
|
+
const files = shape({ total: integer, source: string,
|
|
226
|
+
changes: array(shape({ path: string, op: string, edits: integer, at: string })) });
|
|
227
|
+
const preview = shape({ firstUser: string, lastAssistant: string, artifacts: array(artifact) });
|
|
228
|
+
const details = shape({ sourceRevision: string, reason: string, partial: boolean, request, timeline, files,
|
|
229
|
+
messages: array(value => object(value) && typeof value.role === 'string' && ['user', 'assistant'].includes(value.role)
|
|
230
|
+
&& string(value.text) && (value.at == null || string(value.at))) });
|
|
231
|
+
if (!object(envelope))
|
|
232
|
+
return { ok: false, reason: 'The device returned a preview that was not a JSON object.' };
|
|
233
|
+
if (envelope.schemaVersion !== 1)
|
|
234
|
+
return { ok: false, reason: 'The device returned an unsupported preview schema.' };
|
|
235
|
+
if (!object(envelope.session) || envelope.session.id !== sessionId)
|
|
236
|
+
return { ok: false, reason: 'The device returned a preview for a different or missing session ID.' };
|
|
237
|
+
if (!shape({ agent: string, machine: string, project: string, cwd: string, title: string,
|
|
238
|
+
lastActivity: string, lastActivityMs: number })(envelope.session))
|
|
239
|
+
return { ok: false, reason: 'The device returned malformed session metadata.' };
|
|
240
|
+
if (envelope.cache != null && !object(envelope.cache))
|
|
241
|
+
return { ok: false, reason: 'The device returned malformed cache metadata.' };
|
|
242
|
+
const owners = [envelope.session.machine, object(envelope.cache) ? envelope.cache.device : undefined]
|
|
243
|
+
.filter(value => value !== undefined);
|
|
244
|
+
if (owners.length === 0 || owners.some(owner => typeof owner !== 'string' || !owner.trim() || normalizeHost(owner) !== device))
|
|
245
|
+
return { ok: false, reason: 'The device returned a preview without a matching owner.' };
|
|
246
|
+
if ((envelope.preview != null && !preview(envelope.preview))
|
|
247
|
+
|| (envelope.details != null && !details(envelope.details))
|
|
248
|
+
|| (envelope.active != null && !shape({ status: string, lastActivityMs: number })(envelope.active))
|
|
249
|
+
|| (envelope.error != null && !string(envelope.error)))
|
|
250
|
+
return { ok: false, reason: 'The device returned malformed session details.' };
|
|
251
|
+
const digest = object(envelope.preview) ? envelope.preview : {};
|
|
252
|
+
const detail = object(envelope.details) ? envelope.details : {};
|
|
253
|
+
const hasContent = text(digest.firstUser) || text(digest.lastAssistant)
|
|
254
|
+
|| (Array.isArray(detail.messages) && detail.messages.length > 0)
|
|
255
|
+
|| (object(detail.request) && text(detail.request.text))
|
|
256
|
+
|| (object(detail.timeline) && Array.isArray(detail.timeline.steps) && detail.timeline.steps.length > 0)
|
|
257
|
+
|| (object(detail.files) && Array.isArray(detail.files.changes) && detail.files.changes.length > 0)
|
|
258
|
+
|| (Array.isArray(digest.artifacts) && digest.artifacts.length > 0);
|
|
259
|
+
if (!hasContent)
|
|
260
|
+
return { ok: false, reason: 'The device has no recorded session details available yet.' };
|
|
261
|
+
return { ok: true };
|
|
262
|
+
}
|
|
263
|
+
/** Cross-process lock target for one (device, sessionId) pair. Deliberately a
|
|
264
|
+
* separate lock namespace from `refresh-coordinator.ts` — different SLA,
|
|
265
|
+
* different consumers, no reason to share contention. */
|
|
266
|
+
function leaseTarget(device, sessionId) {
|
|
267
|
+
const digest = createHash('sha256').update(`${device}::${sessionId}`).digest('hex');
|
|
268
|
+
return path.join(getCacheDir(), 'remote-preview-locks', `${digest}.lock`);
|
|
269
|
+
}
|
|
270
|
+
/** Lock wait, transport, and cache writes share one deadline; no pending retry outlives the call. */
|
|
271
|
+
async function withBoundedRemoteLease(device, sessionId, fetchTimeoutMs, deadlineAt, fn, onDeadline) {
|
|
272
|
+
const target = leaseTarget(device, sessionId);
|
|
273
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
274
|
+
try {
|
|
275
|
+
await fs.writeFile(target, '', { flag: 'wx', mode: 0o600 });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error?.code !== 'EEXIST')
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
// Stale slightly above the fetch's own bound: a genuinely dead holder
|
|
282
|
+
// (crashed mid-fetch) is reclaimed soon after its own attempt would have
|
|
283
|
+
// timed out anyway; a live holder's lock never goes stale mid-fetch.
|
|
284
|
+
const staleMs = fetchTimeoutMs + 2_000;
|
|
285
|
+
let release;
|
|
286
|
+
while (!release && Date.now() < deadlineAt) {
|
|
287
|
+
try {
|
|
288
|
+
release = await lockfile.lock(target, {
|
|
289
|
+
realpath: false, stale: staleMs, update: staleMs / 4, retries: 0,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error?.code !== 'ELOCKED')
|
|
294
|
+
throw error;
|
|
295
|
+
const remaining = deadlineAt - Date.now();
|
|
296
|
+
if (remaining > 0)
|
|
297
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, remaining)));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!release)
|
|
301
|
+
return onDeadline();
|
|
302
|
+
const remainingMs = deadlineAt - Date.now();
|
|
303
|
+
if (remainingMs <= 0) {
|
|
304
|
+
// Acquired right at the wire: no budget left to do any real work with it.
|
|
305
|
+
if (release) {
|
|
306
|
+
await release();
|
|
307
|
+
await cleanupLeaseFile(target);
|
|
308
|
+
}
|
|
309
|
+
return onDeadline();
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
return await fn(remainingMs);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
if (release) {
|
|
316
|
+
await release();
|
|
317
|
+
await cleanupLeaseFile(target);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Best-effort removal of the lease marker file after release, so the
|
|
322
|
+
* lock directory doesn't grow one file per (device, sessionId) pair this box
|
|
323
|
+
* has ever previewed. Self-healing either way: `withBoundedRemoteLease`
|
|
324
|
+
* recreates it on demand (`{ flag: 'wx' }` — a no-op if it's still there). */
|
|
325
|
+
async function cleanupLeaseFile(target) {
|
|
326
|
+
try {
|
|
327
|
+
await fs.unlink(target);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Another concurrent caller may already be using it, or it's already
|
|
331
|
+
// gone — either way, nothing to do.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function describeFailure(result) {
|
|
335
|
+
switch (result.reason) {
|
|
336
|
+
case 'no-target': return 'device is not a registered, dialable peer';
|
|
337
|
+
case 'unreachable': return 'device did not answer within the bounded window';
|
|
338
|
+
case 'invalid-json': return 'device returned an unparsable response (version skew?)';
|
|
339
|
+
default: return 'unknown failure';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function freshFromCache(cached, device) {
|
|
343
|
+
return {
|
|
344
|
+
envelope: cached.envelope,
|
|
345
|
+
cache: { source: 'cache', fetchedAt: cached.fetchedAt, stale: false, state: 'fresh', device, reason: null },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function staleFromCache(cached, device, reason) {
|
|
349
|
+
const offline = reason === 'device did not answer within the bounded window' || reason === 'backoff'
|
|
350
|
+
|| reason === 'another request for this session was already in flight';
|
|
351
|
+
return {
|
|
352
|
+
envelope: cached.envelope,
|
|
353
|
+
cache: {
|
|
354
|
+
source: 'cache',
|
|
355
|
+
fetchedAt: cached.fetchedAt,
|
|
356
|
+
stale: true,
|
|
357
|
+
state: offline ? 'stale-offline' : 'stale-error',
|
|
358
|
+
device,
|
|
359
|
+
reason,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function noCacheOutcome(device, reason) {
|
|
364
|
+
const offline = reason.includes('unreachable') || reason.includes('not answer') || reason === 'backoff'
|
|
365
|
+
|| reason.includes('already in flight');
|
|
366
|
+
return {
|
|
367
|
+
cache: {
|
|
368
|
+
source: 'cache', fetchedAt: null, stale: false,
|
|
369
|
+
state: offline ? 'no-cache-offline' : 'no-cache-error',
|
|
370
|
+
device, reason,
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
|
|
19
19
|
* override. A signed-in user with a stale r2.backups bundle still backs up to
|
|
20
20
|
* managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
|
|
21
|
-
* or a DI write token. This mirrors
|
|
22
|
-
* config is deliberately not an override) so the product's
|
|
23
|
-
* contract is identical across surfaces.
|
|
21
|
+
* or a DI write token. This mirrors the artifact-share backend contract (a
|
|
22
|
+
* persisted BYO config is deliberately not an override) so the product's
|
|
23
|
+
* managed-first contract is identical across surfaces.
|
|
24
24
|
*/
|
|
25
25
|
import { type PhoenixSession } from '../../identity/client.js';
|
|
26
26
|
import { type R2Config } from './config.js';
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
|
|
19
19
|
* override. A signed-in user with a stale r2.backups bundle still backs up to
|
|
20
20
|
* managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
|
|
21
|
-
* or a DI write token. This mirrors
|
|
22
|
-
* config is deliberately not an override) so the product's
|
|
23
|
-
* contract is identical across surfaces.
|
|
21
|
+
* or a DI write token. This mirrors the artifact-share backend contract (a
|
|
22
|
+
* persisted BYO config is deliberately not an override) so the product's
|
|
23
|
+
* managed-first contract is identical across surfaces.
|
|
24
24
|
*/
|
|
25
25
|
import { readSession } from '../../identity/client.js';
|
|
26
26
|
import { selectStorageBackendKind } from '../../storage/selection.js';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Cloudflare orchestration for the managed session-backup store. The generic
|
|
2
|
-
// request primitives live in
|
|
2
|
+
// request primitives live in cloudflare/provision; only the isolated sessions
|
|
3
3
|
// resource choices and Worker template belong here. Mirrors lib/traces/provision.ts.
|
|
4
4
|
//
|
|
5
5
|
// This is the OPERATOR provisioning path — cutting the `agents-sessions` Worker +
|
|
6
6
|
// bucket + custom domain once. Product traffic never runs it; a signed-in user
|
|
7
7
|
// talks to the already-deployed Worker through `SessionsHttpClient`.
|
|
8
|
-
import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, putWorkerSecret, } from '../../
|
|
8
|
+
import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, putWorkerSecret, } from '../../cloudflare/provision.js';
|
|
9
9
|
import { DEFAULT_SESSIONS_BUCKET_NAME, DEFAULT_SESSIONS_DOMAIN, DEFAULT_SESSIONS_WORKER_NAME, } from './managed-config.js';
|
|
10
10
|
import { renderSessionsWorkerScript } from './worker-template.js';
|
|
11
11
|
/** Bind the Phoenix identity base URL used to verify every session read and write. */
|
|
@@ -35,4 +35,54 @@ export declare function readSessionTailWithRaw(filePath: string, agent: SessionA
|
|
|
35
35
|
* wrapper over {@link readSessionTailWithRaw} for callers that only need events.
|
|
36
36
|
*/
|
|
37
37
|
export declare function readSessionTail(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
|
|
38
|
+
/**
|
|
39
|
+
* Read the FIRST `maxBytes` of a JSONL transcript as cleaned text — the mirror
|
|
40
|
+
* of {@link readSessionTailContent}, for recovering the session's ACTUAL
|
|
41
|
+
* original request boundedly when it isn't already indexed
|
|
42
|
+
* (`SessionMeta.firstUserMessage`). Returns '' on any error, an empty file, or
|
|
43
|
+
* a first record too large to fit `maxBytes` (bare — no elision fallback; see
|
|
44
|
+
* {@link readSessionHeadContentBounded} for that).
|
|
45
|
+
*/
|
|
46
|
+
export declare function readSessionHeadContent(filePath: string, maxBytes?: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* Recover the transcript's opening JSONL record even when it doesn't fit the
|
|
49
|
+
* plain {@link DEFAULT_HEAD_MAX_BYTES} chunk — the case a first turn carrying
|
|
50
|
+
* a multi-megabyte inline image produces, where the record's own closing
|
|
51
|
+
* newline sits past whatever small chunk was read, so the old bare head
|
|
52
|
+
* reader dropped the WHOLE record (and with it, the real original request)
|
|
53
|
+
* rather than the oversized value inside it.
|
|
54
|
+
*
|
|
55
|
+
* Only invoked when the cheap path already failed to find a complete line
|
|
56
|
+
* (bounded — see {@link readSessionHeadChunk}'s `sawEof`), so an ordinary
|
|
57
|
+
* transcript never pays this cost. Reads up to {@link HEAD_ELISION_MAX_READ_BYTES}
|
|
58
|
+
* from byte 0, runs it through {@link elideOversizedJsonStrings} (bounded by
|
|
59
|
+
* {@link HEAD_ELISION_MAX_MS}), and returns the first complete elided line —
|
|
60
|
+
* text before and after the oversized value is preserved verbatim (only the
|
|
61
|
+
* oversized value itself is shortened), UTF-8 decoding and escape handling
|
|
62
|
+
* both go through the exact same path an unelided record would. Returns ''
|
|
63
|
+
* (a partial result, not a guess) when the scan is cut off by either budget
|
|
64
|
+
* before a complete line was ever produced — never a followup/tail turn
|
|
65
|
+
* substituted for it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readSessionHeadContentBounded(filePath: string, maxReadBytes?: number): string;
|
|
68
|
+
/**
|
|
69
|
+
* Read the FIRST `maxEvents` normalized events from a JSONL transcript's
|
|
70
|
+
* head — the session's actual opening turns, bounded and cheap (one small
|
|
71
|
+
* read from byte 0, the same shared Claude/Codex content parsers `readSessionTail`
|
|
72
|
+
* uses, zero duplicated parse logic). Only Claude and Codex are supported,
|
|
73
|
+
* matching {@link readSessionTailWithRaw}; other agents return no events.
|
|
74
|
+
*
|
|
75
|
+
* When the plain bounded chunk holds no complete first line at all — an
|
|
76
|
+
* opening user turn embedding a multi-megabyte inline image is the real case
|
|
77
|
+
* this covers — falls back to {@link readSessionHeadContentBounded}'s
|
|
78
|
+
* JSON-aware elision scan rather than returning nothing: the same content
|
|
79
|
+
* parsers then see a syntactically valid record with the oversized value
|
|
80
|
+
* shortened, so a text block before or after an elided image block is
|
|
81
|
+
* recovered exactly as if the image had never been oversized (the parser's
|
|
82
|
+
* own image-handling path, e.g. Claude's `normalizedAttachmentEvent`, still
|
|
83
|
+
* runs — it just sees a short placeholder `source.data` instead of the real
|
|
84
|
+
* payload, so an attachment's derived byte size is not meaningful when this
|
|
85
|
+
* fallback fired, only the surrounding TEXT is trustworthy).
|
|
86
|
+
*/
|
|
87
|
+
export declare function readSessionHead(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
|
|
38
88
|
export {};
|