@phnx-labs/agents-cli 1.22.117 → 1.22.118
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 +235 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -1
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup.js +9 -9
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +6 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +0 -11
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/harness-auth-capabilities.js +0 -1
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.js +9 -7
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +12 -1
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +5 -10
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +10 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- package/dist/lib/fleet-shared-repo-sync.js +0 -490
|
@@ -18,7 +18,7 @@ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from './devices/k
|
|
|
18
18
|
import { machineId, normalizeHost } from './session/sync/config.js';
|
|
19
19
|
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from './fleet-shared-state.js';
|
|
20
20
|
import { getCacheDir, getUserAgentsDir, readMeta } from './state.js';
|
|
21
|
-
import { listNativeAccounts, readSlots } from './account-registry.js';
|
|
21
|
+
import { dropSlots, listNativeAccounts, readSlots } from './account-registry.js';
|
|
22
22
|
import { claudeAccountTokenKey, isClaudeWorkerHomeSeeded, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
|
|
23
23
|
import { configuredDeviceRole, isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
|
|
24
24
|
import { AUTH_STORE_ALIAS, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, } from './reserved-stores.js';
|
|
@@ -245,6 +245,26 @@ export async function syncReservedAuthBundle(deps = {}) {
|
|
|
245
245
|
// The functions below fix both. The plan is per ACCOUNT and per KEY
|
|
246
246
|
// (`<ENV>_<accountId>`), not per bundle, and targets `role=worker` peers only.
|
|
247
247
|
//
|
|
248
|
+
// PRESENCE IS FIRST-HAND + ROTATION-AWARE (PHNX-4116). Each device's account-
|
|
249
|
+
// state daemon publishes a per-account verdict row into `accounts.rows`
|
|
250
|
+
// (`account-state-daemon-service.ts`), and the usage-sync SSH exchange stores
|
|
251
|
+
// each peer's reply at `devices/<peer>/daemon-state.json`. A key counts as
|
|
252
|
+
// present on a peer only when BOTH hold:
|
|
253
|
+
// 1. FIRST-HAND — the peer's own reply reports a NON-`missing` verdict for the
|
|
254
|
+
// account (its slot materialized). This is what a publisher-side memo alone
|
|
255
|
+
// could never see: a key removed on the worker out of band flips the next
|
|
256
|
+
// verdict to `missing`, so the push resumes within a tick.
|
|
257
|
+
// 2. ROTATION — the fingerprint (`workerCredential.mintedAt`) we last delivered
|
|
258
|
+
// to that peer matches the account's CURRENT fingerprint. A re-mint
|
|
259
|
+
// (`accounts login`) rotates the reserved key and bumps `mintedAt` while the
|
|
260
|
+
// peer's OLD token keeps authenticating, so its verdict stays non-`missing`;
|
|
261
|
+
// verdict alone would read `present` forever and the rotated key would never
|
|
262
|
+
// reach the worker. The delivery memo is a LOCAL rotation cursor keyed per
|
|
263
|
+
// (peer, bundle, key) → fingerprint, never synced, never the primary signal.
|
|
264
|
+
// A peer reply with no `accounts.rows` field at all (an older CLI) is not proof
|
|
265
|
+
// of an empty inventory, so the plan FAILS CLOSED and skips it (INFO), rather
|
|
266
|
+
// than treating "no rows" as "holds nothing" and pushing every key blindly.
|
|
267
|
+
//
|
|
248
268
|
// INVARIANT 1 (transport, retain nothing): materializing a worker slot happens
|
|
249
269
|
// on the box where the key LANDED (`reconcileLocalWorkerSlots` ->
|
|
250
270
|
// `provisionWorkerSlot`); the credential itself only ever moves over the
|
|
@@ -254,10 +274,34 @@ export async function syncReservedAuthBundle(deps = {}) {
|
|
|
254
274
|
// `isCredentialSafeToPropagate` stays false).
|
|
255
275
|
// ---------------------------------------------------------------------------
|
|
256
276
|
const EMPTY_KEY_SET = new Set();
|
|
277
|
+
/**
|
|
278
|
+
* Skip reason for a peer that has never sent a daemon-state reply (no
|
|
279
|
+
* `devices/<peer>/daemon-state.json`). auth-sync logs these at INFO — a
|
|
280
|
+
* brand-new or never-dialed worker legitimately has no reply on an early tick,
|
|
281
|
+
* so it is informational, not a warning. Stable so the service classifies the
|
|
282
|
+
* skip without matching a substring.
|
|
283
|
+
*/
|
|
284
|
+
export const SKIP_REASON_NO_PEER_REPLY = 'no daemon-state reply from this peer yet';
|
|
285
|
+
/**
|
|
286
|
+
* Skip reason for a peer whose reply exists but carries no `accounts.rows` field
|
|
287
|
+
* at all (an older CLI that does not publish per-account verdicts, or a partial
|
|
288
|
+
* state written before the account-state daemon ran). Without those rows there is
|
|
289
|
+
* no first-hand knowledge of what the peer holds, so the plan FAILS CLOSED and
|
|
290
|
+
* skips rather than pushing every key blindly. auth-sync logs it at INFO — it is
|
|
291
|
+
* transient during a rolling upgrade. An EMPTY `accounts.rows` array (a peer with
|
|
292
|
+
* no registered account) is NOT this case: it is a legitimate "holds nothing",
|
|
293
|
+
* and its keys are (correctly) pushed to provision it.
|
|
294
|
+
*/
|
|
295
|
+
export const SKIP_REASON_NO_ACCOUNT_ROWS = 'daemon-state reply carries no account rows (fail closed)';
|
|
257
296
|
/**
|
|
258
297
|
* Pure plan: for each worker peer, push each bundle it is missing at least one
|
|
259
298
|
* key of. Deterministic (peers and bundles sorted) so it pins exactly in tests.
|
|
260
|
-
* A headed peer is skipped BEFORE any key comparison -- it never receives a key
|
|
299
|
+
* A headed peer is skipped BEFORE any key comparison -- it never receives a key;
|
|
300
|
+
* a peer with no reply yet (`hasReply: false`) is skipped next -- there is no
|
|
301
|
+
* first-hand knowledge of what it holds, so nothing is pushed to it this tick;
|
|
302
|
+
* a peer whose reply carries no `accounts.rows` field (`hasAccountRows: false`)
|
|
303
|
+
* is skipped FAIL-CLOSED for the same reason (an older CLI's reply is not proof
|
|
304
|
+
* of an empty inventory), rather than pushing every key blindly.
|
|
261
305
|
*/
|
|
262
306
|
export function planReservedStoreSync(accounts, peers) {
|
|
263
307
|
const keysByBundle = new Map();
|
|
@@ -274,6 +318,14 @@ export function planReservedStoreSync(accounts, peers) {
|
|
|
274
318
|
items.push({ action: 'skip', device: peer.name, reason: 'headed device receives the account row, never a durable key' });
|
|
275
319
|
continue;
|
|
276
320
|
}
|
|
321
|
+
if (!peer.hasReply) {
|
|
322
|
+
items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_PEER_REPLY });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!peer.hasAccountRows) {
|
|
326
|
+
items.push({ action: 'skip', device: peer.name, reason: SKIP_REASON_NO_ACCOUNT_ROWS });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
277
329
|
if (!peer.reachable) {
|
|
278
330
|
items.push({ action: 'skip', device: peer.name, reason: 'unreachable' });
|
|
279
331
|
continue;
|
|
@@ -318,14 +370,44 @@ export function reservedSyncTargets(meta) {
|
|
|
318
370
|
}
|
|
319
371
|
return out;
|
|
320
372
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
373
|
+
/** Narrow a peer's opaque `accounts.rows` to the verdict fields the planner reads. */
|
|
374
|
+
export function readPeerAccountVerdicts(state) {
|
|
375
|
+
const rows = state?.accounts?.rows;
|
|
376
|
+
if (!Array.isArray(rows))
|
|
377
|
+
return [];
|
|
378
|
+
const out = [];
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
if (!row || typeof row !== 'object' || Array.isArray(row))
|
|
381
|
+
continue;
|
|
382
|
+
const r = row;
|
|
383
|
+
if (typeof r.accountId === 'string' && typeof r.harness === 'string' && typeof r.verdict === 'string') {
|
|
384
|
+
out.push({ accountId: r.accountId, harness: r.harness, verdict: r.verdict });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* True when the peer's reply carried an `accounts.rows` array at all (present,
|
|
391
|
+
* even if empty). An older CLI, or a partial state written before the account-
|
|
392
|
+
* state daemon ran, has no such field — and its absence must NOT read as "the
|
|
393
|
+
* peer holds nothing", or the plan would push every key blindly. An EMPTY array
|
|
394
|
+
* (a peer with no registered account) IS a valid first-hand "holds nothing".
|
|
395
|
+
*/
|
|
396
|
+
export function peerHasAccountRows(state) {
|
|
397
|
+
return Array.isArray(state?.accounts?.rows);
|
|
398
|
+
}
|
|
399
|
+
// --- publisher-side delivery memo (rotation) -------------------------------
|
|
400
|
+
// A peer's verdict says WHETHER it holds a working credential for an account, but
|
|
401
|
+
// not WHICH one: a re-mint (`accounts login`) rotates the reserved key and bumps
|
|
402
|
+
// `workerCredential.mintedAt`, yet the peer's OLD token keeps authenticating, so
|
|
403
|
+
// its verdict stays `live`/`unverified` and never flips to `missing`. Verdict
|
|
404
|
+
// alone would therefore read `present` forever and the new key would never reach
|
|
405
|
+
// the worker. The publisher records the fingerprint it last delivered per
|
|
406
|
+
// (peer, bundle, key); presence requires BOTH the peer's own verdict AND a
|
|
407
|
+
// delivered-fingerprint match, so a re-mint (fingerprint change) re-pushes within
|
|
408
|
+
// a tick while a key removed on the worker (verdict `missing`) also re-pushes.
|
|
409
|
+
// LOCAL publisher bookkeeping, never synced — it is a rotation cursor, not a
|
|
410
|
+
// substitute for the first-hand verdict.
|
|
329
411
|
function deliveryMemoPath(root = getCacheDir()) {
|
|
330
412
|
return path.join(root, 'reserved-sync-delivered.json');
|
|
331
413
|
}
|
|
@@ -346,18 +428,29 @@ function writeDeliveryMemo(memo, root = getCacheDir()) {
|
|
|
346
428
|
fs.writeFileSync(deliveryMemoPath(root), `${JSON.stringify(memo, null, 2)}\n`, 'utf-8');
|
|
347
429
|
}
|
|
348
430
|
/**
|
|
349
|
-
* The keys a peer is KNOWN to hold, per bundle
|
|
350
|
-
* (
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
431
|
+
* The reserved keys a peer is KNOWN to hold, per bundle. Two conditions must BOTH
|
|
432
|
+
* hold (PHNX-4116):
|
|
433
|
+
*
|
|
434
|
+
* 1. FIRST-HAND — the peer's own daemon-state reply reports a NON-`missing`
|
|
435
|
+
* verdict for the account, so it has a working credential for it (its slot
|
|
436
|
+
* materialized). A `missing` verdict, or no row for the account at all, means
|
|
437
|
+
* it does not — self-correcting: a key removed on the worker flips its next
|
|
438
|
+
* verdict to `missing` and the push resumes, which a publisher-side memo alone
|
|
439
|
+
* could never see.
|
|
440
|
+
* 2. ROTATION — the fingerprint we last delivered to this peer (`delivered`)
|
|
441
|
+
* matches the account's CURRENT fingerprint. A re-mint bumps
|
|
442
|
+
* `workerCredential.mintedAt` while the old token still authenticates, so the
|
|
443
|
+
* verdict stays non-`missing`; without this the rotated key would never
|
|
444
|
+
* propagate. A never-delivered key (no memo entry ⇒ `undefined`) never
|
|
445
|
+
* matches, so a newly-added account still pushes.
|
|
354
446
|
*/
|
|
355
|
-
export function peerPresentKeys(
|
|
447
|
+
export function peerPresentKeys(accounts, verdicts, delivered) {
|
|
448
|
+
const held = new Set(verdicts.filter((v) => v.verdict !== 'missing').map((v) => `${v.harness}:${v.accountId}`));
|
|
356
449
|
const present = {};
|
|
357
450
|
for (const account of accounts) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
451
|
+
if (!held.has(`${account.harness}:${account.accountId}`))
|
|
452
|
+
continue;
|
|
453
|
+
if (delivered(account.bundle, account.key) !== account.fingerprint)
|
|
361
454
|
continue;
|
|
362
455
|
(present[account.bundle] ??= new Set()).add(account.key);
|
|
363
456
|
}
|
|
@@ -425,13 +518,18 @@ export async function syncReservedStores(deps = {}) {
|
|
|
425
518
|
}
|
|
426
519
|
const memo = readDeliveryMemo(deps.cacheDir);
|
|
427
520
|
const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
|
|
428
|
-
const peers = devices.map((d) =>
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
521
|
+
const peers = devices.map((d) => {
|
|
522
|
+
const state = stateByDevice.get(normalizeHost(d.name));
|
|
523
|
+
return {
|
|
524
|
+
name: d.name,
|
|
525
|
+
headed: isHeadedDeviceRole(peerRole(d.name)),
|
|
526
|
+
reachable: isDialableDevice(d),
|
|
527
|
+
pinned: isDevicePinned(d, pinned),
|
|
528
|
+
hasReply: state !== undefined,
|
|
529
|
+
hasAccountRows: peerHasAccountRows(state),
|
|
530
|
+
presentKeys: peerPresentKeys(targets, readPeerAccountVerdicts(state), (bundle, key) => memo[memoKey(d.name, bundle, key)]),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
435
533
|
const plan = planReservedStoreSync(targets, peers);
|
|
436
534
|
const byName = new Map(devices.map((d) => [d.name, d]));
|
|
437
535
|
const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
|
|
@@ -452,6 +550,9 @@ export async function syncReservedStores(deps = {}) {
|
|
|
452
550
|
const out = await push(item.bundle, sshTarget(profile));
|
|
453
551
|
if (out.ok) {
|
|
454
552
|
result.pushed.push({ device: item.device, bundle: item.bundle, keys: item.keys });
|
|
553
|
+
// Record the fingerprint delivered so an UNCHANGED key is not re-pushed
|
|
554
|
+
// next tick; a re-mint (new fingerprint) or a removed key (verdict flips
|
|
555
|
+
// `missing`) still re-pushes.
|
|
455
556
|
for (const key of item.keys) {
|
|
456
557
|
const fp = targets.find((t) => t.bundle === item.bundle && t.key === key)?.fingerprint;
|
|
457
558
|
if (fp)
|
|
@@ -469,6 +570,15 @@ export async function syncReservedStores(deps = {}) {
|
|
|
469
570
|
writeDeliveryMemo(memo, deps.cacheDir ?? getCacheDir());
|
|
470
571
|
return result;
|
|
471
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Fire-and-forget the daemon log without pulling its module graph into every
|
|
575
|
+
* consumer of this file: `reconcileLocalWorkerSlots` is synchronous and only
|
|
576
|
+
* runs inside the daemon (where daemon.js is already loaded), so the dynamic
|
|
577
|
+
* import is paid only on the rare tick that actually drops a slot.
|
|
578
|
+
*/
|
|
579
|
+
function defaultWorkerSlotLog(level, message) {
|
|
580
|
+
void import('./daemon/daemon.js').then((m) => m.log(level, message)).catch(() => { });
|
|
581
|
+
}
|
|
472
582
|
function defaultSlotSeeded(harness, slotDir) {
|
|
473
583
|
return harness === 'claude' ? isClaudeWorkerHomeSeeded(slotDir) : true;
|
|
474
584
|
}
|
|
@@ -482,8 +592,10 @@ function defaultSlotSeeded(harness, slotDir) {
|
|
|
482
592
|
* seeded (no `hasCompletedOnboarding`) is provisioned again so it converges.
|
|
483
593
|
*/
|
|
484
594
|
export function reconcileLocalWorkerSlots(deps = {}) {
|
|
485
|
-
const result = { provisioned: [], skipped: [], errors: [] };
|
|
486
|
-
|
|
595
|
+
const result = { provisioned: [], dropped: [], skipped: [], errors: [] };
|
|
596
|
+
// `'selfRole' in deps` (not `??`) so a caller can inject an explicit `undefined`
|
|
597
|
+
// to mean "unmarked device"; a caller that omits it reads the real machine role.
|
|
598
|
+
const role = 'selfRole' in deps ? deps.selfRole : selfConfiguredDeviceRole();
|
|
487
599
|
if (isHeadedDeviceRole(role))
|
|
488
600
|
return result; // headed boxes provision via native login
|
|
489
601
|
const meta = (deps.readMetaFn ?? readMeta)();
|
|
@@ -492,6 +604,41 @@ export function reconcileLocalWorkerSlots(deps = {}) {
|
|
|
492
604
|
const provision = deps.provision ?? provisionWorkerSlot;
|
|
493
605
|
const slotSeeded = deps.slotSeeded ?? defaultSlotSeeded;
|
|
494
606
|
const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
|
|
607
|
+
// Drop stale worker slots (PHNX-4116). A `deviceAccounts.slots` record whose
|
|
608
|
+
// accountId is no longer a registered native account is a leftover from an
|
|
609
|
+
// older account-id generation — every worker held 8 such duplicates, each a
|
|
610
|
+
// full HOME carrying a live setup-token in `.claude/.oauth_token`. Delete that
|
|
611
|
+
// credential (a 0600 file) and drop the record, but KEEP the slot directory:
|
|
612
|
+
// `.claude/projects` holds session transcripts. Fail closed — an empty
|
|
613
|
+
// registry is a transient read, and stripping every slot on it would wipe a
|
|
614
|
+
// healthy box's whole slot set, so drop nothing until the registry answers.
|
|
615
|
+
// This deletion runs ONLY on a device explicitly marked `worker`: provisioning
|
|
616
|
+
// above is safe on an unmarked box, but destroying a credential is not — an
|
|
617
|
+
// unmarked device is not headed (`isHeadedDeviceRole(undefined)` is false) yet
|
|
618
|
+
// must never run a credential-deleting branch.
|
|
619
|
+
if (role === 'worker' && byId.size > 0) {
|
|
620
|
+
const stale = Object.values(slots).filter((slot) => !byId.has(slot.accountId));
|
|
621
|
+
const droppable = [];
|
|
622
|
+
for (const slot of stale) {
|
|
623
|
+
try {
|
|
624
|
+
// force ignores an already-absent token; a real failure (EACCES) keeps
|
|
625
|
+
// the slot so the record is never dropped while its credential lingers.
|
|
626
|
+
fs.rmSync(path.join(slot.slotDir, '.claude', '.oauth_token'), { force: true });
|
|
627
|
+
droppable.push(slot);
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
result.errors.push({ accountId: slot.accountId, message: `stale slot credential not removed, slot kept: ${err.message}` });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (droppable.length > 0) {
|
|
634
|
+
(deps.dropSlots ?? dropSlots)(droppable.map((slot) => slot.accountId));
|
|
635
|
+
const log = deps.log ?? defaultWorkerSlotLog;
|
|
636
|
+
for (const slot of droppable) {
|
|
637
|
+
result.dropped.push(slot.accountId);
|
|
638
|
+
log('INFO', `worker-slot: dropped stale slot ${slot.accountId} — no longer a registered account; removed its credential, kept ${slot.slotDir} for transcripts`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
495
642
|
// Resolve each account to the one (bundle, key) the push plan uses: a T1 row's
|
|
496
643
|
// reserved `__<harness>__` key, or the legacy `auth` key by email for a claude
|
|
497
644
|
// row predating T1. Both are worker credentials this box may already hold, so
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
|
|
|
14
14
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
15
15
|
* assert against the constant instead of hardcoding a number that every bump
|
|
16
16
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
17
|
-
export declare const SCHEMA_VERSION =
|
|
17
|
+
export declare const SCHEMA_VERSION = 51;
|
|
18
18
|
/**
|
|
19
19
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
20
20
|
* user-prompt text every harness already accumulates) to re-derive on every
|
package/dist/lib/session/db.js
CHANGED
|
@@ -29,7 +29,7 @@ const DB_PATH = getSessionsDbPath();
|
|
|
29
29
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
30
30
|
* assert against the constant instead of hardcoding a number that every bump
|
|
31
31
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
32
|
-
export const SCHEMA_VERSION =
|
|
32
|
+
export const SCHEMA_VERSION = 51;
|
|
33
33
|
/**
|
|
34
34
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
35
35
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -182,6 +182,10 @@ CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
|
|
|
182
182
|
-- v17 guarantees the machine column exists (same pattern as last_activity);
|
|
183
183
|
-- idx_sessions_mirror_synced likewise waits for migration v46's column add.
|
|
184
184
|
|
|
185
|
+
-- A row sits at the rowid of the sessions row it describes (v51) and is
|
|
186
|
+
-- addressed by that rowid, never by session_id: an UNINDEXED FTS5 column cannot
|
|
187
|
+
-- be seeked, so a session_id predicate scans every row's content — the same
|
|
188
|
+
-- rule tool_call_text follows against tool_calls.rowid (v36).
|
|
185
189
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
186
190
|
session_id UNINDEXED,
|
|
187
191
|
label,
|
|
@@ -1411,6 +1415,39 @@ function migrateSchema(db, fromVersion) {
|
|
|
1411
1415
|
if (!cols.has('generated_title_at'))
|
|
1412
1416
|
db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_at INTEGER`);
|
|
1413
1417
|
}
|
|
1418
|
+
if (fromVersion < 51) {
|
|
1419
|
+
// v50 -> v51: session_text rows move to the rowid of the sessions row they
|
|
1420
|
+
// describe (PHNX-4154), the repair v36 made to tool_call_text. The only key
|
|
1421
|
+
// was the UNINDEXED session_id, so every delete and content read was a full
|
|
1422
|
+
// scan of the FTS content — 669 MB and 0.2-0.5 s per statement on a real
|
|
1423
|
+
// box — and the mirror ingest issues one per mirrored row (2,298 there)
|
|
1424
|
+
// inside a daemon tick: the event loop froze for minutes, every service
|
|
1425
|
+
// blew its deadline, and browser verbs timed out behind it.
|
|
1426
|
+
//
|
|
1427
|
+
// Same shape as v42: rename, recreate, copy back keyed by sessions.rowid,
|
|
1428
|
+
// drop — search never blacks out. OR REPLACE keeps the newest of any
|
|
1429
|
+
// duplicate session_id rows. A text row whose session is gone has no rowid
|
|
1430
|
+
// to sit at and is dropped; nothing could reach it anyway.
|
|
1431
|
+
db.exec(`ALTER TABLE session_text RENAME TO session_text_v50`);
|
|
1432
|
+
db.exec(`
|
|
1433
|
+
CREATE VIRTUAL TABLE session_text USING fts5(
|
|
1434
|
+
session_id UNINDEXED,
|
|
1435
|
+
label,
|
|
1436
|
+
topic,
|
|
1437
|
+
project,
|
|
1438
|
+
content,
|
|
1439
|
+
assistant,
|
|
1440
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
1441
|
+
);
|
|
1442
|
+
`);
|
|
1443
|
+
db.exec(`
|
|
1444
|
+
INSERT OR REPLACE INTO session_text (rowid, session_id, label, topic, project, content, assistant)
|
|
1445
|
+
SELECT s.rowid, t.session_id, t.label, t.topic, t.project, t.content, t.assistant
|
|
1446
|
+
FROM session_text_v50 t JOIN sessions s ON s.id = t.session_id
|
|
1447
|
+
ORDER BY t.rowid
|
|
1448
|
+
`);
|
|
1449
|
+
db.exec(`DROP TABLE session_text_v50`);
|
|
1450
|
+
}
|
|
1414
1451
|
}
|
|
1415
1452
|
/**
|
|
1416
1453
|
* Stamp `account_key` / `account_org` / `account` on every Claude row from its
|
|
@@ -2292,8 +2329,15 @@ function enrichCachedSessionMeta(meta) {
|
|
|
2292
2329
|
return meta;
|
|
2293
2330
|
}
|
|
2294
2331
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2332
|
+
/**
|
|
2333
|
+
* The one way to address a session's FTS row: its rowid is the sessions rowid
|
|
2334
|
+
* (schema comment on session_text). A `session_id = ?` predicate scans the whole
|
|
2335
|
+
* index instead — the PHNX-4154 daemon freeze.
|
|
2336
|
+
*/
|
|
2337
|
+
const SESSION_TEXT_ROWID = `(SELECT rowid FROM sessions WHERE id = ?)`;
|
|
2338
|
+
const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
2339
|
+
// Binds the session id twice: once for the rowid lookup, once for the column.
|
|
2340
|
+
const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (rowid, session_id, label, topic, project, content, assistant) VALUES (${SESSION_TEXT_ROWID}, ?, ?, ?, ?, ?, ?)`);
|
|
2297
2341
|
// Read back the label the upsert actually stored (which may be the preserved
|
|
2298
2342
|
// one, not the incoming blank) so the FTS label column stays consistent with
|
|
2299
2343
|
// sessions.label after a bare rescan.
|
|
@@ -2406,7 +2450,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
|
|
|
2406
2450
|
const txn = db.transaction(() => {
|
|
2407
2451
|
upsert.run(row);
|
|
2408
2452
|
delText.run(meta.id);
|
|
2409
|
-
insText.run(meta.id,
|
|
2453
|
+
insText.run(meta.id, meta.id,
|
|
2410
2454
|
// Use the label the upsert actually stored (preserve-non-empty rule),
|
|
2411
2455
|
// not the raw incoming one, so FTS label ranking survives a bare rescan.
|
|
2412
2456
|
storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
|
|
@@ -2427,8 +2471,10 @@ function reconcileCodexFileOwners(db, metas) {
|
|
|
2427
2471
|
for (const row of rows) {
|
|
2428
2472
|
if (owners.get(row.file_path) === row.id)
|
|
2429
2473
|
continue;
|
|
2474
|
+
// The text row is keyed by the sessions rowid, so it goes before the row.
|
|
2475
|
+
db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).run(row.id);
|
|
2430
2476
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
|
|
2431
|
-
for (const table of ['
|
|
2477
|
+
for (const table of ['session_preview_cache', 'session_summaries', 'session_insights', 'session_topics', 'session_phenotypes', 'session_resource_usage', 'resource_scan_ledger']) {
|
|
2432
2478
|
db.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(row.id);
|
|
2433
2479
|
}
|
|
2434
2480
|
// The tool ledger has a unique file path too; release the wrong binding
|
|
@@ -2671,7 +2717,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2671
2717
|
};
|
|
2672
2718
|
upsert.run(row);
|
|
2673
2719
|
delText.run(meta.id);
|
|
2674
|
-
insText.run(meta.id,
|
|
2720
|
+
insText.run(meta.id, meta.id,
|
|
2675
2721
|
// Mirror upsertSession: index the label the upsert actually stored
|
|
2676
2722
|
// (preserve-non-empty rule), not the raw incoming one.
|
|
2677
2723
|
storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
|
|
@@ -2746,7 +2792,7 @@ export function syncLabels(labelMap) {
|
|
|
2746
2792
|
if (updates.length === 0)
|
|
2747
2793
|
return 0;
|
|
2748
2794
|
const updSessions = db.prepare(`UPDATE sessions SET label = ? WHERE id = ?`);
|
|
2749
|
-
const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE
|
|
2795
|
+
const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
2750
2796
|
const txn = db.transaction((items) => {
|
|
2751
2797
|
for (const { id, label } of items) {
|
|
2752
2798
|
updSessions.run(label, id);
|
|
@@ -2797,7 +2843,7 @@ export function seedLabelsFromNames(nameMap) {
|
|
|
2797
2843
|
if (updates.length === 0)
|
|
2798
2844
|
return 0;
|
|
2799
2845
|
const updSessions = db.prepare(`UPDATE sessions SET label = ? WHERE id = ?`);
|
|
2800
|
-
const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE
|
|
2846
|
+
const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
2801
2847
|
const txn = db.transaction((items) => {
|
|
2802
2848
|
for (const { id, label } of items) {
|
|
2803
2849
|
updSessions.run(label, id);
|
|
@@ -2839,7 +2885,7 @@ export function syncTopics(topicMap) {
|
|
|
2839
2885
|
if (updates.length === 0)
|
|
2840
2886
|
return 0;
|
|
2841
2887
|
const updSessions = db.prepare(`UPDATE sessions SET topic = ? WHERE id = ?`);
|
|
2842
|
-
const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE
|
|
2888
|
+
const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
2843
2889
|
const txn = db.transaction((items) => {
|
|
2844
2890
|
for (const { id, topic } of items) {
|
|
2845
2891
|
updSessions.run(topic, id);
|
|
@@ -3231,7 +3277,7 @@ export function querySessions(options = {}) {
|
|
|
3231
3277
|
const missingIds = new Set(missing.map(r => r.id));
|
|
3232
3278
|
const phantomIds = new Set();
|
|
3233
3279
|
if (missing.length > 0) {
|
|
3234
|
-
const readContent = db.prepare(`SELECT content FROM session_text WHERE
|
|
3280
|
+
const readContent = db.prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
3235
3281
|
const markArchived = db.prepare(`UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL`);
|
|
3236
3282
|
const now = Date.now();
|
|
3237
3283
|
const classify = db.transaction(() => {
|
|
@@ -3706,7 +3752,7 @@ export function writeRemotePreviewCacheFailure(device, sessionId, reason, backof
|
|
|
3706
3752
|
* when no row exists; an empty string when a row exists but carried no content.
|
|
3707
3753
|
*/
|
|
3708
3754
|
export function readSessionContent(id) {
|
|
3709
|
-
const row = getDB().prepare(`SELECT content FROM session_text WHERE
|
|
3755
|
+
const row = getDB().prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).get(id);
|
|
3710
3756
|
return row?.content;
|
|
3711
3757
|
}
|
|
3712
3758
|
/**
|
|
@@ -3964,11 +4010,11 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3964
4010
|
if (result.changes === 0)
|
|
3965
4011
|
return false;
|
|
3966
4012
|
// Keep the mirror row searchable by topic + first user turn, like a local row.
|
|
3967
|
-
db.prepare(`DELETE FROM session_text WHERE
|
|
4013
|
+
db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`).run(row.id);
|
|
3968
4014
|
db.prepare(`
|
|
3969
|
-
INSERT INTO session_text (session_id, label, topic, project, content, assistant)
|
|
3970
|
-
VALUES (?, ?, ?, '', ?, '')
|
|
3971
|
-
`).run(row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
|
|
4015
|
+
INSERT INTO session_text (rowid, session_id, label, topic, project, content, assistant)
|
|
4016
|
+
VALUES (${SESSION_TEXT_ROWID}, ?, ?, ?, '', ?, '')
|
|
4017
|
+
`).run(row.id, row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
|
|
3972
4018
|
// Carry the peer's daemon-computed summary into the same session_summaries
|
|
3973
4019
|
// cache the local merge reads (PHNX-3939), so a peer session renders its
|
|
3974
4020
|
// goal/checkpoints/checklist inline with no transcript. Stamp is null: the
|
|
@@ -4072,14 +4118,15 @@ export function pruneMirrorSessions(cutoffMs) {
|
|
|
4072
4118
|
if (stale.length === 0)
|
|
4073
4119
|
return 0;
|
|
4074
4120
|
const delRow = db.prepare(`DELETE FROM sessions WHERE id = ?`);
|
|
4075
|
-
const delText = db.prepare(`DELETE FROM session_text WHERE
|
|
4121
|
+
const delText = db.prepare(`DELETE FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
4076
4122
|
const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
|
|
4077
4123
|
const delSummary = db.prepare(`DELETE FROM session_summaries WHERE session_id = ?`);
|
|
4078
4124
|
const delTimeline = db.prepare(`DELETE FROM session_timelines WHERE session_id = ?`);
|
|
4079
4125
|
const txn = db.transaction(() => {
|
|
4080
4126
|
for (const { id } of stale) {
|
|
4081
|
-
|
|
4127
|
+
// Text first: its rowid lookup needs the sessions row still present.
|
|
4082
4128
|
delText.run(id);
|
|
4129
|
+
delRow.run(id);
|
|
4083
4130
|
delPreview.run(id);
|
|
4084
4131
|
delSummary.run(id);
|
|
4085
4132
|
delTimeline.run(id);
|
|
@@ -4461,7 +4508,7 @@ export function topSessionsByCost(n, options = {}) {
|
|
|
4461
4508
|
// still excluded. archived_at is the persisted signal; stamp it here too (once)
|
|
4462
4509
|
// so a session first surfaced through the cost rollup reports `archived`
|
|
4463
4510
|
// consistently with the listing path.
|
|
4464
|
-
const readContent = db.prepare(`SELECT content FROM session_text WHERE
|
|
4511
|
+
const readContent = db.prepare(`SELECT content FROM session_text WHERE rowid = ${SESSION_TEXT_ROWID}`);
|
|
4465
4512
|
const markArchived = db.prepare(`UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL`);
|
|
4466
4513
|
const now = Date.now();
|
|
4467
4514
|
const toStamp = [];
|