sealkeep 0.9.0 → 0.11.1
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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +332 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5940 -297
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/web/sessions-view.js
CHANGED
|
@@ -34,11 +34,108 @@
|
|
|
34
34
|
return `${Math.floor(days / 30)}mo ago`;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
const SESSION_CACHE_KEY = "sealkeep.sessionsView.v1";
|
|
38
|
+
const NO_PROJECT = "(none)";
|
|
37
39
|
let sessions = [];
|
|
40
|
+
let lastSessionData = null;
|
|
41
|
+
let lastLoadedAt = null;
|
|
42
|
+
let currentVaultId = null;
|
|
43
|
+
let hasStorageTarget = false;
|
|
44
|
+
let hasWritableStorageTarget = false;
|
|
45
|
+
let sessionEligibilityLive = false;
|
|
38
46
|
const chosen = new Set();
|
|
39
47
|
|
|
40
48
|
const DAY = 86400000;
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Keep the last successful disk walk in this browser, just as My vault keeps
|
|
52
|
+
* its last catalog. A /v1/sessions walk can cover thousands of transcripts;
|
|
53
|
+
* a transient failure must not turn that useful table into an empty one.
|
|
54
|
+
* The vault id travels with the snapshot so a different vault on this same
|
|
55
|
+
* local port never inherits another vault's paths.
|
|
56
|
+
*/
|
|
57
|
+
function readSessionCache(vaultId) {
|
|
58
|
+
try {
|
|
59
|
+
const cached = JSON.parse(localStorage.getItem(SESSION_CACHE_KEY) ?? "null");
|
|
60
|
+
if (cached?.version !== 1 || cached.vaultId !== vaultId || !cached.loadedAt
|
|
61
|
+
|| !cached.data || !Array.isArray(cached.data.sessions)) return null;
|
|
62
|
+
return cached;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeSessionCache(vaultId, data, loadedAt) {
|
|
69
|
+
if (!vaultId) return;
|
|
70
|
+
try {
|
|
71
|
+
localStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({ version: 1, vaultId, data, loadedAt }));
|
|
72
|
+
} catch {
|
|
73
|
+
// A disabled/full browser store costs only the warm reload. The live
|
|
74
|
+
// session view and its in-memory fallback continue to work.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const loadedAgo = (iso) => {
|
|
79
|
+
const delta = Date.now() - Date.parse(iso);
|
|
80
|
+
if (!Number.isFinite(delta)) return "earlier";
|
|
81
|
+
const minutes = Math.max(0, Math.round(delta / 60000));
|
|
82
|
+
if (minutes < 1) return "just now";
|
|
83
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
84
|
+
const hours = Math.round(minutes / 60);
|
|
85
|
+
if (hours < 24) return `${hours}h ago`;
|
|
86
|
+
const days = Math.round(hours / 24);
|
|
87
|
+
return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function summaryText(data) {
|
|
91
|
+
const rows = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
92
|
+
const ready = rows.filter((item) => !item.archived && !item.queued);
|
|
93
|
+
const queued = rows.filter((item) => !item.archived && item.queued);
|
|
94
|
+
const readyBytes = ready.reduce((total, item) => total + (Number(item.bytes) || 0), 0);
|
|
95
|
+
const queuedBytes = queued.reduce((total, item) => total + (Number(item.bytes) || 0), 0);
|
|
96
|
+
const readyText = ready.length
|
|
97
|
+
? `${bytes(readyBytes)} in ${ready.length} ready to archive`
|
|
98
|
+
: "none waiting for you to archive";
|
|
99
|
+
const queuedText = queued.length
|
|
100
|
+
? ` ${bytes(queuedBytes)} in ${queued.length} already queued to seal.`
|
|
101
|
+
: "";
|
|
102
|
+
return `${bytes(Number(data?.totalBytes) || 0)} across ${rows.length} sessions on disk. ${readyText}.${queuedText}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function setSessionSummary(data, state = "live", loadedAt = null) {
|
|
106
|
+
const summary = $("summary");
|
|
107
|
+
summary.className = state === "failed" ? "note error" : "note";
|
|
108
|
+
if (state === "updating") {
|
|
109
|
+
summary.textContent = `${summaryText(data)} Updating · showing data loaded ${loadedAgo(loadedAt)}.`;
|
|
110
|
+
} else if (state === "failed") {
|
|
111
|
+
summary.textContent = `${summaryText(data)} Could not update · showing data loaded ${loadedAgo(loadedAt)}.`;
|
|
112
|
+
} else {
|
|
113
|
+
summary.textContent = summaryText(data);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function acceptSessionData(data, loadedAt = new Date().toISOString(), eligibilityLive = true) {
|
|
118
|
+
if (!data || !Array.isArray(data.sessions)) throw new Error("Sessions response did not include a session list");
|
|
119
|
+
sessions = data.sessions;
|
|
120
|
+
sessionEligibilityLive = eligibilityLive;
|
|
121
|
+
lastSessionData = {
|
|
122
|
+
sessions: data.sessions,
|
|
123
|
+
totalBytes: Number(data.totalBytes) || 0,
|
|
124
|
+
unarchivedBytes: Number(data.unarchivedBytes) || 0
|
|
125
|
+
};
|
|
126
|
+
lastLoadedAt = loadedAt;
|
|
127
|
+
// Project chips are a projection of THIS answer, not permanent controls.
|
|
128
|
+
// A background refresh can discover a new project (or lose an old one),
|
|
129
|
+
// so reconcile them every time while preserving a still-valid selection.
|
|
130
|
+
buildProjectChips();
|
|
131
|
+
// A selection can go stale across a refresh: anything now archived or
|
|
132
|
+
// already queued must not be re-submitted.
|
|
133
|
+
for (const row of sessions) if (row.archived || row.queued) chosen.delete(row.path);
|
|
134
|
+
setSessionSummary(lastSessionData);
|
|
135
|
+
draw();
|
|
136
|
+
refreshChoice();
|
|
137
|
+
}
|
|
138
|
+
|
|
42
139
|
/**
|
|
43
140
|
* The rows that survive the current filters, in the chosen order.
|
|
44
141
|
*
|
|
@@ -50,6 +147,27 @@
|
|
|
50
147
|
/** The active chip in a group, or "" for none. */
|
|
51
148
|
const chip = (group) => $("chips").querySelector(`.chip.on[data-group="${group}"]`)?.dataset.value ?? "";
|
|
52
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A project label is presentation, not identity. Two unrelated repositories
|
|
152
|
+
* can both be called `api`; filtering by that word mixed their sessions and
|
|
153
|
+
* made the chip's apparent folder disagree with the rows it selected. Prefer
|
|
154
|
+
* the detector's stable key, with path/name fallbacks for older cached rows.
|
|
155
|
+
*/
|
|
156
|
+
function projectIdentity(row) {
|
|
157
|
+
if (!row?.project) return NO_PROJECT;
|
|
158
|
+
if (typeof row.projectKey === "string" && row.projectKey) return `key:${row.projectKey}`;
|
|
159
|
+
if (typeof row.projectPath === "string" && row.projectPath) return `path:${row.projectPath}`;
|
|
160
|
+
return `name:${row.project}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const compactProjectPath = (path) => String(path ?? "").replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
|
164
|
+
|
|
165
|
+
function projectChipFor(row) {
|
|
166
|
+
const identity = projectIdentity(row);
|
|
167
|
+
return [...$("chips").querySelectorAll('.chip[data-group="project"]')]
|
|
168
|
+
.find((button) => button.dataset.value === identity) ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
53
171
|
function visible() {
|
|
54
172
|
const needle = $("f-search").value.trim().toLowerCase();
|
|
55
173
|
const agent = chip("agent");
|
|
@@ -62,10 +180,14 @@
|
|
|
62
180
|
const rows = sessions.filter((row) => {
|
|
63
181
|
if (needle && !row.path.toLowerCase().includes(needle)) return false;
|
|
64
182
|
if (agent && row.agent !== agent) return false;
|
|
65
|
-
//
|
|
66
|
-
//
|
|
183
|
+
// The stable identity, not the display name, is the filter. This keeps
|
|
184
|
+
// same-named repositories separate and also survives a folder rename
|
|
185
|
+
// when the detector still reports the same projectKey.
|
|
186
|
+
// Keep accepting the original name-valued chips as well as the stable
|
|
187
|
+
// identities produced below. The explicit sentinel cannot collide with
|
|
188
|
+
// a real project because real names are prefixed by projectIdentity().
|
|
67
189
|
if (project === "(none)") { if (row.project) return false; }
|
|
68
|
-
else if (project && row.project !== project) return false;
|
|
190
|
+
else if (project && projectIdentity(row) !== project && row.project !== project) return false;
|
|
69
191
|
if (state === "archived" && !row.archived) return false;
|
|
70
192
|
// A queued row is on its way into the vault: no decision is pending on
|
|
71
193
|
// it, so it is not "not archived yet" — it has its own chip. The three
|
|
@@ -94,6 +216,14 @@
|
|
|
94
216
|
const total = sessions.filter((row) => chosen.has(row.path)).reduce((sum, row) => sum + row.bytes, 0);
|
|
95
217
|
$("chosen").textContent = chosen.size === 0 ? "Nothing selected." : `${chosen.size} selected · ${bytes(total)}`;
|
|
96
218
|
$("archive").disabled = chosen.size === 0;
|
|
219
|
+
const rows = selectable();
|
|
220
|
+
const selected = rows.filter((row) => chosen.has(row.path)).length;
|
|
221
|
+
// The table header and the written "Select all shown" control are two
|
|
222
|
+
// entrances to one selection. Keep the native checkbox honest after either
|
|
223
|
+
// entrance, including the useful mixed state after ticking individual rows.
|
|
224
|
+
$("all").checked = rows.length > 0 && selected === rows.length;
|
|
225
|
+
$("all").indeterminate = selected > 0 && selected < rows.length;
|
|
226
|
+
$("all").disabled = rows.length === 0;
|
|
97
227
|
}
|
|
98
228
|
|
|
99
229
|
let draw = function () {
|
|
@@ -104,9 +234,13 @@
|
|
|
104
234
|
// What the filters are currently hiding, said out loud. A list that silently
|
|
105
235
|
// shows 12 of 697 looks like a list of 12.
|
|
106
236
|
const shownBytes = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
237
|
+
const state = chip("state");
|
|
238
|
+
const queuedCount = sessions.filter((item) => !item.archived && item.queued).length;
|
|
107
239
|
$("filter-count").textContent = sessions.length === 0
|
|
108
240
|
? ""
|
|
109
|
-
:
|
|
241
|
+
: state === "unarchived" && rows.length === 0
|
|
242
|
+
? `0 ready to archive of ${sessions.length} sessions${queuedCount ? ` · ${queuedCount} already queued` : ""}`
|
|
243
|
+
: `Showing ${rows.length} of ${sessions.length} sessions · ${bytes(shownBytes)}`;
|
|
110
244
|
|
|
111
245
|
if (rows.length === 0) {
|
|
112
246
|
const row = body.insertRow();
|
|
@@ -115,7 +249,9 @@
|
|
|
115
249
|
cell.className = "note";
|
|
116
250
|
cell.textContent = sessions.length === 0
|
|
117
251
|
? "No sessions found for the agents this build can archive."
|
|
118
|
-
:
|
|
252
|
+
: state === "unarchived" && queuedCount > 0
|
|
253
|
+
? `No session is waiting for you to archive. ${queuedCount} ${queuedCount === 1 ? "is" : "are"} already queued; choose “Queued to seal” above to see ${queuedCount === 1 ? "it" : "them"}.`
|
|
254
|
+
: "No session matches these filters. Widen them, or clear them to see everything.";
|
|
119
255
|
return;
|
|
120
256
|
}
|
|
121
257
|
for (const item of rows) {
|
|
@@ -125,6 +261,7 @@
|
|
|
125
261
|
const tick = row.insertCell();
|
|
126
262
|
const box = document.createElement("input");
|
|
127
263
|
box.type = "checkbox";
|
|
264
|
+
box.setAttribute("aria-label", `Select ${item.path.split("/").pop()} to archive`);
|
|
128
265
|
box.checked = chosen.has(item.path);
|
|
129
266
|
// An archive already covers this file at exactly this length, so sealing
|
|
130
267
|
// it again would write a duplicate of something we hold. A queued row is
|
|
@@ -145,7 +282,7 @@
|
|
|
145
282
|
const name = document.createElement("span");
|
|
146
283
|
name.className = "name";
|
|
147
284
|
name.textContent = item.project;
|
|
148
|
-
const matching =
|
|
285
|
+
const matching = projectChipFor(item);
|
|
149
286
|
if (matching) {
|
|
150
287
|
name.classList.add("filterable");
|
|
151
288
|
name.title = "Show only this project";
|
|
@@ -155,7 +292,7 @@
|
|
|
155
292
|
if (item.projectPath) {
|
|
156
293
|
const where = document.createElement("span");
|
|
157
294
|
where.className = "where";
|
|
158
|
-
where.textContent = item.projectPath
|
|
295
|
+
where.textContent = compactProjectPath(item.projectPath);
|
|
159
296
|
where.title = item.projectPath;
|
|
160
297
|
proj.append(where);
|
|
161
298
|
}
|
|
@@ -187,17 +324,22 @@
|
|
|
187
324
|
if (item.stored) state.innerHTML = '<span class="ok">in your cloud</span>';
|
|
188
325
|
else if (item.archived) state.innerHTML = '<span class="ok">archived</span>';
|
|
189
326
|
else if (item.queued && item.held) {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
327
|
+
// A durable queued intent is not necessarily active work. In
|
|
328
|
+
// particular, a later Settings exclusion pauses automatic work without
|
|
329
|
+
// deleting it. Say which kind of hold this is instead of showing an
|
|
330
|
+
// endless "sealing…" state.
|
|
193
331
|
const span = document.createElement("span");
|
|
194
332
|
span.className = "note";
|
|
195
|
-
span.textContent =
|
|
333
|
+
span.textContent = item.heldKind === "policy"
|
|
334
|
+
? "paused in Settings"
|
|
335
|
+
: item.heldKind === "space"
|
|
336
|
+
? "waiting for room"
|
|
337
|
+
: "waiting to retry";
|
|
196
338
|
span.title = item.held;
|
|
197
339
|
state.append(span);
|
|
198
340
|
}
|
|
199
341
|
else if (item.queued) state.innerHTML = `<span class="queued">sealing…${item.sealingPct !== null && item.sealingPct !== undefined ? ` ${item.sealingPct}%` : ""}</span>`;
|
|
200
|
-
else state.innerHTML = '<span class="note">
|
|
342
|
+
else state.innerHTML = '<span class="note">ready to archive</span>';
|
|
201
343
|
}
|
|
202
344
|
};
|
|
203
345
|
|
|
@@ -207,6 +349,13 @@
|
|
|
207
349
|
// six hundred.
|
|
208
350
|
// Only rows that can actually be sealed: not archived, not already queued.
|
|
209
351
|
const selectable = () => visible().filter((item) => !item.archived && !item.queued);
|
|
352
|
+
const MAX_ROUTED_SEND = 5_000;
|
|
353
|
+
// This is deliberately not `chosen`: row checkboxes mean "seal this
|
|
354
|
+
// original", while this direct action routes the complete sealed heads left
|
|
355
|
+
// by the current filters. One control must never silently borrow the other's
|
|
356
|
+
// selection language.
|
|
357
|
+
const sendableShown = () => visible().filter((item) => item.archived && !item.stored
|
|
358
|
+
&& typeof item.sendableArchiveId === "string" && item.sendableArchiveId);
|
|
210
359
|
|
|
211
360
|
// The button says how many it would take, so it reads as a sentence: "Select
|
|
212
361
|
// all 13 shown (21 GB)". When some visible rows are already archived or in
|
|
@@ -222,21 +371,46 @@
|
|
|
222
371
|
const total = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
223
372
|
$("select-shown").textContent = !rows.length ? "Nothing to select"
|
|
224
373
|
: rows.length === shown ? `Select all ${rows.length} shown (${bytes(total)})`
|
|
225
|
-
: `Select the ${rows.length}
|
|
374
|
+
: `Select the ${rows.length} ready to archive (${bytes(total)})`;
|
|
226
375
|
$("select-shown").disabled = rows.length === 0;
|
|
376
|
+
const send = $("send-archived");
|
|
377
|
+
const sendable = sendableShown();
|
|
378
|
+
send.hidden = !hasStorageTarget;
|
|
379
|
+
send.disabled = !sessionEligibilityLive || !hasWritableStorageTarget || sendable.length === 0 || sendable.length > MAX_ROUTED_SEND;
|
|
380
|
+
send.textContent = sendable.length === 0
|
|
381
|
+
? "No complete sealed chains shown"
|
|
382
|
+
: sendable.length > MAX_ROUTED_SEND
|
|
383
|
+
? `Narrow filters to send (maximum ${MAX_ROUTED_SEND})`
|
|
384
|
+
: `Send ${sendable.length} complete sealed chain${sendable.length === 1 ? "" : "s"} shown to storage`;
|
|
385
|
+
send.title = !sessionEligibilityLive
|
|
386
|
+
? "Refresh Sessions successfully before sending; cached eligibility is display-only"
|
|
387
|
+
: !hasWritableStorageTarget
|
|
388
|
+
? "Connect and resume a configured storage target before sending"
|
|
389
|
+
: sendable.length === 0
|
|
390
|
+
? "No filtered archived-but-not-stored session has a complete local sealed chain"
|
|
391
|
+
: sendable.length > MAX_ROUTED_SEND
|
|
392
|
+
? `Normal routing accepts at most ${MAX_ROUTED_SEND} archive heads at once; narrow the filters first`
|
|
393
|
+
: "Sends only the filtered archived-but-not-stored rows with proven complete local chains, using normal project pins, target order, caps, and fallback";
|
|
227
394
|
};
|
|
228
395
|
|
|
229
396
|
// Shared by the static chips and the project chips built after load — a
|
|
230
397
|
// dynamic chip with different click behavior would be two filter languages
|
|
231
398
|
// on one row.
|
|
232
399
|
function wireChip(button) {
|
|
400
|
+
button.setAttribute("aria-pressed", button.classList.contains("on") ? "true" : "false");
|
|
233
401
|
button.onclick = () => {
|
|
234
402
|
const on = button.classList.contains("on");
|
|
235
403
|
// One chip per group: choosing "older than 30 days" replaces "older than
|
|
236
404
|
// 7 days" rather than stacking two ages. Clicking the active chip clears
|
|
237
405
|
// the group.
|
|
238
|
-
for (const other of $("chips").querySelectorAll(`.chip[data-group="${button.dataset.group}"]`))
|
|
239
|
-
|
|
406
|
+
for (const other of $("chips").querySelectorAll(`.chip[data-group="${button.dataset.group}"]`)) {
|
|
407
|
+
other.classList.remove("on");
|
|
408
|
+
other.setAttribute("aria-pressed", "false");
|
|
409
|
+
}
|
|
410
|
+
if (!on) {
|
|
411
|
+
button.classList.add("on");
|
|
412
|
+
button.setAttribute("aria-pressed", "true");
|
|
413
|
+
}
|
|
240
414
|
draw();
|
|
241
415
|
refreshChoice();
|
|
242
416
|
};
|
|
@@ -249,18 +423,40 @@
|
|
|
249
423
|
* one for sessions whose transcripts never said where they ran. They sit
|
|
250
424
|
* before the age group so the row reads as what, then when, then how big.
|
|
251
425
|
*/
|
|
252
|
-
function
|
|
253
|
-
|
|
426
|
+
function clearProjectChips() {
|
|
427
|
+
for (const node of $("chips").querySelectorAll('.chip[data-group="project"]')) node.remove();
|
|
428
|
+
for (const node of $("chips").querySelectorAll(".project-chip-sep")) node.remove();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildProjectChips(resetSelection = false) {
|
|
432
|
+
const selected = resetSelection ? "" : chip("project");
|
|
433
|
+
clearProjectChips();
|
|
254
434
|
const totals = new Map();
|
|
255
435
|
let unplaced = 0;
|
|
256
436
|
for (const row of sessions) {
|
|
257
|
-
if (row.project)
|
|
258
|
-
|
|
437
|
+
if (!row.project) {
|
|
438
|
+
unplaced += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const identity = projectIdentity(row);
|
|
442
|
+
const project = totals.get(identity) ?? {
|
|
443
|
+
identity,
|
|
444
|
+
name: row.project,
|
|
445
|
+
bytes: 0,
|
|
446
|
+
paths: new Set(),
|
|
447
|
+
projectKey: row.projectKey ?? null
|
|
448
|
+
};
|
|
449
|
+
project.bytes += Number(row.bytes) || 0;
|
|
450
|
+
if (row.projectPath) project.paths.add(row.projectPath);
|
|
451
|
+
totals.set(identity, project);
|
|
259
452
|
}
|
|
260
453
|
// A machine where no transcript names a project gets no dead chip row.
|
|
261
454
|
if (totals.size === 0) return;
|
|
262
455
|
const anchor = $("chips").querySelector('.chip[data-group="age"]');
|
|
263
456
|
const add = (node) => anchor.parentElement.insertBefore(node, anchor);
|
|
457
|
+
const lead = document.createElement("span");
|
|
458
|
+
lead.className = "chip-sep project-chip-sep";
|
|
459
|
+
add(lead);
|
|
264
460
|
const make = (value, label, title) => {
|
|
265
461
|
const button = document.createElement("button");
|
|
266
462
|
button.type = "button";
|
|
@@ -269,14 +465,42 @@
|
|
|
269
465
|
button.dataset.value = value;
|
|
270
466
|
button.textContent = label;
|
|
271
467
|
if (title) button.title = title;
|
|
468
|
+
if (value === selected) button.classList.add("on");
|
|
272
469
|
wireChip(button);
|
|
273
470
|
add(button);
|
|
274
471
|
};
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
472
|
+
const projects = [...totals.values()].sort((a, b) => b.bytes - a.bytes || a.identity.localeCompare(b.identity));
|
|
473
|
+
const nameCounts = new Map();
|
|
474
|
+
for (const project of projects) nameCounts.set(project.name, (nameCounts.get(project.name) ?? 0) + 1);
|
|
475
|
+
let shownProjects = projects.slice(0, 8);
|
|
476
|
+
const selectedProject = projects.find((project) => project.identity === selected);
|
|
477
|
+
if (selectedProject && !shownProjects.some((project) => project.identity === selected)) {
|
|
478
|
+
// A refresh can change the size ranking while someone is looking at one
|
|
479
|
+
// project. Keep that exact active identity in the controls instead of
|
|
480
|
+
// silently clearing the filter merely because it fell from 8th to 9th.
|
|
481
|
+
shownProjects = [...shownProjects.slice(0, 7), selectedProject];
|
|
482
|
+
}
|
|
483
|
+
for (const project of shownProjects) {
|
|
484
|
+
const paths = [...project.paths].sort();
|
|
485
|
+
const duplicateName = nameCounts.get(project.name) > 1;
|
|
486
|
+
const location = paths[0] ? compactProjectPath(paths[0]) : "";
|
|
487
|
+
// Same-named projects need visible locations, not two identical buttons.
|
|
488
|
+
// A truncated stable key is only the last-resort distinction for legacy
|
|
489
|
+
// rows whose metadata has no working directory at all.
|
|
490
|
+
const suffix = duplicateName
|
|
491
|
+
? location || (project.projectKey ? `project …${project.projectKey.slice(-8)}` : "location unavailable")
|
|
492
|
+
: "";
|
|
493
|
+
const label = suffix ? `${project.name} · ${suffix}` : project.name;
|
|
494
|
+
const title = paths.length
|
|
495
|
+
? `Sessions for ${project.name} in ${paths.join(", ")}`
|
|
496
|
+
: `Sessions for this ${project.name} project`;
|
|
497
|
+
make(project.identity, label, title);
|
|
498
|
+
}
|
|
499
|
+
if (unplaced > 0) make(NO_PROJECT, "No project", "Sessions whose transcripts do not say which project they belong to");
|
|
500
|
+
// No trailing separator: the static one at the age anchor already sits
|
|
501
|
+
// there, and adding another doubled it while the group's LEADING edge ran
|
|
502
|
+
// straight on from the state chips.
|
|
503
|
+
|
|
280
504
|
}
|
|
281
505
|
|
|
282
506
|
/**
|
|
@@ -286,12 +510,37 @@
|
|
|
286
510
|
* other than a card statement. Free vaults get nothing here rather than an
|
|
287
511
|
* upsell: a bar reading "0 of 0" would be worse than silence.
|
|
288
512
|
*/
|
|
289
|
-
async function drawPlan() {
|
|
513
|
+
async function drawPlan(statusReady = api("/v1/status"), targetsReady = api("/v1/storage/targets")) {
|
|
514
|
+
// Storage configuration must not wait behind a slow account-plan read.
|
|
515
|
+
// Free/BYOB storage is local machine state and can answer first.
|
|
516
|
+
hasWritableStorageTarget = false;
|
|
517
|
+
draw();
|
|
518
|
+
refreshChoice();
|
|
519
|
+
void targetsReady.then((payload) => {
|
|
520
|
+
const targets = Array.isArray(payload?.targets) ? payload.targets : [];
|
|
521
|
+
hasStorageTarget = targets.length > 0;
|
|
522
|
+
hasWritableStorageTarget = targets.some((target) => target?.connected === true && target?.enabled !== false);
|
|
523
|
+
for (const storageChip of $("chips").querySelectorAll(".storage-chip")) {
|
|
524
|
+
storageChip.hidden = !hasStorageTarget;
|
|
525
|
+
if (!hasStorageTarget) {
|
|
526
|
+
storageChip.classList.remove("on");
|
|
527
|
+
storageChip.setAttribute("aria-pressed", "false");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (!hasStorageTarget) {
|
|
531
|
+
const activeState = $("chips").querySelector('.chip.on[data-group="state"]');
|
|
532
|
+
if (!activeState) {
|
|
533
|
+
const ready = $("chips").querySelector('.chip[data-group="state"][data-value="unarchived"]');
|
|
534
|
+
ready?.classList.add("on");
|
|
535
|
+
ready?.setAttribute("aria-pressed", "true");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
draw();
|
|
539
|
+
refreshChoice();
|
|
540
|
+
}).catch(() => undefined);
|
|
290
541
|
try {
|
|
291
|
-
const status = await
|
|
542
|
+
const status = await statusReady;
|
|
292
543
|
if (!status.plan || !status.quota) return;
|
|
293
|
-
// The cloud-state chips only mean something once the account has storage.
|
|
294
|
-
for (const proChip of $("chips").querySelectorAll(".pro-chip")) proChip.hidden = false;
|
|
295
544
|
const used = Number(status.quota.usedBytes) || 0;
|
|
296
545
|
const limit = Number(status.quota.quotaBytes) || 0;
|
|
297
546
|
const room = limit - used;
|
|
@@ -317,24 +566,51 @@
|
|
|
317
566
|
pollTimer = setInterval(async () => {
|
|
318
567
|
try {
|
|
319
568
|
const data = await api("/v1/sessions");
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
569
|
+
const loadedAt = new Date().toISOString();
|
|
570
|
+
acceptSessionData(data, loadedAt);
|
|
571
|
+
writeSessionCache(currentVaultId, lastSessionData, loadedAt);
|
|
323
572
|
reportProgress();
|
|
324
|
-
} catch {
|
|
573
|
+
} catch {
|
|
574
|
+
// The next tick retries, but until then say that the visible rows are
|
|
575
|
+
// the last successful answer instead of silently presenting them as live.
|
|
576
|
+
if (lastSessionData) setSessionSummary(lastSessionData, "failed", lastLoadedAt);
|
|
577
|
+
}
|
|
325
578
|
}, 3000);
|
|
326
579
|
}
|
|
327
580
|
function reportProgress() {
|
|
328
581
|
const queued = sessions.filter((row) => row.queued);
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
582
|
+
const paused = queued.filter((row) => row.heldKind === "policy");
|
|
583
|
+
const heldActive = queued.filter((row) => row.held && row.heldKind !== "policy");
|
|
584
|
+
const waiting = heldActive.length;
|
|
585
|
+
const active = queued.length - paused.length;
|
|
586
|
+
if (queued.length > 0 && typeof state !== "undefined" && state?.daemon?.phraseRemembered === false) {
|
|
587
|
+
// No amount of three-second polling can make a locked vault progress.
|
|
588
|
+
// Stop the loop and put the exact local remedy beside the waiting rows;
|
|
589
|
+
// returning to Sessions after storing the phrase kicks the drain again.
|
|
590
|
+
stopPolling();
|
|
591
|
+
$("result").textContent = `${queued.length} session${queued.length === 1 ? " is" : "s are"} waiting. This machine cannot encrypt them until it remembers your recovery phrase. `;
|
|
592
|
+
const fix = document.createElement("a");
|
|
593
|
+
fix.href = "#recovery-phrase";
|
|
594
|
+
fix.textContent = "Store recovery phrase";
|
|
595
|
+
$("result").append(fix);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (active > 0) {
|
|
599
|
+
$("result").textContent = waiting === active && heldActive.every((row) => row.heldKind === "space")
|
|
333
600
|
// The guard's own sentence ends without punctuation, so this used to run
|
|
334
601
|
// straight into the instruction: "...rather than filling the disk Free
|
|
335
602
|
// some space and they seal on their own."
|
|
336
|
-
? `${waiting} waiting for disk room — ${String(
|
|
337
|
-
:
|
|
603
|
+
? `${waiting} waiting for disk room — ${String(heldActive[0].held).replace(/[.\s]+$/, "")}. Free some space and they seal on their own.`
|
|
604
|
+
: waiting === active
|
|
605
|
+
? `${waiting} waiting to retry — ${String(heldActive[0].held).replace(/[.\s]+$/, "")}. Sealkeep will retry on its own.`
|
|
606
|
+
: `Sealing in the background — ${active - waiting} working${waiting ? `, ${waiting} waiting to retry` : ""}${paused.length ? `, ${paused.length} paused in Settings` : ""}. You can close or refresh this page; it carries on without it.`;
|
|
607
|
+
} else if (paused.length > 0) {
|
|
608
|
+
// A policy pause changes only when the person changes Settings. Polling
|
|
609
|
+
// every three seconds cannot make progress and used to keep this browser
|
|
610
|
+
// and the local API busy forever. Re-entering Sessions or changing the
|
|
611
|
+
// setting performs a normal refresh.
|
|
612
|
+
stopPolling();
|
|
613
|
+
$("result").textContent = `${paused.length} queued session${paused.length === 1 ? " is" : "s are"} paused by this machine's Settings. ${paused.length === 1 ? "It remains" : "They remain"} untouched and will continue automatically when that agent or folder is enabled.`;
|
|
338
614
|
} else {
|
|
339
615
|
stopPolling();
|
|
340
616
|
$("result").textContent = "Everything queued is sealed. The originals are untouched — reclaim them from the retention page when you are ready.";
|
|
@@ -344,34 +620,136 @@
|
|
|
344
620
|
const active = $("chips").querySelector('.chip[data-group="state"].on');
|
|
345
621
|
if (active?.dataset.value === "sealing") {
|
|
346
622
|
active.classList.remove("on");
|
|
347
|
-
|
|
623
|
+
active.setAttribute("aria-pressed", "false");
|
|
624
|
+
const archived = $("chips").querySelector('.chip[data-group="state"][data-value="archived"]');
|
|
625
|
+
archived?.classList.add("on");
|
|
626
|
+
archived?.setAttribute("aria-pressed", "true");
|
|
348
627
|
draw();
|
|
349
628
|
refreshChoice();
|
|
350
629
|
}
|
|
351
630
|
}
|
|
352
631
|
}
|
|
353
632
|
|
|
354
|
-
async function load() {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
633
|
+
async function load(statusReady = null) {
|
|
634
|
+
// Start the slow disk walk immediately, but attach its rejection handler
|
|
635
|
+
// before waiting for the fast identity used to validate the saved view.
|
|
636
|
+
const sessionsReady = api("/v1/sessions").then(
|
|
637
|
+
(value) => ({ value }),
|
|
638
|
+
(error) => ({ error })
|
|
639
|
+
);
|
|
640
|
+
const identityReady = (statusReady ?? api("/v1/status")).then(
|
|
641
|
+
(value) => ({ value }),
|
|
642
|
+
(error) => ({ error })
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
// On an ordinary revisit the already-rendered rows are the immediate
|
|
646
|
+
// fallback. Label them now; neither a slow identity response nor a slow
|
|
647
|
+
// disk walk should postpone the fact that a refresh is in progress.
|
|
648
|
+
const immediateFallback = lastSessionData
|
|
649
|
+
? { data: lastSessionData, loadedAt: lastLoadedAt }
|
|
650
|
+
: null;
|
|
651
|
+
if (immediateFallback) {
|
|
652
|
+
sessionEligibilityLive = false;
|
|
365
653
|
draw();
|
|
366
654
|
refreshChoice();
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
655
|
+
setSessionSummary(immediateFallback.data, "updating", immediateFallback.loadedAt);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const first = await Promise.race([
|
|
659
|
+
sessionsReady.then((result) => ({ kind: "sessions", result })),
|
|
660
|
+
identityReady.then((identity) => ({ kind: "identity", identity }))
|
|
661
|
+
]);
|
|
662
|
+
if (first.kind === "sessions" && !first.result.error) {
|
|
663
|
+
// Live rows are safe to paint without waiting for account identity: they
|
|
664
|
+
// came from this local agent just now. Identity is needed only before
|
|
665
|
+
// persisting them for a future reload.
|
|
666
|
+
const loadedAt = new Date().toISOString();
|
|
667
|
+
acceptSessionData(first.result.value, loadedAt);
|
|
668
|
+
const accepted = lastSessionData;
|
|
669
|
+
// Start first, then let reportProgress immediately cancel it when every
|
|
670
|
+
// queued row is policy-paused. Doing these in the opposite order created
|
|
671
|
+
// a fresh timer after the pause handler had correctly stopped nothing.
|
|
672
|
+
if (sessions.some((row) => row.queued)) { startPolling(); reportProgress(); }
|
|
673
|
+
void identityReady.then((identity) => {
|
|
674
|
+
const vaultId = identity.value?.vaultId ?? null;
|
|
675
|
+
if (!vaultId) return;
|
|
676
|
+
if (currentVaultId && currentVaultId !== vaultId) {
|
|
677
|
+
chosen.clear();
|
|
678
|
+
buildProjectChips(true);
|
|
679
|
+
draw();
|
|
680
|
+
refreshChoice();
|
|
681
|
+
}
|
|
682
|
+
currentVaultId = vaultId;
|
|
683
|
+
writeSessionCache(vaultId, accepted, loadedAt);
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (first.kind === "sessions" && first.result.error && immediateFallback) {
|
|
688
|
+
// The identity request may itself be slow or unavailable. The user still
|
|
689
|
+
// gets an immediate, honest warning on the rows already in front of them.
|
|
690
|
+
setSessionSummary(immediateFallback.data, "failed", immediateFallback.loadedAt);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const identity = first.kind === "identity" ? first.identity : await identityReady;
|
|
694
|
+
const vaultId = identity.value?.vaultId ?? null;
|
|
695
|
+
if (vaultId && lastSessionData && currentVaultId !== vaultId) {
|
|
696
|
+
// This port now fronts a different vault. Do not retain a prior vault's
|
|
697
|
+
// (or identity-less) paths in memory while the new disk walk completes.
|
|
698
|
+
sessions = [];
|
|
699
|
+
sessionEligibilityLive = false;
|
|
700
|
+
lastSessionData = null;
|
|
701
|
+
lastLoadedAt = null;
|
|
702
|
+
chosen.clear();
|
|
703
|
+
stopPolling();
|
|
704
|
+
clearProjectChips();
|
|
705
|
+
draw();
|
|
706
|
+
refreshChoice();
|
|
707
|
+
}
|
|
708
|
+
if (vaultId) currentVaultId = vaultId;
|
|
709
|
+
|
|
710
|
+
let fallback = lastSessionData && (!vaultId || currentVaultId === vaultId)
|
|
711
|
+
? { data: lastSessionData, loadedAt: lastLoadedAt }
|
|
712
|
+
: null;
|
|
713
|
+
if (!fallback && vaultId) {
|
|
714
|
+
const cached = readSessionCache(vaultId);
|
|
715
|
+
if (cached) {
|
|
716
|
+
acceptSessionData(cached.data, cached.loadedAt, false);
|
|
717
|
+
fallback = { data: lastSessionData, loadedAt: lastLoadedAt };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (fallback) setSessionSummary(fallback.data, "updating", fallback.loadedAt);
|
|
721
|
+
|
|
722
|
+
const result = first.kind === "sessions" ? first.result : await sessionsReady;
|
|
723
|
+
if (result.error) {
|
|
724
|
+
if (fallback) {
|
|
725
|
+
// Keep the rows, filters and selection exactly where they were. The
|
|
726
|
+
// warning explicitly names their age so cached never masquerades as live.
|
|
727
|
+
setSessionSummary(fallback.data, "failed", fallback.loadedAt);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
$("summary").className = "note error";
|
|
371
731
|
$("summary").textContent = token
|
|
372
|
-
? `Could not read sessions: ${error.message}`
|
|
732
|
+
? `Could not read sessions: ${result.error.message}`
|
|
373
733
|
: "Connect the dashboard first so it can read this machine's sessions.";
|
|
374
734
|
$("rows").textContent = "";
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const loadedAt = new Date().toISOString();
|
|
740
|
+
acceptSessionData(result.value, loadedAt);
|
|
741
|
+
writeSessionCache(vaultId, lastSessionData, loadedAt);
|
|
742
|
+
// A background seal keeps going whether or not this view is open. If one
|
|
743
|
+
// is running, a fresh visit picks the progress line straight back up.
|
|
744
|
+
if (sessions.some((row) => row.queued)) { startPolling(); reportProgress(); }
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (fallback) {
|
|
747
|
+
setSessionSummary(fallback.data, "failed", fallback.loadedAt);
|
|
748
|
+
} else {
|
|
749
|
+
$("summary").className = "note error";
|
|
750
|
+
$("summary").textContent = `Could not read sessions: ${error.message}`;
|
|
751
|
+
$("rows").textContent = "";
|
|
752
|
+
}
|
|
375
753
|
}
|
|
376
754
|
}
|
|
377
755
|
|
|
@@ -380,8 +758,13 @@
|
|
|
380
758
|
$("f-search").addEventListener("input", () => { draw(); refreshChoice(); });
|
|
381
759
|
$("f-sort").addEventListener("change", () => { draw(); refreshChoice(); });
|
|
382
760
|
$("f-clear").onclick = () => {
|
|
383
|
-
for (const other of $("chips").querySelectorAll(".chip"))
|
|
384
|
-
|
|
761
|
+
for (const other of $("chips").querySelectorAll(".chip")) {
|
|
762
|
+
other.classList.remove("on");
|
|
763
|
+
other.setAttribute("aria-pressed", "false");
|
|
764
|
+
}
|
|
765
|
+
const ready = $("chips").querySelector('.chip[data-value="unarchived"]');
|
|
766
|
+
ready.classList.add("on");
|
|
767
|
+
ready.setAttribute("aria-pressed", "true");
|
|
385
768
|
$("f-search").value = "";
|
|
386
769
|
$("f-sort").value = "size";
|
|
387
770
|
draw();
|
|
@@ -415,14 +798,20 @@
|
|
|
415
798
|
const failures = outcome.results.filter((item) => !item.ok);
|
|
416
799
|
chosen.clear();
|
|
417
800
|
const data = await api("/v1/sessions");
|
|
418
|
-
|
|
419
|
-
|
|
801
|
+
const loadedAt = new Date().toISOString();
|
|
802
|
+
acceptSessionData(data, loadedAt);
|
|
803
|
+
writeSessionCache(currentVaultId, lastSessionData, loadedAt);
|
|
804
|
+
// The rows just queued belong to "Queued to seal", not "Ready to archive" —
|
|
420
805
|
// staying on the old chip would show an empty table the moment the
|
|
421
806
|
// person acted. Follow the work: switch to the chip that watches it.
|
|
422
807
|
if (outcome.queued > 0) {
|
|
423
808
|
const sealingChip = $("chips").querySelector('.chip[data-group="state"][data-value="sealing"]');
|
|
424
|
-
for (const other of $("chips").querySelectorAll('.chip[data-group="state"]'))
|
|
809
|
+
for (const other of $("chips").querySelectorAll('.chip[data-group="state"]')) {
|
|
810
|
+
other.classList.remove("on");
|
|
811
|
+
other.setAttribute("aria-pressed", "false");
|
|
812
|
+
}
|
|
425
813
|
sealingChip?.classList.add("on");
|
|
814
|
+
sealingChip?.setAttribute("aria-pressed", "true");
|
|
426
815
|
}
|
|
427
816
|
draw();
|
|
428
817
|
refreshChoice();
|
|
@@ -434,6 +823,36 @@
|
|
|
434
823
|
$("archive").disabled = chosen.size === 0;
|
|
435
824
|
}
|
|
436
825
|
};
|
|
826
|
+
|
|
827
|
+
$("send-archived").onclick = async () => {
|
|
828
|
+
// Recompute from the filters at click time. Stale cached rows without the
|
|
829
|
+
// explicit API eligibility field are excluded rather than guessed in.
|
|
830
|
+
const ids = [...new Set(sendableShown().map((item) => item.sendableArchiveId))];
|
|
831
|
+
if (!sessionEligibilityLive || !hasWritableStorageTarget || ids.length === 0 || ids.length > MAX_ROUTED_SEND) return;
|
|
832
|
+
const send = $("send-archived");
|
|
833
|
+
send.disabled = true;
|
|
834
|
+
send.textContent = "Starting normal storage routing…";
|
|
835
|
+
$("result").textContent = `Starting ${ids.length} complete sealed chain${ids.length === 1 ? "" : "s"}…`;
|
|
836
|
+
try {
|
|
837
|
+
const outcome = await api("/v1/upload", { method: "POST", body: JSON.stringify({ ids }) });
|
|
838
|
+
if (outcome.upload && typeof drawUpload === "function") {
|
|
839
|
+
drawUpload(outcome.upload);
|
|
840
|
+
if ((!outcome.upload.finishedAt || outcome.upload.cleanupRetryAt) && typeof watchUpload === "function") watchUpload(outcome.upload);
|
|
841
|
+
}
|
|
842
|
+
$("result").textContent = outcome.alreadyRunning
|
|
843
|
+
? "Another storage transfer is already running. These rows were not added; follow Queue, then try again."
|
|
844
|
+
: `Queued ${ids.length} complete sealed chain${ids.length === 1 ? "" : "s"} for normal storage routing. Follow progress in Queue.`;
|
|
845
|
+
// The successful request consumed this live eligibility snapshot.
|
|
846
|
+
// Queue completion refreshes Sessions; until then, do not offer the
|
|
847
|
+
// same heads again from stale rows.
|
|
848
|
+
if (!outcome.alreadyRunning) sessionEligibilityLive = false;
|
|
849
|
+
} catch (error) {
|
|
850
|
+
$("result").textContent = `Nothing was sent to storage: ${error.message}`;
|
|
851
|
+
} finally {
|
|
852
|
+
draw();
|
|
853
|
+
refreshChoice();
|
|
854
|
+
}
|
|
855
|
+
};
|
|
437
856
|
}
|
|
438
857
|
|
|
439
858
|
let initialised = false;
|
|
@@ -441,11 +860,15 @@
|
|
|
441
860
|
// every later entry shows the already-rendered table instantly and refreshes
|
|
442
861
|
// the data underneath it — no placeholder, no flash, no lost scroll.
|
|
443
862
|
window.initSessionsView = function () {
|
|
863
|
+
const statusReady = api("/v1/status");
|
|
444
864
|
if (!initialised) {
|
|
445
865
|
initialised = true;
|
|
446
866
|
wire();
|
|
447
|
-
void drawPlan();
|
|
448
867
|
}
|
|
449
|
-
|
|
868
|
+
// Account identity and plan share one read with cache validation. Storage
|
|
869
|
+
// targets are refreshed on every entry because connect/pause changes are
|
|
870
|
+
// machine state, not a property of the cached session walk.
|
|
871
|
+
void drawPlan(statusReady);
|
|
872
|
+
void load(statusReady);
|
|
450
873
|
};
|
|
451
874
|
})();
|