@logicseek/hello-cc 1.0.0 → 1.0.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/CHANGELOG.md +77 -0
- package/README.md +32 -0
- package/README.zh-CN.md +31 -0
- package/docs/guide.md +58 -0
- package/docs/guide.zh-CN.md +55 -0
- package/lib/cli/app.mjs +2 -0
- package/lib/cli/commands/coordination.mjs +3 -1
- package/lib/cli/commands/gc.mjs +182 -32
- package/lib/cli/commands/misc.mjs +15 -0
- package/lib/cli/commands/peer.mjs +21 -5
- package/lib/process/identity.mjs +30 -0
- package/lib/runtime/buffer-directory-lease.mjs +2 -1
- package/lib/runtime/client.mjs +3 -0
- package/lib/shared/file-lock.mjs +11 -4
- package/lib/shared/socket-lock-worker.mjs +73 -24
- package/lib/terminal/tmux.mjs +53 -23
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/project-contexts.mjs +6 -1
- package/lib/web/runtime-main.mjs +3 -0
- package/lib/web/startup.mjs +4 -2
- package/package.json +2 -2
- package/scripts/regression.mjs +95 -98
- package/docs/architecture-split-review.md +0 -217
- package/docs/current-state-review-2026-08.md +0 -341
- package/docs/defect-review-2026-07.md +0 -382
- package/docs/layout-split-stack-audit.md +0 -298
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,83 @@ the release description from the current changelog section. Use
|
|
|
16
16
|
`npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
|
|
17
17
|
backfills.
|
|
18
18
|
|
|
19
|
+
## 1.0.1
|
|
20
|
+
|
|
21
|
+
### Summary
|
|
22
|
+
|
|
23
|
+
hello-cc 1.0.1 is a file-lock determinism, buffer-GC safety, fresh-install, and
|
|
24
|
+
release-reliability patch. It makes worker shutdown reclaim kernel lock
|
|
25
|
+
endpoints deterministically when an identity probe keeps its client write half
|
|
26
|
+
open, restores real PTY startup in fresh macOS npm installations, and keeps
|
|
27
|
+
automatic GC fail-closed when optional buffer paths disappear or change.
|
|
28
|
+
|
|
29
|
+
### Highlights
|
|
30
|
+
|
|
31
|
+
- The socket-lock worker now tracks each locally accepted socket and destroys
|
|
32
|
+
those accepted handles during server shutdown, so release no longer depends
|
|
33
|
+
on the peer closing its writable half after reading the lock banner.
|
|
34
|
+
- A real half-open identity-probe regression verifies that release returns
|
|
35
|
+
promptly, a nonblocking reacquisition succeeds immediately, and the probe is
|
|
36
|
+
explicitly stopped and awaited during final cleanup.
|
|
37
|
+
- `node-pty` is exactly pinned to `1.2.0-beta.15`. The stable `node-pty 1.1.0`
|
|
38
|
+
npm artifact contains non-executable Darwin helpers, which makes a fresh
|
|
39
|
+
macOS install fail with `posix_spawnp failed`; the selected artifact publishes
|
|
40
|
+
both Darwin helpers with executable modes.
|
|
41
|
+
- Linux, macOS, and WSL installation instructions now list Node.js 24, tmux
|
|
42
|
+
commands for the major system package managers, CLI verification, and safe
|
|
43
|
+
handling for npm global-install permission errors.
|
|
44
|
+
- `hcc down` now waits for the exact Runtime process identity to exit before it
|
|
45
|
+
reports success, including safe handling for PID reuse and a bounded timeout
|
|
46
|
+
when shutdown cannot be confirmed.
|
|
47
|
+
- Background Web startup keeps its immediate child-exit failure but allows a
|
|
48
|
+
bounded 60-second health window, avoiding false failures on loaded macOS CI
|
|
49
|
+
hosts where process identity and project restoration can exceed 30 seconds.
|
|
50
|
+
- Manual buffer GC allows a bounded 30-second window for runtime evidence
|
|
51
|
+
planning and apply, while still deferring all eligible files if the runtime
|
|
52
|
+
remains unavailable.
|
|
53
|
+
- Automatic GC now treats a missing `.hello-cc/bufs` leaf as empty without
|
|
54
|
+
recreating it. A missing state directory, a symlinked path component, an
|
|
55
|
+
inode replacement, or a retargeted project-root alias fails closed with
|
|
56
|
+
`PROJECT_PATH_FORBIDDEN` before buffer, clock, lock, or history mutation.
|
|
57
|
+
- Buffer path identity is retained through evidence collection, lease
|
|
58
|
+
acquisition, planning, clock reads, and database transactions. GC uses a
|
|
59
|
+
no-create lease while existing file-lock callers retain their original
|
|
60
|
+
parent-creation behavior.
|
|
61
|
+
- Per-connection Web action tokens now require their issuing terminal socket to
|
|
62
|
+
still be open, closing the handshake race before asynchronous token cleanup.
|
|
63
|
+
- Runtime request deadlines now reach the underlying HTTP transport, and peer
|
|
64
|
+
start/attach preflight and mutation requests use a bounded 60-second window
|
|
65
|
+
while stop and terminal-input operations use 30 seconds, instead of all
|
|
66
|
+
inheriting the shorter read-request default.
|
|
67
|
+
|
|
68
|
+
### Compatibility Notes
|
|
69
|
+
|
|
70
|
+
- No CLI command or package-surface behavior changes are intended. Schema v7,
|
|
71
|
+
Runtime API v2, the authenticated browser's ability to choose any existing
|
|
72
|
+
server directory as a project root, and the 1.0.0 accepted-risk boundaries
|
|
73
|
+
are unchanged.
|
|
74
|
+
- Stable project-root aliases remain supported. Only path disappearance,
|
|
75
|
+
replacement, symlink traversal, or alias retargeting during GC is rejected.
|
|
76
|
+
- The existing five-second fail-closed deadline and cleanup-error behavior are
|
|
77
|
+
unchanged.
|
|
78
|
+
- `node-pty@1.2.0-beta.15` is a bounded prerelease dependency risk. The exact
|
|
79
|
+
pin prevents unreviewed beta movement, and release tests verify the installed
|
|
80
|
+
version, regular-file identity, executable modes, and a real PTY. There is no
|
|
81
|
+
postinstall or CI-only `chmod` workaround.
|
|
82
|
+
|
|
83
|
+
### Validation
|
|
84
|
+
|
|
85
|
+
The 1.0.1 release gate includes repeated macOS Node 24 tests; a no-cache Linux
|
|
86
|
+
Node 24/tmux image build followed by three complete `npm test` runs in that same
|
|
87
|
+
image, each ending with `FULL_REGRESSION_OK`; and a fourth clean container that
|
|
88
|
+
installs the same 1.0.1 package tarball and passes PTY, database, Web, and
|
|
89
|
+
missing-buffer GC smoke checks. Fresh macOS consumer installs pass both normal
|
|
90
|
+
and `--ignore-scripts` installation, proving that executable helper modes come
|
|
91
|
+
from the package artifact rather than lifecycle-script mutation. GC regression
|
|
92
|
+
coverage includes absent optional leaves, symlink and ancestor replacement,
|
|
93
|
+
alias retargeting, no-create lock acquisition, and path guards around clock and
|
|
94
|
+
history writes.
|
|
95
|
+
|
|
19
96
|
## 1.0.0
|
|
20
97
|
|
|
21
98
|
### Summary
|
package/README.md
CHANGED
|
@@ -70,10 +70,42 @@ currently supported; use WSL for a Linux-like environment.
|
|
|
70
70
|
Linux has richer process auto-discovery through `/proc`. On macOS, use
|
|
71
71
|
hello-cc shims or `hcc peer start` for reliable tmux-managed terminal sessions.
|
|
72
72
|
|
|
73
|
+
Install Node.js 24 or newer from the official Node.js packages or a Node
|
|
74
|
+
version manager. Then install `tmux` for your platform:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Debian / Ubuntu
|
|
78
|
+
sudo apt-get update && sudo apt-get install -y tmux
|
|
79
|
+
# Fedora / RHEL
|
|
80
|
+
sudo dnf install -y tmux
|
|
81
|
+
# Older RHEL / CentOS
|
|
82
|
+
sudo yum install -y tmux
|
|
83
|
+
# Alpine
|
|
84
|
+
sudo apk add tmux
|
|
85
|
+
# Arch Linux
|
|
86
|
+
sudo pacman -S --needed tmux
|
|
87
|
+
# openSUSE
|
|
88
|
+
sudo zypper install tmux
|
|
89
|
+
# macOS only
|
|
90
|
+
brew install tmux
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
On Linux, use the distribution package manager; Homebrew is only the macOS
|
|
94
|
+
command above. Root shells can omit `sudo`. In WSL, run the matching Linux
|
|
95
|
+
distribution commands inside WSL. Then install and verify hello-cc:
|
|
96
|
+
|
|
73
97
|
```bash
|
|
74
98
|
npm install -g @logicseek/hello-cc
|
|
99
|
+
node --version
|
|
100
|
+
npm --version
|
|
101
|
+
tmux -V
|
|
102
|
+
hcc --version
|
|
103
|
+
hcc --help
|
|
75
104
|
```
|
|
76
105
|
|
|
106
|
+
If npm reports `EACCES`, use a Node version manager or a user-owned npm prefix;
|
|
107
|
+
do not work around it with `sudo npm install -g`.
|
|
108
|
+
|
|
77
109
|
Update an existing global install:
|
|
78
110
|
|
|
79
111
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -57,10 +57,41 @@ hello-cc 支持 Linux 和 macOS。需要 Node.js 24 或更新版本;可被浏
|
|
|
57
57
|
Linux 可以通过 `/proc` 做更完整的进程自动发现。macOS 上建议通过 hello-cc
|
|
58
58
|
shim 或 `hcc peer start` 启动会话,以获得可靠的 tmux 托管终端。
|
|
59
59
|
|
|
60
|
+
先通过 Node.js 官方软件包或 Node 版本管理器安装 Node.js 24 或更新版本,再按
|
|
61
|
+
系统安装 `tmux`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Debian / Ubuntu
|
|
65
|
+
sudo apt-get update && sudo apt-get install -y tmux
|
|
66
|
+
# Fedora / RHEL
|
|
67
|
+
sudo dnf install -y tmux
|
|
68
|
+
# 旧版 RHEL / CentOS
|
|
69
|
+
sudo yum install -y tmux
|
|
70
|
+
# Alpine
|
|
71
|
+
sudo apk add tmux
|
|
72
|
+
# Arch Linux
|
|
73
|
+
sudo pacman -S --needed tmux
|
|
74
|
+
# openSUSE
|
|
75
|
+
sudo zypper install tmux
|
|
76
|
+
# 仅 macOS
|
|
77
|
+
brew install tmux
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Linux 必须使用当前发行版的系统包管理器,而不是 Homebrew。root shell 可以省略
|
|
81
|
+
`sudo`。WSL 用户应在 WSL 内部执行对应 Linux 发行版的命令。然后安装并验证:
|
|
82
|
+
|
|
60
83
|
```bash
|
|
61
84
|
npm install -g @logicseek/hello-cc
|
|
85
|
+
node --version
|
|
86
|
+
npm --version
|
|
87
|
+
tmux -V
|
|
88
|
+
hcc --version
|
|
89
|
+
hcc --help
|
|
62
90
|
```
|
|
63
91
|
|
|
92
|
+
如果 npm 报 `EACCES`,请使用 Node 版本管理器或用户自有的 npm prefix,不要用
|
|
93
|
+
`sudo npm install -g` 绕过权限问题。
|
|
94
|
+
|
|
64
95
|
更新已有的全局安装:
|
|
65
96
|
|
|
66
97
|
```bash
|
package/docs/guide.md
CHANGED
|
@@ -40,12 +40,70 @@ the supported downgrade contract.
|
|
|
40
40
|
|
|
41
41
|
## Install, Update, And Uninstall
|
|
42
42
|
|
|
43
|
+
### Prerequisites
|
|
44
|
+
|
|
45
|
+
Install Node.js 24 or newer from the official Node.js packages or a Node
|
|
46
|
+
version manager. Browser-controllable terminals also require `tmux`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Debian / Ubuntu
|
|
50
|
+
sudo apt-get update
|
|
51
|
+
sudo apt-get install -y tmux
|
|
52
|
+
# Fedora / RHEL
|
|
53
|
+
sudo dnf install -y tmux
|
|
54
|
+
# Older RHEL / CentOS
|
|
55
|
+
sudo yum install -y tmux
|
|
56
|
+
# Alpine
|
|
57
|
+
sudo apk add tmux
|
|
58
|
+
# Arch Linux
|
|
59
|
+
sudo pacman -S --needed tmux
|
|
60
|
+
# openSUSE
|
|
61
|
+
sudo zypper install tmux
|
|
62
|
+
# macOS only
|
|
63
|
+
brew install tmux
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
On Linux, use the distribution package manager, not Homebrew. A root shell can
|
|
67
|
+
omit `sudo`. WSL follows the matching Linux distribution instructions inside
|
|
68
|
+
WSL; native Windows shells are not supported.
|
|
69
|
+
|
|
70
|
+
`hcc web` can attempt to install tmux when a supported package manager and
|
|
71
|
+
sufficient privileges are available. Manual installation is the deterministic
|
|
72
|
+
choice for servers, containers, and non-interactive sudo configurations.
|
|
73
|
+
|
|
74
|
+
### Install And Verify
|
|
75
|
+
|
|
43
76
|
Install hello-cc globally:
|
|
44
77
|
|
|
45
78
|
```bash
|
|
46
79
|
npm install -g @logicseek/hello-cc
|
|
47
80
|
```
|
|
48
81
|
|
|
82
|
+
If npm reports `EACCES`, use a Node version manager or configure a user-owned
|
|
83
|
+
npm prefix. Do not use `sudo npm install -g`, because it can create root-owned
|
|
84
|
+
package and configuration files.
|
|
85
|
+
|
|
86
|
+
Verify every prerequisite and the installed CLI:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
node --version
|
|
90
|
+
npm --version
|
|
91
|
+
tmux -V
|
|
92
|
+
hcc --version
|
|
93
|
+
hcc --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The Node version must start with `v24` or a newer major. Every command above
|
|
97
|
+
must exit successfully before starting a managed terminal.
|
|
98
|
+
|
|
99
|
+
To avoid a global install, use:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx @logicseek/hello-cc web
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Update And Uninstall
|
|
106
|
+
|
|
49
107
|
Update an existing global install:
|
|
50
108
|
|
|
51
109
|
```bash
|
package/docs/guide.zh-CN.md
CHANGED
|
@@ -36,12 +36,67 @@ v1 会把恢复的数据库重新迁移到 schema v7;用旧版本 hello-cc 打
|
|
|
36
36
|
|
|
37
37
|
## 安装、更新和卸载
|
|
38
38
|
|
|
39
|
+
### 前置依赖
|
|
40
|
+
|
|
41
|
+
通过 Node.js 官方软件包或 Node 版本管理器安装 Node.js 24 或更新版本。浏览器可
|
|
42
|
+
控制的终端还需要 `tmux`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Debian / Ubuntu
|
|
46
|
+
sudo apt-get update
|
|
47
|
+
sudo apt-get install -y tmux
|
|
48
|
+
# Fedora / RHEL
|
|
49
|
+
sudo dnf install -y tmux
|
|
50
|
+
# 旧版 RHEL / CentOS
|
|
51
|
+
sudo yum install -y tmux
|
|
52
|
+
# Alpine
|
|
53
|
+
sudo apk add tmux
|
|
54
|
+
# Arch Linux
|
|
55
|
+
sudo pacman -S --needed tmux
|
|
56
|
+
# openSUSE
|
|
57
|
+
sudo zypper install tmux
|
|
58
|
+
# 仅 macOS
|
|
59
|
+
brew install tmux
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Linux 必须使用当前发行版的系统包管理器,而不是 Homebrew。root shell 可以省略
|
|
63
|
+
`sudo`。WSL 用户应在 WSL 内部按对应 Linux 发行版安装;不支持原生 Windows
|
|
64
|
+
shell。
|
|
65
|
+
|
|
66
|
+
当存在受支持的包管理器且权限足够时,`hcc web` 可以尝试自动安装 tmux。对于
|
|
67
|
+
服务器、容器和非交互式 sudo 环境,手动安装是确定性更高的方式。
|
|
68
|
+
|
|
69
|
+
### 安装和验证
|
|
70
|
+
|
|
39
71
|
全局安装 hello-cc:
|
|
40
72
|
|
|
41
73
|
```bash
|
|
42
74
|
npm install -g @logicseek/hello-cc
|
|
43
75
|
```
|
|
44
76
|
|
|
77
|
+
如果 npm 报 `EACCES`,请使用 Node 版本管理器或配置用户自有的 npm prefix。
|
|
78
|
+
不要使用 `sudo npm install -g`,否则可能产生 root 所有的包和配置文件。
|
|
79
|
+
|
|
80
|
+
验证全部前置依赖和 CLI:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node --version
|
|
84
|
+
npm --version
|
|
85
|
+
tmux -V
|
|
86
|
+
hcc --version
|
|
87
|
+
hcc --help
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Node 版本应为 `v24` 开头或更高主版本;启动托管终端前,上述命令都应成功退出。
|
|
91
|
+
|
|
92
|
+
不全局安装时可以使用:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx @logicseek/hello-cc web
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 更新和卸载
|
|
99
|
+
|
|
45
100
|
更新已有的全局安装:
|
|
46
101
|
|
|
47
102
|
```bash
|
package/lib/cli/app.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { readPackageMeta } from '../package-meta.mjs';
|
|
|
51
51
|
import { removeGuidanceBlocks as removeGuidanceBlocksForRoot, writeGuidance as writeGuidanceForRoot } from '../guidance.mjs';
|
|
52
52
|
import { globalRuntimePath, runtimePath } from '../runtime/paths.mjs';
|
|
53
53
|
import { readRuntime, reclaimRuntimePointerFiles } from '../runtime/state.mjs';
|
|
54
|
+
import { waitForProcessIdentityExit } from '../process/identity.mjs';
|
|
54
55
|
|
|
55
56
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
56
57
|
|
|
@@ -310,6 +311,7 @@ const { cmdDown, cmdFindRoot, cmdWhichReal } = createMiscCommands({
|
|
|
310
311
|
path, fs, process, CliError, parseOpts, printResult,
|
|
311
312
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
312
313
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
314
|
+
waitForProcessIdentityExit,
|
|
313
315
|
PRODUCT_NAME
|
|
314
316
|
});
|
|
315
317
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Coordination CLI commands extracted from bin/hcc.mjs: handoff, event,
|
|
2
2
|
// heartbeat, ask, broadcast, inject. Factory pattern with injected deps.
|
|
3
3
|
|
|
4
|
+
import { RUNTIME_SESSION_MUTATION_TIMEOUT_MS } from '../../runtime/client.mjs';
|
|
5
|
+
|
|
4
6
|
export function createCoordinationCommands(deps) {
|
|
5
7
|
const {
|
|
6
8
|
connect,
|
|
@@ -228,7 +230,7 @@ export function createCoordinationCommands(deps) {
|
|
|
228
230
|
return runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(peer)}/input`, {
|
|
229
231
|
text,
|
|
230
232
|
enter
|
|
231
|
-
}, runtime);
|
|
233
|
+
}, runtime, { timeoutMs: RUNTIME_SESSION_MUTATION_TIMEOUT_MS });
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
async function cmdInject(ctx, args) {
|
package/lib/cli/commands/gc.mjs
CHANGED
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
} from '../../runtime/buffer-gc.mjs';
|
|
40
40
|
import { applyClockSafeBufferPlan } from '../../runtime/buffer-gc-protocol.mjs';
|
|
41
41
|
|
|
42
|
+
export const RUNTIME_BUFFER_GC_TIMEOUT_MS = 30_000;
|
|
43
|
+
|
|
42
44
|
export function createGcCommands(deps) {
|
|
43
45
|
const {
|
|
44
46
|
connect, now, helpGc,
|
|
@@ -109,7 +111,8 @@ function observeGcClockSafety(ctx, db, {
|
|
|
109
111
|
observedAt,
|
|
110
112
|
historyCategories,
|
|
111
113
|
includeStalePeers,
|
|
112
|
-
historySnapshot
|
|
114
|
+
historySnapshot,
|
|
115
|
+
bufferPathGuard = null
|
|
113
116
|
}) {
|
|
114
117
|
try {
|
|
115
118
|
return runOptimisticEvidenceMutation(db, {
|
|
@@ -124,10 +127,13 @@ function observeGcClockSafety(ctx, db, {
|
|
|
124
127
|
}),
|
|
125
128
|
observe: (subject) => observeGcEvidence(ctx, subject),
|
|
126
129
|
same: (left, right) => JSON.stringify(left) === JSON.stringify(right),
|
|
127
|
-
beforeMutate: (subject, evidenceByOwner) =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
beforeMutate: (subject, evidenceByOwner) => {
|
|
131
|
+
if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
|
|
132
|
+
return observeClockSafetyInTransactionOrThrow(
|
|
133
|
+
db,
|
|
134
|
+
gcClockSafetyOptions(subject, evidenceByOwner, olderThanDays)
|
|
135
|
+
);
|
|
136
|
+
},
|
|
131
137
|
changedMessage: 'GC subjects changed while clock evidence was being observed; retry',
|
|
132
138
|
mutate: (subject, evidenceByOwner, observation) => ({
|
|
133
139
|
...observation,
|
|
@@ -192,6 +198,128 @@ function previewGcClockSafety(ctx, db, options) {
|
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
function bufferDirectoryPathForbidden(message, directory) {
|
|
202
|
+
return new CliError('PROJECT_PATH_FORBIDDEN', `${message}: ${directory}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertBufferDirectoryPathUnchanged(captured) {
|
|
206
|
+
let resolvedRoot;
|
|
207
|
+
try {
|
|
208
|
+
resolvedRoot = fs.realpathSync.native(captured.requestedRoot);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
211
|
+
throw bufferDirectoryPathForbidden(
|
|
212
|
+
'Project root changed during buffer GC',
|
|
213
|
+
captured.requestedRoot
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (resolvedRoot !== captured.canonicalRoot) {
|
|
217
|
+
throw bufferDirectoryPathForbidden(
|
|
218
|
+
'Project root changed during buffer GC',
|
|
219
|
+
captured.requestedRoot
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
for (const component of captured.components) {
|
|
223
|
+
let current;
|
|
224
|
+
try {
|
|
225
|
+
current = fs.lstatSync(component.path);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
228
|
+
throw bufferDirectoryPathForbidden(
|
|
229
|
+
'Buffer directory path changed during lease acquisition',
|
|
230
|
+
captured.directory
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (current.isSymbolicLink() || !current.isDirectory() ||
|
|
234
|
+
current.dev !== component.stat.dev || current.ino !== component.stat.ino) {
|
|
235
|
+
throw bufferDirectoryPathForbidden(
|
|
236
|
+
'Buffer directory path changed during lease acquisition',
|
|
237
|
+
captured.directory
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!captured.exists) {
|
|
242
|
+
try {
|
|
243
|
+
fs.lstatSync(captured.directory);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error?.code === 'ENOENT') return;
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
throw bufferDirectoryPathForbidden(
|
|
249
|
+
'Buffer directory path changed during lease acquisition',
|
|
250
|
+
captured.directory
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function captureRequiredBufferDirectoryComponent(component, description) {
|
|
256
|
+
const stat = fs.lstatSync(component);
|
|
257
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
258
|
+
throw bufferDirectoryPathForbidden(`${description} must be a real directory`, component);
|
|
259
|
+
}
|
|
260
|
+
return { path: component, stat };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function captureOptionalBufferDirectory(root, directoryName) {
|
|
264
|
+
const requestedRoot = path.resolve(root);
|
|
265
|
+
const canonicalRoot = fs.realpathSync.native(requestedRoot);
|
|
266
|
+
const stateDirectory = path.join(canonicalRoot, '.hello-cc');
|
|
267
|
+
const directory = path.join(stateDirectory, directoryName);
|
|
268
|
+
const components = [
|
|
269
|
+
captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root'),
|
|
270
|
+
captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory')
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
let stat;
|
|
274
|
+
try {
|
|
275
|
+
stat = fs.lstatSync(directory);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.code === 'ENOENT') {
|
|
278
|
+
const captured = {
|
|
279
|
+
requestedRoot,
|
|
280
|
+
canonicalRoot,
|
|
281
|
+
directory,
|
|
282
|
+
components,
|
|
283
|
+
exists: false
|
|
284
|
+
};
|
|
285
|
+
assertBufferDirectoryPathUnchanged(captured);
|
|
286
|
+
return captured;
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
291
|
+
throw bufferDirectoryPathForbidden('Buffer directory must be a real directory', directory);
|
|
292
|
+
}
|
|
293
|
+
components.push({ path: directory, stat });
|
|
294
|
+
return { requestedRoot, canonicalRoot, directory, components, exists: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectGuardedBufferEvidence(collector, captured) {
|
|
298
|
+
const evidence = collector(captured.directory);
|
|
299
|
+
assertBufferDirectoryPathUnchanged(captured);
|
|
300
|
+
return evidence;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function withOptionalBufferDirectoryLease(captured, callback) {
|
|
304
|
+
assertBufferDirectoryPathUnchanged(captured);
|
|
305
|
+
if (!captured.exists) return null;
|
|
306
|
+
return withBufferDirectoryLease(captured.directory, (leasedDirectory) => {
|
|
307
|
+
const assertDirectoryUnchanged = () => {
|
|
308
|
+
assertBufferDirectoryPathUnchanged(captured);
|
|
309
|
+
if (fs.realpathSync.native(captured.directory) !== leasedDirectory) {
|
|
310
|
+
throw bufferDirectoryPathForbidden(
|
|
311
|
+
'Buffer directory path changed during lease acquisition',
|
|
312
|
+
captured.directory
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
assertDirectoryUnchanged();
|
|
317
|
+
const result = callback(leasedDirectory, assertDirectoryUnchanged);
|
|
318
|
+
assertDirectoryUnchanged();
|
|
319
|
+
return result;
|
|
320
|
+
}, { createParent: false });
|
|
321
|
+
}
|
|
322
|
+
|
|
195
323
|
function runGc(ctx, db, {
|
|
196
324
|
olderThanDays = 7,
|
|
197
325
|
dryRun = false,
|
|
@@ -203,6 +331,9 @@ function runGc(ctx, db, {
|
|
|
203
331
|
history = false,
|
|
204
332
|
observedAt = null
|
|
205
333
|
} = {}) {
|
|
334
|
+
const bufferPathGuard = skipBufferFiles
|
|
335
|
+
? null
|
|
336
|
+
: captureOptionalBufferDirectory(ctx.root, BUFS_DIR_NAME);
|
|
206
337
|
const auto = scope === 'auto';
|
|
207
338
|
const historyCategories = auto ? ['events'] : ['events', 'tasks', 'messages', 'handoffs'];
|
|
208
339
|
const applyHistory = auto || history;
|
|
@@ -213,16 +344,19 @@ function runGc(ctx, db, {
|
|
|
213
344
|
: gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
|
|
214
345
|
const bufferRetentionSec = (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400;
|
|
215
346
|
const preparedBufferEvidence = !skipBufferFiles && collectBufferEvidenceNow
|
|
216
|
-
? collectBufferEvidenceNow
|
|
347
|
+
? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
|
|
217
348
|
: null;
|
|
218
|
-
const bufferGcCutoffs =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
349
|
+
const bufferGcCutoffs = skipBufferFiles
|
|
350
|
+
? []
|
|
351
|
+
: bufferDirectoryPaths(bufferPathGuard.directory).flatMap((file) => {
|
|
352
|
+
try {
|
|
353
|
+
const stat = fs.lstatSync(file);
|
|
354
|
+
return [Math.max(0, Math.floor(stat.mtimeMs / 1000) + bufferRetentionSec)];
|
|
355
|
+
} catch {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
|
|
226
360
|
bufferGcCutoffs.push(...(preparedBufferEvidence?.gcCutoffs || []));
|
|
227
361
|
const historySnapshot = createHistoryGcSnapshot(db, gcNow - retentionSec, {
|
|
228
362
|
categories: historyCategories,
|
|
@@ -245,7 +379,8 @@ function runGc(ctx, db, {
|
|
|
245
379
|
bufferGcCutoffs,
|
|
246
380
|
historyCategories,
|
|
247
381
|
gcNow,
|
|
248
|
-
historySnapshot
|
|
382
|
+
historySnapshot,
|
|
383
|
+
bufferPathGuard
|
|
249
384
|
}),
|
|
250
385
|
'History GC run'
|
|
251
386
|
);
|
|
@@ -266,6 +401,7 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
266
401
|
historyCategories,
|
|
267
402
|
gcNow,
|
|
268
403
|
historySnapshot,
|
|
404
|
+
bufferPathGuard = null,
|
|
269
405
|
clockObservation: preparedClockObservation = null,
|
|
270
406
|
forceDefer = false
|
|
271
407
|
}) {
|
|
@@ -275,17 +411,23 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
275
411
|
observedAt: gcNow,
|
|
276
412
|
historyCategories,
|
|
277
413
|
includeStalePeers: !auto,
|
|
278
|
-
historySnapshot
|
|
414
|
+
historySnapshot,
|
|
415
|
+
bufferPathGuard
|
|
279
416
|
});
|
|
280
417
|
const graceActive = forceDefer || clockGraceSuppressed(gcNow, clockObservation.graceUntil);
|
|
281
418
|
let databaseGraceActive = graceActive;
|
|
282
419
|
const beforeDatabaseMutate = () => {
|
|
420
|
+
if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
|
|
283
421
|
if (databaseGraceActive) return false;
|
|
284
422
|
try {
|
|
285
423
|
const observedAt = now();
|
|
286
424
|
const allowed = !clockGraceSuppressed(observedAt, readClockGraceUntil(db));
|
|
287
|
-
if (!allowed)
|
|
288
|
-
|
|
425
|
+
if (!allowed) {
|
|
426
|
+
databaseGraceActive = true;
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
if (bufferPathGuard) assertBufferDirectoryPathUnchanged(bufferPathGuard);
|
|
430
|
+
return true;
|
|
289
431
|
} catch (error) {
|
|
290
432
|
if (error instanceof CliError) throw error;
|
|
291
433
|
throw clockSafetyUnavailable(error);
|
|
@@ -325,14 +467,16 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
325
467
|
const bufCutoffMs = Number.isFinite(bufferCutoffMs)
|
|
326
468
|
? bufferCutoffMs
|
|
327
469
|
: gcNow * 1000 - (auto ? Math.min(olderThanDays, 7) : olderThanDays) * 86400000;
|
|
328
|
-
const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
|
|
329
470
|
if (!graceActive && !skipBufferFiles) {
|
|
330
|
-
const bufferResult =
|
|
471
|
+
const bufferResult = withOptionalBufferDirectoryLease(bufferPathGuard, (
|
|
472
|
+
leasedBufsDir,
|
|
473
|
+
assertDirectoryUnchanged
|
|
474
|
+
) => {
|
|
331
475
|
const currentEvidence = collectBufferEvidenceNow
|
|
332
|
-
? collectBufferEvidenceNow
|
|
476
|
+
? collectGuardedBufferEvidence(collectBufferEvidenceNow, bufferPathGuard)
|
|
333
477
|
: preparedBufferEvidence || { protectedPaths: protectedBuffers, unknownPaths: new Set(), gcCutoffs: [] };
|
|
334
478
|
const currentPlan = planBufferFiles({
|
|
335
|
-
directories: [
|
|
479
|
+
directories: [leasedBufsDir],
|
|
336
480
|
cutoffMs: bufCutoffMs,
|
|
337
481
|
protectedPaths: currentEvidence.protectedPaths,
|
|
338
482
|
unknownPaths: currentEvidence.unknownPaths,
|
|
@@ -341,12 +485,16 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
341
485
|
...(currentEvidence.gcCutoffs || [])
|
|
342
486
|
]
|
|
343
487
|
});
|
|
488
|
+
assertDirectoryUnchanged();
|
|
344
489
|
const currentObservedAt = now();
|
|
345
|
-
const currentObservation = tx(db, () =>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
490
|
+
const currentObservation = tx(db, () => {
|
|
491
|
+
assertDirectoryUnchanged();
|
|
492
|
+
return observeClockSafetyInTransaction(db, {
|
|
493
|
+
operation: 'gc',
|
|
494
|
+
gcCutoffs: bufferPlanGcCutoffs(currentPlan, bufferRetentionSec),
|
|
495
|
+
nowSec: currentObservedAt
|
|
496
|
+
});
|
|
497
|
+
});
|
|
350
498
|
if (clockGraceSuppressed(currentObservedAt, currentObservation.graceUntil)) {
|
|
351
499
|
databaseGraceActive = true;
|
|
352
500
|
return deferBufferPlan(currentPlan);
|
|
@@ -360,9 +508,11 @@ function runGcWithHistorySnapshot(ctx, db, {
|
|
|
360
508
|
}
|
|
361
509
|
return applyBufferPlan(currentPlan);
|
|
362
510
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
511
|
+
if (bufferResult !== null) {
|
|
512
|
+
results.buf_files = bufferResult.deleted;
|
|
513
|
+
results.protected_buf_files = bufferResult.protected;
|
|
514
|
+
results.deferred_buf_files = bufferResult.deferred;
|
|
515
|
+
}
|
|
366
516
|
}
|
|
367
517
|
|
|
368
518
|
// A clock jump can make every lock look expired at once. Preserve those rows
|
|
@@ -589,7 +739,7 @@ async function prepareManualBufferGc(ctx, db, { retentionSec, dryRun }) {
|
|
|
589
739
|
phase: 'prepare',
|
|
590
740
|
retentionSec,
|
|
591
741
|
dryRun
|
|
592
|
-
}, runtime, { timeoutMs:
|
|
742
|
+
}, runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
|
|
593
743
|
return {
|
|
594
744
|
mode: 'runtime',
|
|
595
745
|
runtime,
|
|
@@ -668,7 +818,7 @@ async function applyManualBufferGc(ctx, db, prepared, { dryRun, graceActive }) {
|
|
|
668
818
|
const applied = await runtimeRequest(ctx, 'POST', '/api/runtime/gc-buffers', {
|
|
669
819
|
phase: 'apply',
|
|
670
820
|
token: prepared.token
|
|
671
|
-
}, prepared.runtime, { timeoutMs:
|
|
821
|
+
}, prepared.runtime, { timeoutMs: RUNTIME_BUFFER_GC_TIMEOUT_MS });
|
|
672
822
|
const counts = requireBufferGcResult(applied);
|
|
673
823
|
if (typeof applied?.complete !== 'boolean' || typeof applied?.graceActive !== 'boolean') {
|
|
674
824
|
throw new CliError('RUNTIME_BAD_RESPONSE', 'Runtime buffer GC returned invalid completion state');
|