@phnx-labs/agents-cli 1.22.66 → 1.22.69
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 +56 -0
- package/README.md +21 -8
- package/dist/bootstrap.js +6 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +50 -0
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +5 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +73 -20
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +18 -5
- package/dist/lib/accounting/usage.js +165 -20
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +15 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +27 -2
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +28 -0
- package/dist/lib/run-trace-sync.js +99 -0
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +5 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +16 -2
- package/dist/lib/session/db.js +73 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +187 -26
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +17 -0
- package/dist/lib/session/prompt.js +35 -0
- package/dist/lib/session/recovery.d.ts +43 -6
- package/dist/lib/session/recovery.js +80 -10
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +188 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +67 -11
- package/dist/lib/share/publish.js +98 -16
- package/dist/lib/share/worker-template.js +105 -9
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -9,9 +9,13 @@ import { machineId, normalizeHost } from '../../machine-id.js';
|
|
|
9
9
|
import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
|
|
10
10
|
import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
|
|
11
11
|
import { isReapableOrphan } from '../active.js';
|
|
12
|
+
import { querySessions } from '../db.js';
|
|
13
|
+
import { linearIssueUrl } from '../linear.js';
|
|
14
|
+
import { sessionAgentSupportsResume } from '../recovery.js';
|
|
12
15
|
import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, } from '../session-cache.js';
|
|
13
16
|
export const SESSION_WATCH_VERSION = 1;
|
|
14
17
|
export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
|
|
18
|
+
export const SESSION_WATCH_PREVIOUS_LIMIT = 50;
|
|
15
19
|
/** Stable, opaque identity for one row within one device scope. */
|
|
16
20
|
export function sessionWatchRowKey(scope, row) {
|
|
17
21
|
const identity = activeSessionJournalIdentity(row);
|
|
@@ -24,6 +28,9 @@ export function toSessionWatchRow(scope, row) {
|
|
|
24
28
|
// leaked --device tunnel sessions (RUSH-3011 / issue #3b). A live or
|
|
25
29
|
// recently-exited session stays resumable.
|
|
26
30
|
const resumable = Boolean(row.sessionId) && !isReapableOrphan(row);
|
|
31
|
+
const previous = row.status === 'closed'
|
|
32
|
+
|| row.status === 'crashed'
|
|
33
|
+
|| (row.status === 'abandoned' && row.pidAlive === false);
|
|
27
34
|
const viewingIn = row.viewingIn
|
|
28
35
|
? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
|
|
29
36
|
: null;
|
|
@@ -31,6 +38,7 @@ export function toSessionWatchRow(scope, row) {
|
|
|
31
38
|
...row,
|
|
32
39
|
rowKey,
|
|
33
40
|
sourceDevice: scope,
|
|
41
|
+
previous,
|
|
34
42
|
resumable,
|
|
35
43
|
unwatched: !viewingIn,
|
|
36
44
|
viewingIn,
|
|
@@ -39,6 +47,88 @@ export function toSessionWatchRow(scope, row) {
|
|
|
39
47
|
: null,
|
|
40
48
|
};
|
|
41
49
|
}
|
|
50
|
+
function epochMs(value) {
|
|
51
|
+
const parsed = value ? Date.parse(value) : Number.NaN;
|
|
52
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
53
|
+
}
|
|
54
|
+
/** Stable identity for a durable Previous row. It is deliberately distinct
|
|
55
|
+
* from the live row key for the same session id: both may coexist on the one
|
|
56
|
+
* stream, and the presentation layer lets the live row win while it exists. */
|
|
57
|
+
export function previousSessionWatchRowKey(scope, sessionId) {
|
|
58
|
+
return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
|
|
59
|
+
}
|
|
60
|
+
/** Project one durable indexed session into the same canonical watch contract
|
|
61
|
+
* as live sessions. This is the only history backfill consumed by AGI EXT. */
|
|
62
|
+
export function toPreviousSessionWatchRow(scope, session) {
|
|
63
|
+
const resumable = sessionAgentSupportsResume(session.agent);
|
|
64
|
+
const sourceDevice = normalizeHost(session.machine ?? scope);
|
|
65
|
+
const startedAtMs = epochMs(session.timestamp);
|
|
66
|
+
const lastActivityMs = epochMs(session.lastActivity) || startedAtMs;
|
|
67
|
+
const worktree = session.worktreeSlug && session.cwd
|
|
68
|
+
? { slug: session.worktreeSlug, path: session.cwd, ...(session.gitBranch ? { branch: session.gitBranch } : {}) }
|
|
69
|
+
: undefined;
|
|
70
|
+
return {
|
|
71
|
+
context: 'recent',
|
|
72
|
+
kind: session.agent,
|
|
73
|
+
...(session.harness ? { harness: session.harness } : {}),
|
|
74
|
+
sessionId: session.id,
|
|
75
|
+
...(session.cwd ? { cwd: session.cwd } : {}),
|
|
76
|
+
...(session.project ? { project: session.project } : {}),
|
|
77
|
+
...(session.label ? { label: session.label, title: session.label } : session.topic ? { title: session.topic } : {}),
|
|
78
|
+
...(session.topic ? { topic: session.topic } : {}),
|
|
79
|
+
...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
|
|
80
|
+
...(session.version ? { version: session.version } : {}),
|
|
81
|
+
...(session.account ? { account: session.account } : {}),
|
|
82
|
+
...(session.prUrl ? { pr: { url: session.prUrl, number: session.prNumber } } : {}),
|
|
83
|
+
...(worktree ? { worktree } : {}),
|
|
84
|
+
...(session.gitBranch ? { branch: session.gitBranch } : {}),
|
|
85
|
+
...(session.ticketId ? { ticket: { id: session.ticketId, url: linearIssueUrl(session.ticketId) } } : {}),
|
|
86
|
+
...(session.createdTickets ? { createdTickets: session.createdTickets } : {}),
|
|
87
|
+
...(session.spawnedTeam ? { spawnedTeam: session.spawnedTeam } : {}),
|
|
88
|
+
...(session.tokenCount != null ? { tokenCount: session.tokenCount } : {}),
|
|
89
|
+
...(session.durationMs != null ? { durationMs: session.durationMs } : {}),
|
|
90
|
+
...(session.subAgentCount != null ? { subAgentCount: session.subAgentCount } : {}),
|
|
91
|
+
startedAtMs,
|
|
92
|
+
lastActivityMs,
|
|
93
|
+
status: 'closed',
|
|
94
|
+
phase: 'done',
|
|
95
|
+
pidAlive: false,
|
|
96
|
+
rowKey: previousSessionWatchRowKey(scope, session.id),
|
|
97
|
+
sourceDevice,
|
|
98
|
+
previous: true,
|
|
99
|
+
resumable,
|
|
100
|
+
unwatched: true,
|
|
101
|
+
viewingIn: null,
|
|
102
|
+
recovery: resumable
|
|
103
|
+
? { command: 'agents', args: ['sessions', 'resume', session.id, '--device', sourceDevice], ...(session.cwd ? { cwd: session.cwd } : {}) }
|
|
104
|
+
: null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function demoteWatchRow(scope, row) {
|
|
108
|
+
if (!row.sessionId || row.rowKey === previousSessionWatchRowKey(scope, row.sessionId))
|
|
109
|
+
return null;
|
|
110
|
+
const sourceDevice = row.sourceDevice || scope;
|
|
111
|
+
return {
|
|
112
|
+
...row,
|
|
113
|
+
context: 'recent',
|
|
114
|
+
status: 'closed',
|
|
115
|
+
phase: 'done',
|
|
116
|
+
pidAlive: false,
|
|
117
|
+
rowKey: previousSessionWatchRowKey(scope, row.sessionId),
|
|
118
|
+
previous: true,
|
|
119
|
+
unwatched: true,
|
|
120
|
+
viewingIn: null,
|
|
121
|
+
recovery: row.resumable
|
|
122
|
+
? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', sourceDevice], ...(row.cwd ? { cwd: row.cwd } : {}) }
|
|
123
|
+
: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function isDurablePreviousRow(scope, row) {
|
|
127
|
+
return Boolean(row.sessionId) && row.rowKey === previousSessionWatchRowKey(scope, row.sessionId);
|
|
128
|
+
}
|
|
129
|
+
function rowRecency(row) {
|
|
130
|
+
return row.lastActivityMs ?? row.startedAtMs ?? 0;
|
|
131
|
+
}
|
|
42
132
|
function sameRow(a, b) {
|
|
43
133
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
44
134
|
}
|
|
@@ -51,24 +141,77 @@ export class SessionWatchState {
|
|
|
51
141
|
base(type) {
|
|
52
142
|
return { version: SESSION_WATCH_VERSION, type, streamId: this.streamId, sequence: ++this.sequence, capturedAt: Date.now() };
|
|
53
143
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
144
|
+
/** Keep one visible Previous row per session and the newest bounded window.
|
|
145
|
+
* Returns removed keys so delta callers can converge already-connected clients. */
|
|
146
|
+
prunePrevious(scope, rows) {
|
|
147
|
+
const removed = [];
|
|
148
|
+
const bySession = new Map();
|
|
149
|
+
for (const row of rows.values()) {
|
|
150
|
+
if (!row.previous || !row.sessionId)
|
|
151
|
+
continue;
|
|
152
|
+
const existing = bySession.get(row.sessionId);
|
|
153
|
+
if (!existing) {
|
|
154
|
+
bySession.set(row.sessionId, row);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const keepRow = rowRecency(row) > rowRecency(existing)
|
|
158
|
+
|| (rowRecency(row) === rowRecency(existing) && isDurablePreviousRow(scope, row));
|
|
159
|
+
const drop = keepRow ? existing : row;
|
|
160
|
+
const keep = keepRow ? row : existing;
|
|
161
|
+
if (rows.delete(drop.rowKey))
|
|
162
|
+
removed.push(drop.rowKey);
|
|
163
|
+
bySession.set(row.sessionId, keep);
|
|
164
|
+
}
|
|
165
|
+
const previous = [...bySession.values()].sort((a, b) => rowRecency(b) - rowRecency(a));
|
|
166
|
+
for (const row of previous.slice(SESSION_WATCH_PREVIOUS_LIMIT)) {
|
|
167
|
+
if (rows.delete(row.rowKey))
|
|
168
|
+
removed.push(row.rowKey);
|
|
169
|
+
}
|
|
170
|
+
return removed;
|
|
171
|
+
}
|
|
172
|
+
reset(scope, sourceRows, indexedRows = []) {
|
|
173
|
+
const liveRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
|
|
174
|
+
const sourceIds = new Set(liveRows.map((row) => row.sessionId).filter((id) => Boolean(id)));
|
|
175
|
+
const rows = new Map(liveRows.map((row) => [row.rowKey, row]));
|
|
176
|
+
for (const indexed of indexedRows) {
|
|
177
|
+
if (sourceIds.has(indexed.id))
|
|
178
|
+
continue;
|
|
179
|
+
const row = toPreviousSessionWatchRow(scope, indexed);
|
|
180
|
+
rows.set(row.rowKey, row);
|
|
181
|
+
}
|
|
182
|
+
this.prunePrevious(scope, rows);
|
|
183
|
+
this.rows.set(scope, rows);
|
|
184
|
+
return { ...this.base('reset'), scope, rows: [...rows.values()] };
|
|
58
185
|
}
|
|
59
186
|
update(scope, sourceRows) {
|
|
60
187
|
const previous = this.rows.get(scope) ?? new Map();
|
|
61
188
|
const nextRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
|
|
62
|
-
const
|
|
189
|
+
const activeIds = new Set(nextRows.filter((row) => !row.previous).map((row) => row.sessionId).filter((id) => Boolean(id)));
|
|
190
|
+
const next = new Map([...previous.values()]
|
|
191
|
+
.filter((row) => isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
|
|
192
|
+
.map((row) => [row.rowKey, row]));
|
|
193
|
+
for (const row of nextRows)
|
|
194
|
+
next.set(row.rowKey, row);
|
|
63
195
|
const events = [];
|
|
64
196
|
for (const [rowKey, row] of next) {
|
|
65
197
|
if (!previous.has(rowKey) || !sameRow(previous.get(rowKey), row)) {
|
|
66
198
|
events.push({ ...this.base('upsert'), scope, rowKey, row });
|
|
67
199
|
}
|
|
68
200
|
}
|
|
69
|
-
for (const rowKey of previous
|
|
70
|
-
if (
|
|
71
|
-
|
|
201
|
+
for (const [rowKey, row] of previous) {
|
|
202
|
+
if (next.has(rowKey))
|
|
203
|
+
continue;
|
|
204
|
+
if (isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
|
|
205
|
+
continue;
|
|
206
|
+
events.push({ ...this.base('remove'), scope, rowKey });
|
|
207
|
+
const demoted = demoteWatchRow(scope, row);
|
|
208
|
+
if (demoted && (!next.has(demoted.rowKey) || !sameRow(next.get(demoted.rowKey), demoted))) {
|
|
209
|
+
next.set(demoted.rowKey, demoted);
|
|
210
|
+
events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const rowKey of this.prunePrevious(scope, next)) {
|
|
214
|
+
events.push({ ...this.base('remove'), scope, rowKey });
|
|
72
215
|
}
|
|
73
216
|
this.rows.set(scope, next);
|
|
74
217
|
return events;
|
|
@@ -78,6 +221,11 @@ export class SessionWatchState {
|
|
|
78
221
|
const events = [];
|
|
79
222
|
for (const source of upserts) {
|
|
80
223
|
const row = toSessionWatchRow(scope, source);
|
|
224
|
+
if (!row.previous && row.sessionId) {
|
|
225
|
+
const previousKey = previousSessionWatchRowKey(scope, row.sessionId);
|
|
226
|
+
if (current.delete(previousKey))
|
|
227
|
+
events.push({ ...this.base('remove'), scope, rowKey: previousKey });
|
|
228
|
+
}
|
|
81
229
|
if (!current.has(row.rowKey) || !sameRow(current.get(row.rowKey), row)) {
|
|
82
230
|
current.set(row.rowKey, row);
|
|
83
231
|
events.push({ ...this.base('upsert'), scope, rowKey: row.rowKey, row });
|
|
@@ -85,8 +233,18 @@ export class SessionWatchState {
|
|
|
85
233
|
}
|
|
86
234
|
for (const identity of removes) {
|
|
87
235
|
const rowKey = createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
|
|
88
|
-
|
|
89
|
-
|
|
236
|
+
const removed = current.get(rowKey);
|
|
237
|
+
if (!current.delete(rowKey))
|
|
238
|
+
continue;
|
|
239
|
+
events.push({ ...this.base('remove'), scope, rowKey });
|
|
240
|
+
const demoted = removed ? demoteWatchRow(scope, removed) : null;
|
|
241
|
+
if (demoted && (!current.has(demoted.rowKey) || !sameRow(current.get(demoted.rowKey), demoted))) {
|
|
242
|
+
current.set(demoted.rowKey, demoted);
|
|
243
|
+
events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const rowKey of this.prunePrevious(scope, current)) {
|
|
247
|
+
events.push({ ...this.base('remove'), scope, rowKey });
|
|
90
248
|
}
|
|
91
249
|
this.rows.set(scope, current);
|
|
92
250
|
return events;
|
|
@@ -100,6 +258,24 @@ export class SessionWatchState {
|
|
|
100
258
|
return { ...this.base('heartbeat'), scope, capturedAt };
|
|
101
259
|
}
|
|
102
260
|
}
|
|
261
|
+
/** One bounded index read when the watch starts. The stream then owns this
|
|
262
|
+
* projection: active rows update through the journal, and removals demote into
|
|
263
|
+
* Previous rows without another transcript/index poll. */
|
|
264
|
+
export function readPreviousSessionsForWatch(scope) {
|
|
265
|
+
try {
|
|
266
|
+
return querySessions({
|
|
267
|
+
machine: normalizeHost(scope),
|
|
268
|
+
agents: ['claude', 'codex', 'muse', 'opencode'],
|
|
269
|
+
sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
|
270
|
+
excludeTeamOrigin: true,
|
|
271
|
+
})
|
|
272
|
+
.filter((session) => !session.archived && Boolean(session.filePath))
|
|
273
|
+
.slice(0, SESSION_WATCH_PREVIOUS_LIMIT);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
103
279
|
/**
|
|
104
280
|
* Keep one local subscription alive. Startup reads one canonical reset snapshot;
|
|
105
281
|
* steady state tails the canonical writer journal and never invokes a gather.
|
|
@@ -107,6 +283,7 @@ export class SessionWatchState {
|
|
|
107
283
|
export async function watchLocalSessions(options) {
|
|
108
284
|
const state = new SessionWatchState();
|
|
109
285
|
const readCache = options.readCache ?? readActiveSessionsCache;
|
|
286
|
+
const readPrevious = options.readPrevious ?? readPreviousSessionsForWatch;
|
|
110
287
|
const journal = options.journalPath ?? activeSessionsJournalPath();
|
|
111
288
|
const heartbeatMs = options.heartbeatMs ?? SESSION_WATCH_HEARTBEAT_MS;
|
|
112
289
|
let offset = 0;
|
|
@@ -115,7 +292,7 @@ export async function watchLocalSessions(options) {
|
|
|
115
292
|
}
|
|
116
293
|
catch { /* first publication */ }
|
|
117
294
|
const initial = readCache('local');
|
|
118
|
-
options.emit(state.reset(options.scope, initial?.sessions ?? []));
|
|
295
|
+
options.emit(state.reset(options.scope, initial?.sessions ?? [], readPrevious(options.scope)));
|
|
119
296
|
// Known gap (out of scope for RUSH-2484): a present cache alone marks
|
|
120
297
|
// 'available' with no staleness/age check, so a reconnect can briefly render
|
|
121
298
|
// a stale snapshot as live before the out-of-band gather this file triggers
|
|
@@ -32,6 +32,7 @@ export declare function activeSessionJournalIdentity(row: ActiveSession): string
|
|
|
32
32
|
*/
|
|
33
33
|
export interface ImmutableSessionFields {
|
|
34
34
|
topic?: string;
|
|
35
|
+
firstUserMessage?: string;
|
|
35
36
|
label?: string;
|
|
36
37
|
name?: string;
|
|
37
38
|
cwd?: string;
|
|
@@ -51,7 +52,7 @@ export interface ImmutableSessionFields {
|
|
|
51
52
|
context?: ActiveSession['context'];
|
|
52
53
|
}
|
|
53
54
|
/** Keys stored in the immutable memo (transcript-stable). */
|
|
54
|
-
export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
|
|
55
|
+
export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "firstUserMessage", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
|
|
55
56
|
/**
|
|
56
57
|
* Live / volatile fields that MUST NOT be served from the immutable memo.
|
|
57
58
|
* They either change without a transcript write (pid death, attach state) or
|
|
@@ -19,6 +19,7 @@ import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './
|
|
|
19
19
|
import { isShellExecTool } from './shell-programs.js';
|
|
20
20
|
import { classifyFileChanges } from './digest.js';
|
|
21
21
|
import { extractArtifacts } from './highlights.js';
|
|
22
|
+
import { LINEAR_KEY_DENYLIST, linearIssueKeys } from './linear.js';
|
|
22
23
|
/**
|
|
23
24
|
* Detect per-session rate-limit / usage-limit signals in assistant or error
|
|
24
25
|
* text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
|
|
@@ -187,15 +188,12 @@ export function extractRecentDirectoriesTouched(events, cwd) {
|
|
|
187
188
|
const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
|
|
188
189
|
const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
|
|
189
190
|
/**
|
|
190
|
-
* Linear/Jira-style ref
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* Linear/Jira-style ref detection reuses the canonical key matcher +
|
|
192
|
+
* {@link LINEAR_KEY_DENYLIST} from `./linear.js`, so the transcript detector and
|
|
193
|
+
* the owner-ping linkifier agree on what a real key is (no second copy to drift).
|
|
193
194
|
*/
|
|
194
|
-
const TICKET_RE = /\b([A-Z]{2,6}-\d{1,6})\b/;
|
|
195
195
|
/** Lowercase branch form (Linear branch names): muqsit/rush-1234-fix. */
|
|
196
196
|
const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
|
|
197
|
-
/** Keys that look like tickets but aren't — avoid false positives from branches. */
|
|
198
|
-
const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
|
|
199
197
|
const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
|
|
200
198
|
// Either separator: a Windows session cwd is `…\.agents\worktrees\<slug>`, and a
|
|
201
199
|
// forward-slash-only pattern silently derived no slug there (the RUSH-2358
|
|
@@ -283,15 +281,15 @@ export function detectWorktree(cwd, branch) {
|
|
|
283
281
|
/** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
|
|
284
282
|
export function detectTicket(text, branch) {
|
|
285
283
|
if (text) {
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
return { id:
|
|
284
|
+
const key = linearIssueKeys(text)[0];
|
|
285
|
+
if (key)
|
|
286
|
+
return { id: key };
|
|
289
287
|
}
|
|
290
288
|
if (branch) {
|
|
291
289
|
const m = branch.match(TICKET_BRANCH_RE);
|
|
292
290
|
if (m) {
|
|
293
291
|
const key = m[1].toUpperCase();
|
|
294
|
-
if (!
|
|
292
|
+
if (!LINEAR_KEY_DENYLIST.has(key))
|
|
295
293
|
return { id: `${key}-${m[2]}` };
|
|
296
294
|
}
|
|
297
295
|
}
|
|
@@ -355,9 +353,9 @@ export function isTicketCreateTool(name, command) {
|
|
|
355
353
|
export function extractCreatedTicket(text) {
|
|
356
354
|
if (!text)
|
|
357
355
|
return undefined;
|
|
358
|
-
const lin = text
|
|
359
|
-
if (lin
|
|
360
|
-
return lin
|
|
356
|
+
const lin = linearIssueKeys(text)[0];
|
|
357
|
+
if (lin)
|
|
358
|
+
return lin;
|
|
361
359
|
const gh = text.match(GH_ISSUE_URL_RE);
|
|
362
360
|
if (gh)
|
|
363
361
|
return `#${gh[1]}`;
|
|
@@ -232,6 +232,8 @@ export interface SessionMeta {
|
|
|
232
232
|
/** Effective normalized launch mode captured by the SessionStart hook. */
|
|
233
233
|
mode?: SessionRunMode;
|
|
234
234
|
topic?: string;
|
|
235
|
+
/** Full, cleaned first genuine user turn; distinct from the one-line topic. */
|
|
236
|
+
firstUserMessage?: string;
|
|
235
237
|
/**
|
|
236
238
|
* The session's human-readable name — one field, several sources with a plain
|
|
237
239
|
* priority: an agent-generated title / Claude `/rename` wins; else the launch
|
|
@@ -88,8 +88,8 @@ export declare function phoenixIdBaseForDeploy(opts?: {
|
|
|
88
88
|
domain?: string;
|
|
89
89
|
}): string | undefined;
|
|
90
90
|
/**
|
|
91
|
-
* Pick the principal. Signed-in
|
|
92
|
-
* override → managed; otherwise BYO.
|
|
91
|
+
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
92
|
+
* BYO override → managed; otherwise BYO.
|
|
93
93
|
*/
|
|
94
94
|
export declare function shouldUseManaged(opts?: ResolveShareBackendOpts): boolean;
|
|
95
95
|
export declare function resolveShareBackend(opts?: ResolveShareBackendOpts): ShareBackend;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* - `AGENTS_SHARE_BACKEND=byo`
|
|
19
19
|
*/
|
|
20
20
|
import { PHOENIX_ID_BASE, readSession } from '../identity/client.js';
|
|
21
|
+
import { selectStorageBackendKind } from '../storage/selection.js';
|
|
21
22
|
import { DEFAULT_SHARE_DOMAIN, readShareConfig, readWriteToken, readWriteTokenEnv, } from './config.js';
|
|
22
23
|
/** Env var that forces the BYO path. Value must be exactly `byo`. */
|
|
23
24
|
export const SHARE_BACKEND_ENV = 'AGENTS_SHARE_BACKEND';
|
|
@@ -82,18 +83,28 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
|
|
|
82
83
|
return base;
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
86
|
+
* The share surface's BYO-override signals: an explicit `--byo`, a
|
|
87
|
+
* caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
|
|
88
|
+
* WHICH signals count is surface-specific; the managed-vs-BYO decision itself is
|
|
89
|
+
* the shared policy (`selectStorageBackendKind`). A persisted BYO endpoint config
|
|
90
|
+
* is deliberately NOT an override here — a signed-in user with a stale BYO config
|
|
91
|
+
* still publishes to managed unless they opt out explicitly (the product's
|
|
92
|
+
* managed-first contract).
|
|
87
93
|
*/
|
|
88
|
-
|
|
94
|
+
function shareByoOverride(opts) {
|
|
89
95
|
if (opts.byo === true)
|
|
90
|
-
return
|
|
96
|
+
return true;
|
|
91
97
|
if (opts.writeToken)
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
return true;
|
|
99
|
+
return (process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
103
|
+
* BYO override → managed; otherwise BYO.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldUseManaged(opts = {}) {
|
|
106
|
+
return (selectStorageBackendKind({ byoOverride: shareByoOverride(opts), session: opts.session }) ===
|
|
107
|
+
'managed');
|
|
97
108
|
}
|
|
98
109
|
export function resolveShareBackend(opts = {}) {
|
|
99
110
|
if (shouldUseManaged(opts)) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type ShareConfig } from './config.js';
|
|
2
|
-
/** DI seam for tests — override the real HTTP DELETE.
|
|
2
|
+
/** DI seam for tests — override the real HTTP DELETE. `body`/`retryAfter` carry
|
|
3
|
+
* the Worker's error response (read only on `!ok`) so a failed delete surfaces
|
|
4
|
+
* WHY, via the bounded {@link extractShareHttpError}. */
|
|
3
5
|
export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
|
|
4
6
|
ok: boolean;
|
|
5
7
|
status: number;
|
|
8
|
+
body?: string;
|
|
9
|
+
retryAfter?: string;
|
|
6
10
|
}>;
|
|
7
11
|
/** DI seam for tests — override the real HTTP existence check (HEAD). */
|
|
8
12
|
export type CheckFn = (url: string) => Promise<{
|
package/dist/lib/share/delete.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// the operation is reported as successful.
|
|
10
10
|
import { resolveShareBackend } from './backend.js';
|
|
11
11
|
import { buildShareKey, resolveShareUsername } from './publish.js';
|
|
12
|
+
import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
|
|
12
13
|
/**
|
|
13
14
|
* Normalize any of the three accepted target forms to the R2 key that publish
|
|
14
15
|
* would have written:
|
|
@@ -59,7 +60,10 @@ async function defaultCheck(url) {
|
|
|
59
60
|
}
|
|
60
61
|
async function defaultDelete(url, headers) {
|
|
61
62
|
const res = await fetch(url, { method: 'DELETE', headers });
|
|
62
|
-
|
|
63
|
+
if (res.ok)
|
|
64
|
+
return { ok: true, status: res.status };
|
|
65
|
+
const body = await res.text().catch(() => undefined);
|
|
66
|
+
return { ok: false, status: res.status, body, retryAfter: res.headers.get('retry-after') ?? undefined };
|
|
63
67
|
}
|
|
64
68
|
async function defaultRevisionsFetch(url) {
|
|
65
69
|
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
@@ -103,7 +107,8 @@ export async function deleteObject(endpoint, key, opts = {}) {
|
|
|
103
107
|
const existedBefore = before.status !== 404;
|
|
104
108
|
const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
|
|
105
109
|
if (!r.ok) {
|
|
106
|
-
|
|
110
|
+
const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
|
|
111
|
+
throw new Error(`Delete failed (${r.status}) for ${url}${detail}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
|
|
107
112
|
}
|
|
108
113
|
const after = await check(url);
|
|
109
114
|
const verified404 = after.status === 404;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE bounded extractor for the share Worker's error responses.
|
|
3
|
+
*
|
|
4
|
+
* Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
|
|
5
|
+
* previously threw on `!ok` with only the status code, discarding the JSON error
|
|
6
|
+
* body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
|
|
7
|
+
* `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
|
|
8
|
+
* surfaced as a bare "Publish failed (429)" with no reason. This pulls the
|
|
9
|
+
* server's own `error` string and preserves the status + `Retry-After`, so the
|
|
10
|
+
* user sees why.
|
|
11
|
+
*
|
|
12
|
+
* Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
|
|
13
|
+
* of the body, only lifts a string `error` field, caps its length, and NEVER
|
|
14
|
+
* surfaces an arbitrary body — a non-JSON or oversized body yields no message,
|
|
15
|
+
* so an HTML error page or a huge payload can't leak into a CLI error string.
|
|
16
|
+
*/
|
|
17
|
+
/** The raw pieces a caller's fetch wrapper hands in — status plus the (optional)
|
|
18
|
+
* response body text and `Retry-After` header value. */
|
|
19
|
+
export interface ShareHttpResponse {
|
|
20
|
+
status: number;
|
|
21
|
+
/** The response body text, when the caller read it (only on `!ok` paths). */
|
|
22
|
+
body?: string;
|
|
23
|
+
/** The `Retry-After` header value, when present (a 429 carries it). */
|
|
24
|
+
retryAfter?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The extracted, bounded error facts. `serverMessage` is present only when the
|
|
27
|
+
* body parsed as JSON carrying a non-empty string `error`. */
|
|
28
|
+
export interface ShareHttpError {
|
|
29
|
+
status: number;
|
|
30
|
+
serverMessage?: string;
|
|
31
|
+
retryAfter?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
|
|
34
|
+
* is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
|
|
35
|
+
export declare const MAX_ERROR_BODY_BYTES = 4096;
|
|
36
|
+
/** Cap the surfaced message so even a pathological (but valid-JSON) `error`
|
|
37
|
+
* string can't blow up the CLI output. */
|
|
38
|
+
export declare const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
39
|
+
/**
|
|
40
|
+
* Extract the bounded error facts from a share Worker response. Pulls a string
|
|
41
|
+
* `error` field from a small JSON body, preserves status + `Retry-After`, and
|
|
42
|
+
* never surfaces an arbitrary body.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractShareHttpError(res: ShareHttpResponse): ShareHttpError;
|
|
45
|
+
/**
|
|
46
|
+
* A one-line suffix summarizing the server's structured error, or `''` when the
|
|
47
|
+
* response carried nothing extractable. Appended after the status-bearing prefix
|
|
48
|
+
* a caller already builds, e.g.:
|
|
49
|
+
* `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
|
|
50
|
+
* → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatShareHttpErrorDetail(err: ShareHttpError): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE bounded extractor for the share Worker's error responses.
|
|
3
|
+
*
|
|
4
|
+
* Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
|
|
5
|
+
* previously threw on `!ok` with only the status code, discarding the JSON error
|
|
6
|
+
* body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
|
|
7
|
+
* `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
|
|
8
|
+
* surfaced as a bare "Publish failed (429)" with no reason. This pulls the
|
|
9
|
+
* server's own `error` string and preserves the status + `Retry-After`, so the
|
|
10
|
+
* user sees why.
|
|
11
|
+
*
|
|
12
|
+
* Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
|
|
13
|
+
* of the body, only lifts a string `error` field, caps its length, and NEVER
|
|
14
|
+
* surfaces an arbitrary body — a non-JSON or oversized body yields no message,
|
|
15
|
+
* so an HTML error page or a huge payload can't leak into a CLI error string.
|
|
16
|
+
*/
|
|
17
|
+
/** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
|
|
18
|
+
* is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
|
|
19
|
+
export const MAX_ERROR_BODY_BYTES = 4096;
|
|
20
|
+
/** Cap the surfaced message so even a pathological (but valid-JSON) `error`
|
|
21
|
+
* string can't blow up the CLI output. */
|
|
22
|
+
export const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
23
|
+
/**
|
|
24
|
+
* Extract the bounded error facts from a share Worker response. Pulls a string
|
|
25
|
+
* `error` field from a small JSON body, preserves status + `Retry-After`, and
|
|
26
|
+
* never surfaces an arbitrary body.
|
|
27
|
+
*/
|
|
28
|
+
export function extractShareHttpError(res) {
|
|
29
|
+
const out = { status: res.status };
|
|
30
|
+
const retryAfter = res.retryAfter?.trim();
|
|
31
|
+
if (retryAfter)
|
|
32
|
+
out.retryAfter = retryAfter;
|
|
33
|
+
const raw = res.body;
|
|
34
|
+
if (raw && raw.length <= MAX_ERROR_BODY_BYTES) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
38
|
+
const err = parsed.error;
|
|
39
|
+
if (typeof err === 'string' && err.trim()) {
|
|
40
|
+
out.serverMessage = err.trim().slice(0, MAX_ERROR_MESSAGE_CHARS);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not JSON (an HTML error page, a proxy body, truncated bytes) — deliberately
|
|
46
|
+
// surface nothing rather than dump an arbitrary body into the CLI error.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A one-line suffix summarizing the server's structured error, or `''` when the
|
|
53
|
+
* response carried nothing extractable. Appended after the status-bearing prefix
|
|
54
|
+
* a caller already builds, e.g.:
|
|
55
|
+
* `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
|
|
56
|
+
* → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
|
|
57
|
+
*/
|
|
58
|
+
export function formatShareHttpErrorDetail(err) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (err.serverMessage)
|
|
61
|
+
parts.push(err.serverMessage);
|
|
62
|
+
if (err.retryAfter)
|
|
63
|
+
parts.push(`retry after ${err.retryAfter}s`);
|
|
64
|
+
return parts.length ? ` — ${parts.join('; ')}` : '';
|
|
65
|
+
}
|