@reed-io/dsh-workspace-move 0.0.0-stage → 0.1.0
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +107 -2
- package/README.zh-CN.md +84 -0
- package/cordis.patch.yml +3 -0
- package/icon/icon.svg +7 -0
- package/lib/artifact.js +236 -0
- package/lib/client.js +534 -0
- package/lib/directory-browse.js +98 -0
- package/lib/engine.js +263 -0
- package/lib/fsx.js +250 -0
- package/lib/host-api-client.js +41 -0
- package/lib/host-api.js +159 -0
- package/lib/index.js +7 -0
- package/lib/paths.js +99 -0
- package/lib/plan.js +156 -0
- package/lib/plugin.js +159 -0
- package/lib/registry-ops.js +177 -0
- package/lib/sessions.js +266 -0
- package/locale/en.json +44 -0
- package/locale/zh.json +44 -0
- package/package.json +91 -4
- package/src/client.js +618 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Move a workspace folder, its tree, its nested workspace registrations and its
|
|
6
|
+
sessions to a new location from the workspace context menu.
|
|
7
|
+
- Rename the workspace by giving the folder a new name at the destination.
|
|
8
|
+
- Rewrite session logs in place (header `cwd` only) so history and titles survive
|
|
9
|
+
byte for byte.
|
|
10
|
+
- Refuse moves while any of the workspace's sessions is running, on destination
|
|
11
|
+
collisions, and on names this platform rejects.
|
|
12
|
+
- Roll a failed move back: folder, registrations and sessions return to where they
|
|
13
|
+
were.
|
|
14
|
+
- Undo the last move of the current run from the toast or the workspace menu.
|
|
15
|
+
- Expose the same engine as the `move_workspace` agent tool.
|
|
16
|
+
- Chinese and English UI.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 reed-io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @reed-io/dsh-workspace-move
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Move a DeepSeek Harness workspace — the folder itself, everything inside it, the
|
|
4
|
+
workspaces registered inside it, and the sessions stored for it — to another
|
|
5
|
+
place on disk, from the workspace menu. It behaves like `mv`: one target path, one
|
|
6
|
+
atomic result. Changing the folder name renames the workspace at the same time.
|
|
7
|
+
|
|
8
|
+
## What a move carries
|
|
9
|
+
|
|
10
|
+
| Carried with the workspace | How |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| The workspace folder and its whole tree | One rename on the same volume; copy, verify, then delete the source across volumes. |
|
|
13
|
+
| Workspaces registered inside that folder | Their registered paths are rewritten to follow the folder. Registrations, titles and session lists are kept. |
|
|
14
|
+
| Sessions listed in the workspace | Each session log moves to the destination project directory and its stored `cwd` header is rewritten in place, so history and titles are kept byte for byte. |
|
|
15
|
+
| The workspace title | Only when the moving folder also renames the workspace and the title was still the folder name. |
|
|
16
|
+
|
|
17
|
+
Nothing else moves. The source folder is deleted only when the move emptied it,
|
|
18
|
+
and its parent folder is never touched.
|
|
19
|
+
|
|
20
|
+
## Use it
|
|
21
|
+
|
|
22
|
+
Right-click a workspace in the sidebar (or use its `…` button) and choose
|
|
23
|
+
**Move workspace…**. Pick the destination folder in the built-in browser, type
|
|
24
|
+
the folder name the workspace should have there, review what travels with it, and
|
|
25
|
+
confirm.
|
|
26
|
+
|
|
27
|
+
A move is refused, with a reason, when:
|
|
28
|
+
|
|
29
|
+
- a workspace folder already exists at the destination — pick another name;
|
|
30
|
+
- the destination is inside the workspace, or the workspace is inside the
|
|
31
|
+
destination's own path;
|
|
32
|
+
- any of the sessions still has work running — stop them first;
|
|
33
|
+
- the name is not a legal folder name on this platform.
|
|
34
|
+
|
|
35
|
+
After a successful move the toast offers **Undo**, and the workspace menu offers
|
|
36
|
+
**Undo last move** while the move is still the most recent one of this run. Undo
|
|
37
|
+
is kept in memory only: it lasts until DeepSeek Harness restarts.
|
|
38
|
+
|
|
39
|
+
A move either completes or fails as a whole. If any step fails, the plugin rolls
|
|
40
|
+
back the folder, the registrations and every session it had already touched, and
|
|
41
|
+
reports the failure.
|
|
42
|
+
|
|
43
|
+
## Agent tool
|
|
44
|
+
|
|
45
|
+
The same engine is one tool, so an agent can do it too:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
move_workspace(workspaceId, targetParent, folderName?)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`folderName` defaults to the current folder name. The tool reports the final
|
|
52
|
+
destination, how many sessions and nested workspaces came along, and whether the
|
|
53
|
+
move can still be undone.
|
|
54
|
+
|
|
55
|
+
## Behaviour notes
|
|
56
|
+
|
|
57
|
+
- The move runs in the Host process, which owns both the workspace registry and
|
|
58
|
+
the session store; the browser only drives it.
|
|
59
|
+
- Sessions are rewritten in place: only the compressed header frame carrying
|
|
60
|
+
`cwd` is re-encoded, every event frame is copied byte for byte.
|
|
61
|
+
- A moved session is re-attached to its workspace after its log has landed at the
|
|
62
|
+
new path, so the workspace keeps its session order.
|
|
63
|
+
- Running DeepSeek Harness does not need a restart after a move for the moved
|
|
64
|
+
sessions to be usable; a session that is itself currently running cannot be
|
|
65
|
+
moved, which the running-work check enforces.
|
|
66
|
+
- Developed and verified on Windows first. The path handling, volume detection
|
|
67
|
+
and folder-name rules are platform-aware, so macOS and Linux use the same code
|
|
68
|
+
path — with the native shared-folder case handled by the copy-and-verify branch.
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- DeepSeek Harness `>=0.2.0-rc.1 <0.3.0-0`
|
|
73
|
+
- Node `^22.19.0 || >=24.0.0`
|
|
74
|
+
- A local (owner) desktop session: the destination dialog talks to the Host over a
|
|
75
|
+
loopback-only route.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
Requires a running DeepSeek Harness. Replace `desktop` with the profile you use
|
|
80
|
+
(`web`, `desktop`, …).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
dsh plugin --profile desktop add @reed-io/dsh-workspace-move@latest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or add the bundle to a profile's `dsh.profile.bundles` and restart DeepSeek
|
|
87
|
+
Harness. After installing, reload the page (`Ctrl+R`) so the sidebar picks up the
|
|
88
|
+
new menu entries.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run build # src/client.js -> lib/client.js (browser bundle)
|
|
94
|
+
npm run check # build, then verify the client bundle contract and containment
|
|
95
|
+
npm test # unit tests: path encoding, folder rules, session log rewriting
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`lib/client.js` is generated — edit `src/client.js` instead. The Host half
|
|
99
|
+
(`lib/*.js` except `client.js`) is plain ESM, loaded straight by Node.
|
|
100
|
+
|
|
101
|
+
A separate end-to-end harness drives real Cordis services (move, rename, refusal,
|
|
102
|
+
rollback, undo) against a scratch session store. It needs the Harness packages
|
|
103
|
+
resolvable from `node_modules`, so it lives in the development checkout rather
|
|
104
|
+
than in a fresh clone.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @reed-io/dsh-workspace-move
|
|
2
|
+
|
|
3
|
+
把 DeepSeek Harness 的工作区连同它的文件夹、文件夹里的全部内容、其下注册的子工作区以及它保存的会话,一起搬到磁盘上的另一个位置。行为类似 `mv`:一个目标路径,一个原子结果。修改文件夹名时,工作区也会一并重命名。
|
|
4
|
+
|
|
5
|
+
## 一次移动会带上什么
|
|
6
|
+
|
|
7
|
+
| 跟着工作区一起走的东西 | 方式 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| 工作区文件夹及其整个目录树 | 同一分区用 rename;跨分区复制、校验后再删源。 |
|
|
10
|
+
| 注册在该文件夹内的子工作区 | 它们的注册路径会跟着文件夹一起改写,注册、标题和会话列表全部保留。 |
|
|
11
|
+
| 工作区列出的会话 | 每个会话日志移动到目标项目目录,并就地改写日志头里的 `cwd`,历史与标题逐字节保留。 |
|
|
12
|
+
| 工作区标题 | 仅当这次移动同时改了文件夹名、且标题仍是文件夹名时才改。 |
|
|
13
|
+
|
|
14
|
+
除此之外什么都不动。只有当移动把源目录搬空时才会删除源目录本身,它的上级目录永远不会被触碰。
|
|
15
|
+
|
|
16
|
+
## 使用方法
|
|
17
|
+
|
|
18
|
+
在侧边栏右键工作区(或点它的 `…` 按钮),选择 **移动工作区…**。在内置的目录浏览器里选目标文件夹,填写工作区在新位置的文件夹名,确认要一起搬走的内容,然后确认。
|
|
19
|
+
|
|
20
|
+
以下情况会拒绝移动并给出原因:
|
|
21
|
+
|
|
22
|
+
- 目标位置已经存在同名文件夹 —— 换个名字;
|
|
23
|
+
- 目标在工作区内部,或工作区在目标自身的路径链里;
|
|
24
|
+
- 还有会话正在运行 —— 先停掉它们;
|
|
25
|
+
- 名字在这个平台上不是合法文件夹名。
|
|
26
|
+
|
|
27
|
+
移动成功后,浮动提示会提供 **撤销**;只要该次移动仍是本轮最近的一次,工作区菜单也会提供 **撤销上次移动**。撤销记录只保存在内存中,Harness 重启后失效。
|
|
28
|
+
|
|
29
|
+
一次移动要么完整完成,要么整体失败。任何一步出错,插件都会把文件夹、注册信息和已经动过的会话全部回滚,并报告失败原因。
|
|
30
|
+
|
|
31
|
+
## Agent 工具
|
|
32
|
+
|
|
33
|
+
同一套引擎也注册为一个工具,Agent 可以直接调用:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
move_workspace(workspaceId, targetParent, folderName?)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`folderName` 省略时沿用原文件夹名。工具会返回最终位置、一起搬走的会话数与子工作区数,以及这次移动是否还能撤销。
|
|
40
|
+
|
|
41
|
+
## 行为说明
|
|
42
|
+
|
|
43
|
+
- 移动在 Host 进程内完成,工作区注册表和会话存储都由 Host 拥有;浏览器端只负责驱动。
|
|
44
|
+
- 会话是就地改写:只重新压缩承载 `cwd` 的头部帧,其余事件帧逐字节复制。
|
|
45
|
+
- 会话日志落到新路径后,再重新挂回工作区,因此工作区的会话顺序保持不变。
|
|
46
|
+
- 移动后不需要重启 Harness 才能使用被移动的会话;正在运行的那个会话本身不能被移动,运行检查会拦住它。
|
|
47
|
+
- 首先在 Windows 上开发与验证。路径处理、分区判定和文件夹命名规则都按平台区分,macOS 与 Linux 走同一套代码路径;原生共享文件夹的情况由"复制后校验"分支处理。
|
|
48
|
+
|
|
49
|
+
## 依赖
|
|
50
|
+
|
|
51
|
+
- DeepSeek Harness `>=0.2.0-rc.1 <0.3.0-0`
|
|
52
|
+
- Node `^22.19.0 || >=24.0.0`
|
|
53
|
+
- 本机(owner)桌面会话:目标选择对话框通过一条仅限回环地址的路由与 Host 通信。
|
|
54
|
+
|
|
55
|
+
## 安装
|
|
56
|
+
|
|
57
|
+
需要已安装并能正常运行 DeepSeek Harness。下面的 `desktop` 请换成你实际使用的
|
|
58
|
+
profile(`web`、`desktop`……)。
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile desktop add @reed-io/dsh-workspace-move@latest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
或者把该 bundle 加入某个 profile 的 `dsh.profile.bundles`,然后重启 DeepSeek Harness。
|
|
65
|
+
安装后刷新页面(`Ctrl+R`),侧边栏才会出现新的菜单项。
|
|
66
|
+
|
|
67
|
+
## 开发
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm run build # src/client.js -> lib/client.js(浏览器 bundle)
|
|
71
|
+
npm run check # 构建后校验客户端 bundle 契约与失败隔离
|
|
72
|
+
npm test # 单元测试:路径编码、文件夹命名规则、会话日志改写
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`lib/client.js` 是构建产物,请改 `src/client.js`。宿主侧(`lib/*.js` 中除
|
|
76
|
+
`client.js` 外)是普通 ESM,由 Node 直接加载。
|
|
77
|
+
|
|
78
|
+
另有一套端到端 harness,会拉起真实的 Cordis 服务,在临时会话存储上跑移动、重命名、
|
|
79
|
+
拒绝、回滚与撤销。它需要 `node_modules` 里能解析到 Harness 的包,因此留在开发用的
|
|
80
|
+
检出目录里,不随新克隆的仓库分发。
|
|
81
|
+
|
|
82
|
+
## 许可证
|
|
83
|
+
|
|
84
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/icon/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
|
2
|
+
<rect x="3" y="3" width="42" height="42" rx="11" fill="#eaf1f8"/>
|
|
3
|
+
<rect x="9" y="19" width="12" height="18" rx="2.6" fill="#4f86b8"/>
|
|
4
|
+
<path d="M9 24V14.6c0-1.4 1.1-2.6 2.6-2.6h4.6c.9 0 1.7.5 2.2 1.2l1.2 1.8h-8.6c-.7 0-1.2.4-1.2 1.1V24z" fill="#4f86b8"/>
|
|
5
|
+
<path d="M25 28h9.2" stroke="#6ea8d8" stroke-width="4.2" stroke-linecap="round"/>
|
|
6
|
+
<path d="M31.4 22.2 38 28l-6.6 5.8" fill="none" stroke="#6ea8d8" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
7
|
+
</svg>
|
package/lib/artifact.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Physical rewriting of one session artifact when its working directory changes.
|
|
3
|
+
*
|
|
4
|
+
* Only the header frame is re-encoded — it carries the single `cwd` field the
|
|
5
|
+
* backend uses to locate and validate the log. Every other byte, including every
|
|
6
|
+
* event record inside the payload frame, is preserved verbatim, so recorded
|
|
7
|
+
* history keeps saying what actually ran and the artifact stays a valid log for
|
|
8
|
+
* the backend's own scanner.
|
|
9
|
+
* @module @reed-io/dsh-workspace-move
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { constants, zstdCompress, zstdDecompressSync } from "node:zlib";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
|
|
16
|
+
const zstdCompressAsync = promisify(zstdCompress);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decompress one complete zstd frame. zlib's async decode can answer an empty
|
|
20
|
+
* buffer for a frame smaller than its output chunk, so the frame is decoded in
|
|
21
|
+
* place; frames here are single header lines, never large.
|
|
22
|
+
* @param frame - one complete zstd frame.
|
|
23
|
+
* @returns the decompressed bytes.
|
|
24
|
+
*/
|
|
25
|
+
function zstdDecompressFrame(frame, options = undefined) {
|
|
26
|
+
return Promise.resolve(zstdDecompressSync(frame, options));
|
|
27
|
+
}
|
|
28
|
+
const ZSTD_MAGIC = 4247762216;
|
|
29
|
+
const CHECKSUM_OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect zstd framing from the bytes themselves — staging files may not carry the
|
|
33
|
+
* `.zstd` suffix while a rewrite is in flight.
|
|
34
|
+
* @param buffer - artifact bytes.
|
|
35
|
+
* @returns whether the artifact starts with a zstd frame.
|
|
36
|
+
*/
|
|
37
|
+
export function isZstdFramed(buffer) {
|
|
38
|
+
return buffer.length >= 4 && buffer.readUInt32LE(0) === ZSTD_MAGIC;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Measure every complete Zstandard frame in a buffer without decoding them.
|
|
43
|
+
* @param buffer - artifact bytes.
|
|
44
|
+
* @returns frame boundaries plus the offset of a torn trailing frame, if any.
|
|
45
|
+
*/
|
|
46
|
+
export function scanFrames(buffer) {
|
|
47
|
+
const frames = [];
|
|
48
|
+
let start = 0;
|
|
49
|
+
while (start < buffer.length) {
|
|
50
|
+
let offset = start;
|
|
51
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
52
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
53
|
+
if (frames.length === 0) throw new Error("session artifact is not zstd framed");
|
|
54
|
+
return { frames, tornStart: start };
|
|
55
|
+
}
|
|
56
|
+
offset += 4;
|
|
57
|
+
if (buffer.length - offset < 1) return { frames, tornStart: start };
|
|
58
|
+
const descriptor = buffer.readUInt8(offset);
|
|
59
|
+
offset += 1;
|
|
60
|
+
if ((descriptor & 24) !== 0) throw new Error("zstd frame carries reserved bits");
|
|
61
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
62
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
63
|
+
const checksum = (descriptor & 4) !== 0;
|
|
64
|
+
const dictionaryFlag = descriptor & 3;
|
|
65
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
66
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
|
|
67
|
+
offset += (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
68
|
+
if (offset > buffer.length) return { frames, tornStart: start };
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (buffer.length - offset < 3) return { frames, tornStart: start };
|
|
71
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
72
|
+
offset += 3;
|
|
73
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
74
|
+
const blockType = (blockHeader >>> 1) & 3;
|
|
75
|
+
const blockSize = blockHeader >>> 3;
|
|
76
|
+
if (blockType === 3) throw new Error("zstd frame holds a reserved block type");
|
|
77
|
+
offset += blockType === 1 ? 1 : blockSize;
|
|
78
|
+
if (offset > buffer.length) return { frames, tornStart: start };
|
|
79
|
+
if (lastBlock) break;
|
|
80
|
+
}
|
|
81
|
+
if (checksum) {
|
|
82
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
83
|
+
offset += 4;
|
|
84
|
+
}
|
|
85
|
+
frames.push({ start, end: offset });
|
|
86
|
+
start = offset;
|
|
87
|
+
}
|
|
88
|
+
return { frames };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Split an artifact into its header line text and the untouched byte tail.
|
|
93
|
+
* @param buffer - artifact bytes.
|
|
94
|
+
* @param compressed - whether the artifact is zstd framed.
|
|
95
|
+
* @returns the header text (without its newline) and the tail to preserve.
|
|
96
|
+
*/
|
|
97
|
+
async function splitHeader(buffer, compressed) {
|
|
98
|
+
if (!compressed) {
|
|
99
|
+
const newline = buffer.indexOf(10);
|
|
100
|
+
const text = (newline < 0 ? buffer : buffer.subarray(0, newline)).toString("utf8");
|
|
101
|
+
return { text, tail: newline < 0 ? Buffer.alloc(0) : buffer.subarray(newline + 1) };
|
|
102
|
+
}
|
|
103
|
+
const { frames } = scanFrames(buffer);
|
|
104
|
+
if (frames.length === 0) throw new Error("session artifact holds no complete zstd frame");
|
|
105
|
+
const first = frames[0];
|
|
106
|
+
const text = (await zstdDecompressFrame(buffer.subarray(first.start, first.end))).toString("utf8");
|
|
107
|
+
return { text: text.replace(/\n$/u, ""), tail: buffer.subarray(first.end) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the header of an artifact this module just wrote.
|
|
112
|
+
* @param path - artifact path.
|
|
113
|
+
* @returns the parsed header.
|
|
114
|
+
*/
|
|
115
|
+
async function readTranscodedHeader(path) {
|
|
116
|
+
const buffer = await readFile(path);
|
|
117
|
+
const { frames } = scanFrames(buffer);
|
|
118
|
+
const text = (await zstdDecompressFrame(buffer.subarray(frames[0].start, frames[0].end))).toString("utf8");
|
|
119
|
+
return JSON.parse(text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read one artifact's header line as an object.
|
|
124
|
+
* @param path - artifact path.
|
|
125
|
+
* @returns the parsed header.
|
|
126
|
+
*/
|
|
127
|
+
export async function readArtifactHeader(path) {
|
|
128
|
+
const buffer = await readFile(path);
|
|
129
|
+
const { text } = await splitHeader(buffer, isZstdFramed(buffer));
|
|
130
|
+
return JSON.parse(text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Re-encode one header line in the artifact's own framing.
|
|
135
|
+
* @param header - header object to encode.
|
|
136
|
+
* @param compressed - whether the artifact is zstd framed.
|
|
137
|
+
* @returns encoded header frame including its newline.
|
|
138
|
+
*/
|
|
139
|
+
async function encodeHeader(header, compressed) {
|
|
140
|
+
const line = Buffer.from(`${JSON.stringify(header)}\n`, "utf8");
|
|
141
|
+
return compressed ? zstdCompressAsync(line, CHECKSUM_OPTS) : line;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rewrite one artifact's header cwd in place, keeping its payload byte-identical.
|
|
146
|
+
* @param path - artifact path.
|
|
147
|
+
* @param cwd - replacement working directory.
|
|
148
|
+
* @param expectedId - session id the artifact must carry.
|
|
149
|
+
* @returns whether the artifact changed.
|
|
150
|
+
*/
|
|
151
|
+
export async function rewriteArtifactCwd(path, cwd, expectedId) {
|
|
152
|
+
const buffer = await readFile(path);
|
|
153
|
+
const compressed = isZstdFramed(buffer);
|
|
154
|
+
const { text, tail } = await splitHeader(buffer, compressed);
|
|
155
|
+
const header = JSON.parse(text);
|
|
156
|
+
if (header.id !== expectedId) throw new Error(`session artifact '${path}' holds '${String(header.id)}', expected '${expectedId}'`);
|
|
157
|
+
if (header.cwd === cwd) return false;
|
|
158
|
+
const stage = join(dirname(path), `.stage-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
159
|
+
const bytes = Buffer.concat([await encodeHeader({ ...header, cwd }, compressed), tail]);
|
|
160
|
+
try {
|
|
161
|
+
await writeFile(stage, bytes);
|
|
162
|
+
await rename(stage, path);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await rm(stage, { force: true }).catch(() => {});
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
const written = await readArtifactHeader(path);
|
|
168
|
+
if (written.id !== expectedId || written.cwd !== cwd) throw new Error(`session artifact '${path}' did not accept cwd '${cwd}'`);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Write one session's artifact at a new path with a rewritten header, then read
|
|
174
|
+
* it back. Fails when the target already exists.
|
|
175
|
+
* @param source - original artifact path.
|
|
176
|
+
* @param target - new artifact path (its parent must exist).
|
|
177
|
+
* @param cwd - replacement working directory.
|
|
178
|
+
* @param expectedId - session id the artifact must carry.
|
|
179
|
+
* @returns nothing; throws when the copy cannot be verified.
|
|
180
|
+
*/
|
|
181
|
+
export async function copyArtifactWithCwd(source, target, cwd, expectedId) {
|
|
182
|
+
const buffer = await readFile(source);
|
|
183
|
+
const compressed = isZstdFramed(buffer);
|
|
184
|
+
const { text, tail } = await splitHeader(buffer, compressed);
|
|
185
|
+
const header = JSON.parse(text);
|
|
186
|
+
if (header.id !== expectedId) throw new Error(`session artifact '${source}' holds '${String(header.id)}', expected '${expectedId}'`);
|
|
187
|
+
await writeFile(target, Buffer.concat([await encodeHeader({ ...header, cwd }, compressed), tail]), { flag: "wx" });
|
|
188
|
+
const reread = await readFile(target);
|
|
189
|
+
const written = JSON.parse((await splitHeader(reread, isZstdFramed(reread))).text);
|
|
190
|
+
if (written.id !== expectedId || written.cwd !== cwd) throw new Error(`copied session artifact '${target}' did not accept cwd '${cwd}'`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Decode and re-encode one artifact into a new file, rewriting its header cwd and
|
|
195
|
+
* re-compressing every record — the fallback for an artifact whose payload could
|
|
196
|
+
* not be copied verbatim after a provable header rewrite.
|
|
197
|
+
* @param source - original artifact path.
|
|
198
|
+
* @param target - new artifact path (its parent must exist, file must not exist).
|
|
199
|
+
* @param cwd - replacement working directory.
|
|
200
|
+
* @param expectedId - session id the artifact must carry.
|
|
201
|
+
* @returns the number of event records re-encoded.
|
|
202
|
+
*/
|
|
203
|
+
export async function transcodeArtifactWithCwd(source, target, cwd, expectedId) {
|
|
204
|
+
const buffer = await readFile(source);
|
|
205
|
+
const compressed = isZstdFramed(buffer);
|
|
206
|
+
const { text } = await splitHeader(buffer, compressed);
|
|
207
|
+
const header = JSON.parse(text);
|
|
208
|
+
if (header.id !== expectedId) throw new Error(`session artifact '${source}' holds '${String(header.id)}', expected '${expectedId}'`);
|
|
209
|
+
let plain;
|
|
210
|
+
if (compressed) {
|
|
211
|
+
const { frames, tornStart } = scanFrames(buffer);
|
|
212
|
+
const parts = [];
|
|
213
|
+
for (const frame of frames) parts.push(await zstdDecompressFrame(buffer.subarray(frame.start, frame.end)));
|
|
214
|
+
plain = Buffer.concat(parts).toString("utf8");
|
|
215
|
+
if (tornStart !== undefined) plain += (await zstdDecompressFrame(buffer.subarray(tornStart), { finishFlush: constants.ZSTD_e_flush })).toString("utf8");
|
|
216
|
+
} else {
|
|
217
|
+
plain = buffer.toString("utf8");
|
|
218
|
+
}
|
|
219
|
+
const newline = plain.indexOf("\n");
|
|
220
|
+
const body = newline < 0 ? "" : plain.slice(newline + 1).replace(/\n$/u, "");
|
|
221
|
+
const records = body === "" ? [] : body.split("\n");
|
|
222
|
+
for (const record of records) JSON.parse(record);
|
|
223
|
+
const bytes = [await encodeHeader({ ...header, cwd }, compressed)];
|
|
224
|
+
if (records.length > 0) {
|
|
225
|
+
const payload = Buffer.from(`${records.join("\n")}\n`, "utf8");
|
|
226
|
+
bytes.push(compressed ? await zstdCompressAsync(payload, CHECKSUM_OPTS) : payload);
|
|
227
|
+
}
|
|
228
|
+
const staged = `${target}.stage-${process.pid}-${Date.now()}`;
|
|
229
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
230
|
+
await writeFile(staged, Buffer.concat(bytes), { flag: "wx" });
|
|
231
|
+
await rm(target, { force: true });
|
|
232
|
+
await rename(staged, target);
|
|
233
|
+
const written = await readTranscodedHeader(target);
|
|
234
|
+
if (written.id !== expectedId || written.cwd !== cwd) throw new Error(`transcoded session artifact '${target}' did not accept cwd '${cwd}'`);
|
|
235
|
+
return records.length;
|
|
236
|
+
}
|