dsh-archived-chats 1.0.2 → 1.0.3
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/docs/ARCHITECTURE.en.md +5 -5
- package/docs/ARCHITECTURE.md +5 -5
- package/lib/client.js +18 -11
- package/lib/durable.js +68 -0
- package/lib/export.js +8 -3
- package/lib/history-restore.js +21 -13
- package/lib/import.js +66 -7
- package/lib/index.js +92 -62
- package/lib/metadata.js +39 -17
- package/lib/recycle.js +96 -28
- package/lib/restore.js +69 -37
- package/lib/retention.js +5 -14
- package/lib/search.js +129 -51
- package/lib/snapshot.js +49 -23
- package/lib/trash.js +5 -35
- package/package.json +2 -1
package/docs/ARCHITECTURE.en.md
CHANGED
|
@@ -76,7 +76,7 @@ Preview accepts visible archived IDs by default and only recycle-catalog IDs wit
|
|
|
76
76
|
|
|
77
77
|
The preview/image authorization sequence is fixed: first require POST and `x-dsh-archived-chats: 1`, then bounded-parse `sessionId` and `attachmentId`; next confirm that the session is still in the currently visible archive set, find an exact image-descriptor match in that session's canonical projection, and only then read bytes through the optional `attachments.readImage` service. Both preview and preview/image recheck visible archive state after asynchronous reads and immediately before sending a response, preventing an overlapping unarchive or delete from exposing stale content. Image bytes use `no-store` and `nosniff`; cross-session, non-archived, and unprojected references are rejected, and error responses never echo filesystem paths. A host without attachment-read capability returns `preview-image-unsupported`; this degrades images only and does not block text, Markdown, reasoning, tool, JSON, or code preview.
|
|
78
78
|
|
|
79
|
-
Cross-session persistence inspection is limited to four concurrent reads. A broken session is reported in `skipped` while other hits still succeed. Canonical
|
|
79
|
+
Cross-session persistence inspection is limited to four concurrent reads, stops scheduling batches once the hit limit is satisfied, and aborts an older browser request when a newer search starts. A broken session is reported in `skipped` while other hits still succeed. Canonical projection limits each segment to 256 Ki code points, each message to 1 Mi code points and 1,000 segments, and each session to 10,000 projected messages; unknown structured values are bounded by depth, node, and character budgets before stringify. A 30-second TTL, 64-session LRU, and per-session cache cap keep bounded projections resident. Unarchive, delete, and restore invalidate affected cache entries.
|
|
80
80
|
|
|
81
81
|
## History versions and restore-as-copy
|
|
82
82
|
|
|
@@ -104,7 +104,7 @@ ZIP paths are sanitized and collision-safe. Batch export inspects and writes ses
|
|
|
104
104
|
|
|
105
105
|
## Import and restore flow
|
|
106
106
|
|
|
107
|
-
import/inspect accepts only version-one ZIPs produced by this plugin. The Host streams bounded compressed chunks, preflights declared entry sizes, counts actual output, and caps entry count, per-entry bytes, manifest bytes, and total expansion. Iterative JSON validation then caps depth, node count, and total Unicode code points before
|
|
107
|
+
import/inspect accepts only version-one ZIPs produced by this plugin. The Host streams bounded compressed chunks, preflights declared entry sizes, counts actual output, and caps entry count, per-entry bytes, manifest bytes, and total expansion. Iterative JSON validation then caps depth, node count, and total Unicode code points before checking paths, versions, generator, workspace, storage descriptors, timestamps, `source.meta.id`, the event array, and cross-file consistency:
|
|
108
108
|
|
|
109
109
|
1. The browser uploads the ZIP and receives session summaries, versions, size, and warnings.
|
|
110
110
|
2. Existing session IDs are marked as conflicts and deselected by default.
|
|
@@ -113,19 +113,19 @@ import/inspect accepts only version-one ZIPs produced by this plugin. The Host s
|
|
|
113
113
|
5. restore.js uses a feature-detected adapter to write sessions, metadata, and archive state.
|
|
114
114
|
6. Any failure rolls back staged data and never overwrites an existing session.
|
|
115
115
|
|
|
116
|
-
The confirmation token expires quickly
|
|
116
|
+
The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import is enabled only when the Host exposes writer, removal rollback, archive, and metadata capabilities; workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
|
|
117
117
|
|
|
118
118
|
## Recycle and protection-snapshot lifecycle
|
|
119
119
|
|
|
120
120
|
`trash.json` permits only `trashed`, `purge-pending`, and `degraded`. Legal transitions are `missing -> trashed`, `trashed/degraded -> purge-pending`, and removal of an existing state after a committed transaction. A `purge-pending` record cannot restore.
|
|
121
121
|
|
|
122
|
-
Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits
|
|
122
|
+
Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync.
|
|
123
123
|
|
|
124
124
|
Move ordering is: validate archive ownership → dispose or park a live session → capture and verify snapshot → recheck ownership → atomically commit `trashed` → invalidate caches. Ordinary move never removes the persistence artifact.
|
|
125
125
|
|
|
126
126
|
Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the recycle record, without rewriting persistence; the snapshot remains history. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.
|
|
127
127
|
|
|
128
|
-
Permanent purge persists `purge-pending` before physical writes, then removes the original, every validated snapshot for that source, and the recycle record. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
|
|
128
|
+
Permanent purge persists `purge-pending` before physical writes, then removes the original, every validated snapshot for that source, and the recycle record. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
|
|
129
129
|
|
|
130
130
|
## Browser client
|
|
131
131
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -76,7 +76,7 @@ preview 默认只接受当前可见归档 ID;显式 `scope: "trash"` 时仅接
|
|
|
76
76
|
|
|
77
77
|
preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chats: 1`,再有界解析 `sessionId` 与 `attachmentId`;随后确认会话仍在当前可见归档集合中,从该会话的规范投影中查找完全匹配的图片描述符,最后才通过可选的 `attachments.readImage` 服务读取。preview 和 preview/image 都会在异步读取完成后、响应发送前再次检查可见归档状态,避免并发取消归档或删除泄露旧内容。图片字节以 `no-store`、`nosniff` 返回;跨会话、非归档或不在投影中的引用均会被拒绝,错误响应不回显文件路径。宿主没有附件读取能力时返回 `preview-image-unsupported`;这只降级图片,不阻塞文本、Markdown、思考、工具、JSON 或代码预览。
|
|
78
78
|
|
|
79
|
-
跨会话搜索的持久层读取并发上限为 4
|
|
79
|
+
跨会话搜索的持久层读取并发上限为 4,达到命中上限后不再调度后续批次,浏览器的新搜索会中止旧请求;单个会话失败会记入 skipped,其他命中仍正常返回。规范投影使用 30 秒 TTL、64 会话 LRU,并限制单段 256 Ki Unicode 码点、单消息 1 Mi 码点/1,000 段、单会话 10,000 条投影消息;结构化未知值在 stringify 前即受深度、节点和字符预算约束。超出投影边界的内容会安全截断且不会常驻缓存。取消归档、删除和恢复会使相关缓存失效。
|
|
80
80
|
|
|
81
81
|
## 历史版本与恢复为副本
|
|
82
82
|
|
|
@@ -104,7 +104,7 @@ ZIP 路径会清理遍历字符并处理重名。批量导出按会话顺序逐
|
|
|
104
104
|
|
|
105
105
|
## 导入和恢复流程
|
|
106
106
|
|
|
107
|
-
import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩块流式解压,先核对条目声明大小,再累计实际输出,并限制条目数、单条目、manifest 与总解压量。JSON 校验使用迭代遍历限制深度、节点数和 Unicode
|
|
107
|
+
import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩块流式解压,先核对条目声明大小,再累计实际输出,并限制条目数、单条目、manifest 与总解压量。JSON 校验使用迭代遍历限制深度、节点数和 Unicode 字符总量,然后继续校验路径、版本、生成器、工作区、存储描述符、时间戳、`source.meta.id`、事件数组及跨文件一致性并生成预览:
|
|
108
108
|
|
|
109
109
|
1. 浏览器上传 ZIP,Host 返回会话摘要、版本、大小和警告。
|
|
110
110
|
2. 已存在的会话 ID 标记为冲突并默认取消选择。
|
|
@@ -113,19 +113,19 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
|
|
|
113
113
|
5. restore.js 通过能力探测的适配器写入会话、元数据和归档状态。
|
|
114
114
|
6. 任一步骤失败都回滚暂存数据,不覆盖已有会话。
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。只有同时提供 writer、删除回滚、归档和元数据能力的宿主才会启用导入;工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
|
|
117
117
|
|
|
118
118
|
## 回收与保护快照生命周期
|
|
119
119
|
|
|
120
120
|
`trash.json` 的合法状态只有 `trashed`、`purge-pending`、`degraded`。合法转换为 `missing -> trashed`、`trashed/degraded -> purge-pending`,以及任一现有状态在事务成功后移除。`purge-pending` 不得恢复。
|
|
121
121
|
|
|
122
|
-
保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON
|
|
122
|
+
保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。
|
|
123
123
|
|
|
124
124
|
移入顺序为:校验归档所有权 → 处置/停放运行中会话 → 捕获并验证快照 → 再次校验所有权 → 原子写入 `trashed` 记录 → 使缓存失效。普通移入不删除持久层文件。
|
|
125
125
|
|
|
126
126
|
恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除回收记录,不重写持久层,保护快照保留为历史;原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
|
|
127
127
|
|
|
128
|
-
永久删除在任何物理写入前持久化 `purge-pending
|
|
128
|
+
永久删除在任何物理写入前持久化 `purge-pending`,再删除原会话、该来源的全部已验证快照和回收记录。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
|
|
129
129
|
|
|
130
130
|
## 浏览器客户端
|
|
131
131
|
|
package/lib/client.js
CHANGED
|
@@ -805,6 +805,11 @@ window.__ModuleLoader__.load({
|
|
|
805
805
|
return new Date(ms).toLocaleString();
|
|
806
806
|
}
|
|
807
807
|
}
|
|
808
|
+
function isoTimestamp(ms) {
|
|
809
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return undefined;
|
|
810
|
+
const date = new Date(ms);
|
|
811
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : undefined;
|
|
812
|
+
}
|
|
808
813
|
|
|
809
814
|
function formatBytes(value) {
|
|
810
815
|
if (!Number.isFinite(value) || value < 0) return "—";
|
|
@@ -1524,8 +1529,8 @@ window.__ModuleLoader__.load({
|
|
|
1524
1529
|
return res.blob();
|
|
1525
1530
|
}
|
|
1526
1531
|
|
|
1527
|
-
async function fetchArchiveSearch(query, limit = 50) {
|
|
1528
|
-
return post("/search", { query, limit });
|
|
1532
|
+
async function fetchArchiveSearch(query, limit = 50, signal) {
|
|
1533
|
+
return post("/search", { query, limit }, signal);
|
|
1529
1534
|
}
|
|
1530
1535
|
|
|
1531
1536
|
async function saveMetadata(sessionId, tags, note) {
|
|
@@ -2240,7 +2245,7 @@ window.__ModuleLoader__.load({
|
|
|
2240
2245
|
setCopied(true);
|
|
2241
2246
|
};
|
|
2242
2247
|
return (0, jsx.jsxs)("div", { className: "dac-preview-actions", children: [
|
|
2243
|
-
(0, jsx.jsx)("time", { dateTime:
|
|
2248
|
+
(0, jsx.jsx)("time", { dateTime: isoTimestamp(node.time), children: formatDate(t, node.time) }),
|
|
2244
2249
|
(0, jsx.jsx)("button", { type: "button", onClick: copy, "aria-label": copied ? t("preview.copied") : t("preview.copy"), children: copied ? t("preview.copied") : t("preview.copy") }),
|
|
2245
2250
|
] });
|
|
2246
2251
|
}
|
|
@@ -3273,7 +3278,7 @@ window.__ModuleLoader__.load({
|
|
|
3273
3278
|
const trashReturnFocusRef = _react.useRef(null);
|
|
3274
3279
|
const metaReturnFocusRef = _react.useRef(null);
|
|
3275
3280
|
const importInputRef = _react.useRef(null);
|
|
3276
|
-
const searchRequestRef = _react.useRef(0);
|
|
3281
|
+
const searchRequestRef = _react.useRef({ sequence: 0, controller: null });
|
|
3277
3282
|
const previewReturnFocusRef = _react.useRef(null);
|
|
3278
3283
|
const previewRequestRef = _react.useRef({ sequence: 0, controller: null });
|
|
3279
3284
|
|
|
@@ -3406,16 +3411,18 @@ window.__ModuleLoader__.load({
|
|
|
3406
3411
|
}, [sessions, t]);
|
|
3407
3412
|
_react.useEffect(() => {
|
|
3408
3413
|
const trimmed = query.trim();
|
|
3409
|
-
const
|
|
3410
|
-
|
|
3414
|
+
const current = searchRequestRef.current;
|
|
3415
|
+
current.controller?.abort();
|
|
3416
|
+
const request = { sequence: current.sequence + 1, controller: new AbortController() };
|
|
3417
|
+
searchRequestRef.current = request;
|
|
3411
3418
|
if (Array.from(trimmed).length < 2) {
|
|
3412
3419
|
setContentSearch({ status: "idle", query: trimmed, hits: [], skipped: [] });
|
|
3413
|
-
return
|
|
3420
|
+
return () => request.controller.abort();
|
|
3414
3421
|
}
|
|
3415
3422
|
setContentSearch({ status: "loading", query: trimmed, hits: [], skipped: [] });
|
|
3416
3423
|
const timer = setTimeout(() => {
|
|
3417
|
-
fetchArchiveSearch(trimmed, 100).then((result) => {
|
|
3418
|
-
if (searchRequestRef.current !==
|
|
3424
|
+
fetchArchiveSearch(trimmed, 100, request.controller.signal).then((result) => {
|
|
3425
|
+
if (searchRequestRef.current !== request || request.controller.signal.aborted) return;
|
|
3419
3426
|
setContentSearch({
|
|
3420
3427
|
status: "ready",
|
|
3421
3428
|
query: trimmed,
|
|
@@ -3423,11 +3430,11 @@ window.__ModuleLoader__.load({
|
|
|
3423
3430
|
skipped: Array.isArray(result?.skipped) ? result.skipped : [],
|
|
3424
3431
|
});
|
|
3425
3432
|
}).catch(() => {
|
|
3426
|
-
if (searchRequestRef.current !==
|
|
3433
|
+
if (searchRequestRef.current !== request || request.controller.signal.aborted) return;
|
|
3427
3434
|
setContentSearch({ status: "error", query: trimmed, hits: [], skipped: [] });
|
|
3428
3435
|
});
|
|
3429
3436
|
}, 300);
|
|
3430
|
-
return () => clearTimeout(timer);
|
|
3437
|
+
return () => { clearTimeout(timer); request.controller.abort(); };
|
|
3431
3438
|
}, [query]);
|
|
3432
3439
|
_react.useEffect(() => {
|
|
3433
3440
|
if (sessions === null) return;
|
package/lib/durable.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
|
|
6
|
+
const WINDOWS_DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EISDIR', 'EPERM']);
|
|
7
|
+
|
|
8
|
+
function directorySyncUnsupported(error) {
|
|
9
|
+
return DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)
|
|
10
|
+
|| (process.platform === 'win32' && WINDOWS_DIRECTORY_SYNC_UNSUPPORTED.has(error?.code));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Flush a file after it has been written. `r+` is supported on Windows. */
|
|
14
|
+
export async function syncFile(path, openFile = open) {
|
|
15
|
+
const handle = await openFile(path, 'r+');
|
|
16
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Flush directory-entry changes where the platform exposes directory fsync. */
|
|
20
|
+
export async function syncDirectory(path, openFile = open) {
|
|
21
|
+
let handle;
|
|
22
|
+
try { handle = await openFile(path, 'r'); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (directorySyncUnsupported(error)) return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await handle.sync();
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (directorySyncUnsupported(error)) return false;
|
|
32
|
+
throw error;
|
|
33
|
+
} finally {
|
|
34
|
+
await handle.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Atomically replace one private file and make the rename crash-durable. */
|
|
39
|
+
export async function atomicWriteFile(filePath, data, { encoding = undefined } = {}) {
|
|
40
|
+
const directory = dirname(filePath);
|
|
41
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
42
|
+
await chmod(directory, 0o700);
|
|
43
|
+
let tempPath = null;
|
|
44
|
+
try {
|
|
45
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
46
|
+
const candidate = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(candidate, data, {
|
|
49
|
+
...(encoding === undefined ? {} : { encoding }),
|
|
50
|
+
mode: 0o600,
|
|
51
|
+
flag: 'wx',
|
|
52
|
+
});
|
|
53
|
+
tempPath = candidate;
|
|
54
|
+
break;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await rm(candidate, { force: true }).catch(() => undefined);
|
|
57
|
+
if (error?.code !== 'EEXIST' || attempt === 7) throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await chmod(tempPath, 0o600);
|
|
61
|
+
await syncFile(tempPath);
|
|
62
|
+
await rename(tempPath, filePath);
|
|
63
|
+
tempPath = null;
|
|
64
|
+
await syncDirectory(directory);
|
|
65
|
+
} finally {
|
|
66
|
+
if (tempPath !== null) await rm(tempPath, { force: true }).catch(() => undefined);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/export.js
CHANGED
|
@@ -237,6 +237,12 @@ function messageLabel(message) {
|
|
|
237
237
|
return 'System';
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
function isoTimestamp(value) {
|
|
241
|
+
if (!Number.isFinite(value)) return null;
|
|
242
|
+
const date = new Date(value);
|
|
243
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
240
246
|
/** Render the durable human transcript from append-origin Harness messages. */
|
|
241
247
|
export function renderTranscript(item, events, exportedAt) {
|
|
242
248
|
const lines = [
|
|
@@ -262,9 +268,8 @@ export function renderTranscript(item, events, exportedAt) {
|
|
|
262
268
|
if (!isAppendSurfaceEvent(event)) continue;
|
|
263
269
|
const message = deriveEventMessage(event);
|
|
264
270
|
if (message === null) continue;
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
: '';
|
|
271
|
+
const isoTime = isoTimestamp(event.time);
|
|
272
|
+
const timestamp = isoTime === null ? '' : ` - ${isoTime}`;
|
|
268
273
|
const content = Array.isArray(message.content)
|
|
269
274
|
? message.content.map(renderContentBlock).filter(Boolean).join('\n\n')
|
|
270
275
|
: fenced('json', jsonText(message.content));
|
package/lib/history-restore.js
CHANGED
|
@@ -45,6 +45,12 @@ function compatibleAttachment(left, right) {
|
|
|
45
45
|
&& left.height === right.height;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
async function attachmentData(item) {
|
|
49
|
+
const data = typeof item?.read === 'function' ? await item.read() : item?.data;
|
|
50
|
+
if (!(data instanceof Uint8Array)) throw failure('history-snapshot-degraded', 'history attachment bytes are unavailable', 409);
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function rewriteAttachmentReferences(value, replacements) {
|
|
49
55
|
if (Array.isArray(value)) return value.map((item) => rewriteAttachmentReferences(item, replacements));
|
|
50
56
|
if (value === null || typeof value !== 'object') return value;
|
|
@@ -204,20 +210,21 @@ export function createHistoryRestoreService({
|
|
|
204
210
|
const warnings = workspaceId !== null && workspaceId !== undefined && workspace === null
|
|
205
211
|
? [{ id: entry.destinationId, reason: 'workspace-unresolved' }]
|
|
206
212
|
: [];
|
|
207
|
-
let
|
|
208
|
-
let
|
|
209
|
-
let
|
|
210
|
-
let
|
|
213
|
+
let createAttempted = false;
|
|
214
|
+
let attachmentAttempted = false;
|
|
215
|
+
let metadataAttempted = false;
|
|
216
|
+
let registryAttempted = false;
|
|
211
217
|
const rollbackErrors = [];
|
|
212
218
|
try {
|
|
219
|
+
createAttempted = true;
|
|
213
220
|
await persistence.create(meta);
|
|
214
|
-
created = true;
|
|
215
221
|
|
|
216
222
|
const replacements = new Map();
|
|
217
223
|
for (const item of checked.attachments) {
|
|
218
224
|
const original = attachmentReference(item.descriptor);
|
|
225
|
+
const data = await attachmentData(item);
|
|
219
226
|
const restored = await attachments.saveImage({
|
|
220
|
-
data
|
|
227
|
+
data,
|
|
221
228
|
mediaType: original.mediaType,
|
|
222
229
|
...(original.name === undefined ? {} : { name: original.name }),
|
|
223
230
|
});
|
|
@@ -234,18 +241,18 @@ export function createHistoryRestoreService({
|
|
|
234
241
|
if (workspace !== null && !sessionIds(workspace).includes(entry.destinationId)) {
|
|
235
242
|
if (typeof workspace.attachSession !== 'function' || typeof workspace.detachSession !== 'function') warnings.push({ id: entry.destinationId, reason: 'workspace-unresolved' });
|
|
236
243
|
else {
|
|
244
|
+
attachmentAttempted = true;
|
|
237
245
|
await workspace.attachSession(entry.destinationId);
|
|
238
|
-
attached = true;
|
|
239
246
|
}
|
|
240
247
|
}
|
|
241
248
|
const tags = Array.isArray(checked.record.archive?.tags) ? checked.record.archive.tags : [];
|
|
242
249
|
const note = typeof checked.record.archive?.note === 'string' ? checked.record.archive.note : '';
|
|
243
250
|
if (tags.length > 0 || note !== '') {
|
|
251
|
+
metadataAttempted = true;
|
|
244
252
|
await metadataStore.set(entry.destinationId, { tags, note });
|
|
245
|
-
metadataChanged = true;
|
|
246
253
|
}
|
|
254
|
+
registryAttempted = true;
|
|
247
255
|
await registry.setState({ ...registry.state, archivedSessionIds: [...originalArchiveIds, entry.destinationId] });
|
|
248
|
-
registryChanged = true;
|
|
249
256
|
invalidate?.(entry.destinationId, entry.sourceSessionId);
|
|
250
257
|
return {
|
|
251
258
|
restored: [entry.destinationId],
|
|
@@ -254,22 +261,23 @@ export function createHistoryRestoreService({
|
|
|
254
261
|
warnings,
|
|
255
262
|
};
|
|
256
263
|
} catch (cause) {
|
|
257
|
-
if (
|
|
264
|
+
if (registryAttempted
|
|
265
|
+
&& JSON.stringify((registry.archivedSessionIds ?? registry.state?.archivedSessionIds ?? []).map(String)) !== JSON.stringify(originalArchiveIds)) {
|
|
258
266
|
try { await registry.setState({ ...registry.state, archivedSessionIds: originalArchiveIds }); }
|
|
259
267
|
catch (error) { rollbackErrors.push(error); }
|
|
260
268
|
}
|
|
261
|
-
if (
|
|
269
|
+
if (metadataAttempted) {
|
|
262
270
|
try {
|
|
263
271
|
const previous = originalMetadata.entries?.[entry.destinationId];
|
|
264
272
|
if (previous === undefined) await metadataStore.remove([entry.destinationId]);
|
|
265
273
|
else await metadataStore.set(entry.destinationId, { tags: previous.tags, note: previous.note });
|
|
266
274
|
} catch (error) { rollbackErrors.push(error); }
|
|
267
275
|
}
|
|
268
|
-
if (
|
|
276
|
+
if (attachmentAttempted && sessionIds(workspace).includes(entry.destinationId)) {
|
|
269
277
|
try { await workspace.detachSession(entry.destinationId); }
|
|
270
278
|
catch (error) { rollbackErrors.push(error); }
|
|
271
279
|
}
|
|
272
|
-
if (
|
|
280
|
+
if (createAttempted) {
|
|
273
281
|
try { await rollbackCreated(location, entry.destinationId); }
|
|
274
282
|
catch (error) { rollbackErrors.push(error); }
|
|
275
283
|
}
|
package/lib/import.js
CHANGED
|
@@ -210,9 +210,22 @@ function requireString(value, path, errors, { nullable = false } = {}) {
|
|
|
210
210
|
if (typeof value !== 'string' || value === '') errors.push(error('field-invalid', path, `${path} must be a non-empty string`));
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
function
|
|
213
|
+
function canonicalTimestamp(value) {
|
|
214
|
+
if (typeof value !== 'string') return false;
|
|
215
|
+
const parsed = new Date(value);
|
|
216
|
+
return Number.isFinite(parsed.valueOf()) && parsed.toISOString() === value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function requireTimestamp(value, path, errors, { nullable = false } = {}) {
|
|
220
|
+
if (nullable && value === null) return;
|
|
221
|
+
if (!canonicalTimestamp(value)) errors.push(error('field-invalid', path, `${path} must be a canonical timestamp`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function requireDateNumber(value, path, errors, { nullable = false } = {}) {
|
|
214
225
|
if (nullable && value === null) return;
|
|
215
|
-
if (!Number.
|
|
226
|
+
if (!Number.isSafeInteger(value) || value < 0 || !Number.isFinite(new Date(value).valueOf())) {
|
|
227
|
+
errors.push(error('field-invalid', path, `${path} must be a valid millisecond timestamp`));
|
|
228
|
+
}
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
function sameJson(left, right) {
|
|
@@ -240,19 +253,29 @@ function validateDescriptor(descriptor, path, errors) {
|
|
|
240
253
|
return;
|
|
241
254
|
}
|
|
242
255
|
requireString(descriptor.id, `${path}.id`, errors);
|
|
256
|
+
if (forbiddenKeys.has(descriptor.id)) errors.push(error('field-invalid', `${path}.id`, 'session id is unsafe'));
|
|
243
257
|
requireString(descriptor.title, `${path}.title`, errors, { nullable: true });
|
|
244
|
-
|
|
258
|
+
requireDateNumber(descriptor.createdAt, `${path}.createdAt`, errors, { nullable: true });
|
|
245
259
|
requireString(descriptor.origin, `${path}.origin`, errors, { nullable: true });
|
|
246
|
-
|
|
260
|
+
requireTimestamp(descriptor.metadataUpdatedAt, `${path}.metadataUpdatedAt`, errors, { nullable: true });
|
|
247
261
|
if (!Array.isArray(descriptor.tags) || descriptor.tags.some((tag) => typeof tag !== 'string')) errors.push(error('metadata-invalid', `${path}.tags`, 'tags must be an array of strings'));
|
|
248
262
|
if (typeof descriptor.note !== 'string' && descriptor.note !== null) errors.push(error('metadata-invalid', `${path}.note`, 'note must be a string or null'));
|
|
249
263
|
if (!isObject(descriptor.workspace) && descriptor.workspace !== null) errors.push(error('field-invalid', `${path}.workspace`, 'workspace must be an object or null'));
|
|
264
|
+
else if (isObject(descriptor.workspace)) {
|
|
265
|
+
const keys = Object.keys(descriptor.workspace).sort();
|
|
266
|
+
if (!sameJson(keys, ['id', 'title'])) errors.push(error('field-invalid', `${path}.workspace`, 'workspace fields are unsupported'));
|
|
267
|
+
if (descriptor.workspace.id !== null && (typeof descriptor.workspace.id !== 'string' || descriptor.workspace.id === '')) errors.push(error('field-invalid', `${path}.workspace.id`, 'workspace.id must be a string or null'));
|
|
268
|
+
if (descriptor.workspace.title !== null && typeof descriptor.workspace.title !== 'string') errors.push(error('field-invalid', `${path}.workspace.title`, 'workspace.title must be a string or null'));
|
|
269
|
+
}
|
|
250
270
|
if (!isObject(descriptor.storage)) errors.push(error('field-invalid', `${path}.storage`, 'storage must be an object'));
|
|
251
271
|
else {
|
|
252
272
|
if (!['ready', 'unavailable'].includes(descriptor.storage.status)) errors.push(error('field-invalid', `${path}.storage.status`, 'storage status is unsupported'));
|
|
253
273
|
if (descriptor.storage.status === 'ready') {
|
|
254
|
-
|
|
255
|
-
|
|
274
|
+
if (!Number.isSafeInteger(descriptor.storage.sizeBytes) || descriptor.storage.sizeBytes < 0) errors.push(error('field-invalid', `${path}.storage.sizeBytes`, 'storage size must be a nonnegative safe integer'));
|
|
275
|
+
if (!Number.isSafeInteger(descriptor.storage.fileCount) || descriptor.storage.fileCount < 0) errors.push(error('field-invalid', `${path}.storage.fileCount`, 'storage file count must be a nonnegative safe integer'));
|
|
276
|
+
} else if (descriptor.storage.status === 'unavailable'
|
|
277
|
+
&& (descriptor.storage.sizeBytes !== null || descriptor.storage.fileCount !== null)) {
|
|
278
|
+
errors.push(error('field-invalid', `${path}.storage`, 'unavailable storage sizes must be null'));
|
|
256
279
|
}
|
|
257
280
|
}
|
|
258
281
|
if (!isObject(descriptor.files)) errors.push(error('field-invalid', `${path}.files`, 'files must be an object'));
|
|
@@ -262,6 +285,35 @@ function validateDescriptor(descriptor, path, errors) {
|
|
|
262
285
|
}
|
|
263
286
|
}
|
|
264
287
|
|
|
288
|
+
function validateSource(record, descriptor, jsonName, errors) {
|
|
289
|
+
if (!isObject(record.source)) {
|
|
290
|
+
errors.push(error('source-invalid', `${jsonName}.source`, 'session source must be an object'));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const sourceKeys = Object.keys(record.source).sort();
|
|
294
|
+
if (!sameJson(sourceKeys, ['events', 'meta'])) errors.push(error('source-invalid', `${jsonName}.source`, 'session source fields are unsupported'));
|
|
295
|
+
if (!isObject(record.source.meta)) {
|
|
296
|
+
errors.push(error('source-invalid', `${jsonName}.source.meta`, 'session source meta must be an object'));
|
|
297
|
+
} else if (record.source.meta.id !== descriptor.id) {
|
|
298
|
+
errors.push(error('session-mismatch', `${jsonName}.source.meta.id`, 'source meta id does not match session id'));
|
|
299
|
+
}
|
|
300
|
+
if (!Array.isArray(record.source.events)) {
|
|
301
|
+
errors.push(error('source-invalid', `${jsonName}.source.events`, 'session source events must be an array'));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (let index = 0; index < record.source.events.length; index += 1) {
|
|
305
|
+
const eventValue = record.source.events[index];
|
|
306
|
+
const path = `${jsonName}.source.events[${index}]`;
|
|
307
|
+
if (!isObject(eventValue)) {
|
|
308
|
+
errors.push(error('source-invalid', path, 'session events must be objects'));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (Object.hasOwn(eventValue, 'time') && eventValue.time !== null) {
|
|
312
|
+
requireDateNumber(eventValue.time, `${path}.time`, errors);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
function normalizeWarnings(item, record) {
|
|
266
318
|
const warnings = [];
|
|
267
319
|
if (hasAttachment(record.source?.events)) warnings.push('attachments-not-included');
|
|
@@ -300,6 +352,12 @@ export function inspectImport(source, options = {}) {
|
|
|
300
352
|
if (manifest.attachmentsIncluded !== false) throw Object.assign(new Error('attachment bytes are not accepted'), { importError: error('attachments-unsupported', 'manifest.attachmentsIncluded', 'version-one packages must not include attachment bytes') });
|
|
301
353
|
|
|
302
354
|
const errors = [];
|
|
355
|
+
requireTimestamp(manifest.exportedAt, 'manifest.exportedAt', errors);
|
|
356
|
+
if (!isObject(manifest.generator)
|
|
357
|
+
|| typeof manifest.generator.name !== 'string' || manifest.generator.name === ''
|
|
358
|
+
|| typeof manifest.generator.version !== 'string' || manifest.generator.version === '') {
|
|
359
|
+
errors.push(error('field-invalid', 'manifest.generator', 'generator name and version are required'));
|
|
360
|
+
}
|
|
303
361
|
const ids = new Set();
|
|
304
362
|
const referenced = new Set(['manifest.json']);
|
|
305
363
|
const items = [];
|
|
@@ -332,7 +390,9 @@ export function inspectImport(source, options = {}) {
|
|
|
332
390
|
errors.push(error('session-mismatch', jsonName, 'session JSON format, version, or archive.id does not match manifest'));
|
|
333
391
|
continue;
|
|
334
392
|
}
|
|
393
|
+
requireTimestamp(record.exportedAt, `${jsonName}.exportedAt`, errors);
|
|
335
394
|
if (!sameJson(record.archive, descriptor)) errors.push(error('session-mismatch', jsonName, 'manifest descriptor does not match session archive descriptor'));
|
|
395
|
+
validateSource(record, descriptor, jsonName, errors);
|
|
336
396
|
try { normalizeMetadata({ tags: descriptor.tags, note: descriptor.note ?? '' }); }
|
|
337
397
|
catch { errors.push(error('metadata-invalid', `${path}.tags`, 'tags or note exceed metadata limits')); }
|
|
338
398
|
items.push({
|
|
@@ -348,7 +408,6 @@ export function inspectImport(source, options = {}) {
|
|
|
348
408
|
hasAttachmentReferences: hasAttachment(record.source?.events),
|
|
349
409
|
warnings: normalizeWarnings(descriptor, record),
|
|
350
410
|
record,
|
|
351
|
-
jsonBytes,
|
|
352
411
|
transcriptSizeBytes: markdownBytes.byteLength,
|
|
353
412
|
});
|
|
354
413
|
}
|