@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.2
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/LICENSE +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- package/src/native/workspace-tools.mjs +1740 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped Node child file workspaces and version-checked publication.
|
|
3
|
+
*
|
|
4
|
+
* Ports ksadk.harness.workspace_adapters.native_isolation @355059d to a pure
|
|
5
|
+
* Node/ESM module. These are immutable input assets, not a second Run ledger:
|
|
6
|
+
* file tools are confined to the child directory; arbitrary host-user processes
|
|
7
|
+
* are not a sandbox. The host keeps workspaces for follow-up and recovery.
|
|
8
|
+
*
|
|
9
|
+
* Faithful to the reference semantics that matter:
|
|
10
|
+
* - ``prepare`` freezes the actual input files ONCE, preserving dirty and
|
|
11
|
+
* untracked content; it is idempotent and never resets a child's changes.
|
|
12
|
+
* - ``merge`` prechecks ALL conflicts before writing; an unrelated parent
|
|
13
|
+
* change is preserved; an OS failure reports the real applied prefix
|
|
14
|
+
* (multi-file publication is NOT an atomic transaction).
|
|
15
|
+
* - a previous publication is granted ONLY by a caller-supplied receipt
|
|
16
|
+
* (``previous``) whose immutable inventory is verified against disk; a
|
|
17
|
+
* saved inventory existing on disk never authorizes another merge.
|
|
18
|
+
* - identity is a Host-supplied 64-hex string; model paths are never accepted.
|
|
19
|
+
* - .git/.agentkit/.harness-tools/.artifacts are ignored; symlinks and special
|
|
20
|
+
* files are rejected; the 64 MiB payload limit is enforced.
|
|
21
|
+
*
|
|
22
|
+
* Cross-process coordination uses a per-path SQLite BEGIN IMMEDIATE transaction
|
|
23
|
+
* (the same primitive the Node workspace state store uses). SQLite's busy_timeout
|
|
24
|
+
* retries a contended BEGIN, but that retry is a SYNCHRONOUS busy-wait that
|
|
25
|
+
* blocks the Node event loop — callers must not hold the lock across an await.
|
|
26
|
+
* The OS releases the lock when the process exits (no stale lockfile recovery).
|
|
27
|
+
* Production workspace-tools.mjs acquires the same source-mutation lock for
|
|
28
|
+
* builtin writes. Arbitrary commands and external editors do not participate.
|
|
29
|
+
* The Host owns Run/fence/approval and
|
|
30
|
+
* child-completion checks; this module provides the synchronous final guard
|
|
31
|
+
* checkpoints and never loosens a boundary after an await.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import {
|
|
35
|
+
chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
|
|
36
|
+
openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync,
|
|
37
|
+
renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
40
|
+
|
|
41
|
+
import { publishWorkspaceBytes, sha256, isInside, realpathExisting } from './workspace-paths.mjs';
|
|
42
|
+
import { acquireWorkspaceMutationLock } from './workspace-mutation-lock.mjs';
|
|
43
|
+
|
|
44
|
+
export const CHILD_FILE_WORKSPACE_VERSION = 'native-child-files/v1';
|
|
45
|
+
export const CHILD_FILE_PUBLICATION_VERSION = 'native-child-publication/v1';
|
|
46
|
+
export const IGNORED_PATH_PARTS = new Set(['.git', '.agentkit', '.harness-tools', '.artifacts']);
|
|
47
|
+
export const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
|
|
48
|
+
const HEX64 = /^[0-9a-f]{64}$/u;
|
|
49
|
+
const REVISION_RE = /^sha256:[0-9a-f]{64}$/u;
|
|
50
|
+
|
|
51
|
+
/** A safe host diagnostic that contains no source text or credentials. */
|
|
52
|
+
export class ChildWorkspaceError extends Error {
|
|
53
|
+
constructor(reason) { super(reason); this.name = 'ChildWorkspaceError'; this.code = reason; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A frozen child workspace input snapshot. */
|
|
57
|
+
export class ChildFileWorkspace {
|
|
58
|
+
constructor(isolationId, root, baseline, baseRevision) {
|
|
59
|
+
this.isolationId = isolationId;
|
|
60
|
+
this.root = root;
|
|
61
|
+
this.baseline = baseline;
|
|
62
|
+
this.baseRevision = baseRevision;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A host grant from an acknowledged merge, never inferred from disk. */
|
|
67
|
+
export class ChildFilePublication {
|
|
68
|
+
constructor(revision, inventoryDigest) {
|
|
69
|
+
this.revision = revision;
|
|
70
|
+
this.inventoryDigest = inventoryDigest;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A directory entry in the immutable inventory. */
|
|
75
|
+
export class DirectoryEntry {
|
|
76
|
+
constructor(path, kind, mode, contentDigest) {
|
|
77
|
+
this.path = path;
|
|
78
|
+
this.kind = kind;
|
|
79
|
+
this.mode = mode;
|
|
80
|
+
this.contentDigest = contentDigest ?? null;
|
|
81
|
+
}
|
|
82
|
+
equals(other) {
|
|
83
|
+
return other && this.path === other.path && this.kind === other.kind
|
|
84
|
+
&& this.mode === other.mode && (this.contentDigest ?? null) === (other.contentDigest ?? null);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- cross-process coordination ----------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Acquire shared workspace-mutation locks for one or more paths, all-or-nothing
|
|
91
|
+
* by sorted canonical order. Uses the shared workspace-mutation-lock.mjs so
|
|
92
|
+
* builtin workspace-tools workers (which the Host wires to the same lock)
|
|
93
|
+
* coordinate with this module. SQLite BEGIN IMMEDIATE; synchronous busy-wait
|
|
94
|
+
* BLOCKS the event loop — never hold across an await. The OS releases on
|
|
95
|
+
* process exit. */
|
|
96
|
+
function lockPaths(paths, { timeoutMs = 10000 } = {}) {
|
|
97
|
+
const sorted = [...new Set(paths.filter(Boolean).map(p => resolve(p)))].sort();
|
|
98
|
+
const releases = [];
|
|
99
|
+
try {
|
|
100
|
+
for (const p of sorted) releases.push(acquireWorkspaceMutationLock(p, { timeoutMs }));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
for (const r of releases) { try { r(); } catch { /* best-effort */ } }
|
|
103
|
+
if (error.code === 'workspace_mutation_busy') throw new ChildWorkspaceError('child_workspace_lock_timeout');
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return () => { for (const r of releases) { try { r(); } catch { /* best-effort */ } } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- inventory capture -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
const MAX_INVENTORY_ENTRIES = 2048;
|
|
112
|
+
const MAX_INVENTORY_BYTES = 64 * 1024 * 1024;
|
|
113
|
+
|
|
114
|
+
/** Capture a stable inventory: two passes must agree. Mirrors the reference
|
|
115
|
+
* double-capture (native_directory) and the Node workspace-tools observe.
|
|
116
|
+
* Exported for testing and for the Host's final guard checkpoint. */
|
|
117
|
+
export function captureInventory(root) {
|
|
118
|
+
const first = scanInventory(root);
|
|
119
|
+
const second = scanInventory(root);
|
|
120
|
+
if (first.digest !== second.digest || first.entryCount !== second.entryCount
|
|
121
|
+
|| !entriesIdentical(first.list, second.list)) {
|
|
122
|
+
throw new ChildWorkspaceError('child_workspace_changed_during_capture');
|
|
123
|
+
}
|
|
124
|
+
return { revision: 'sha256:' + second.digest, entries: second.byPath };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function entriesIdentical(a, b) {
|
|
128
|
+
if (a.length !== b.length) return false;
|
|
129
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scanInventory(root) {
|
|
134
|
+
const hash = createHash('sha256');
|
|
135
|
+
hash.update('ksadk.directory-workspace/v1\0');
|
|
136
|
+
const state = { entries: 0, hashedBytes: 0, list: [], byPath: Object.create(null) };
|
|
137
|
+
scanDir(root, '', hash, state, root);
|
|
138
|
+
state.list.sort();
|
|
139
|
+
return { digest: hash.digest('hex'), entryCount: state.entries, list: state.list, byPath: state.byPath };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isIgnored(name) { return IGNORED_PATH_PARTS.has(name); }
|
|
143
|
+
|
|
144
|
+
function scanDir(root, rel, hash, state, rootReal) {
|
|
145
|
+
const dir = rel ? join(root, rel) : root;
|
|
146
|
+
let dirents;
|
|
147
|
+
try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
|
|
148
|
+
dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
149
|
+
for (const d of dirents) {
|
|
150
|
+
if (isIgnored(d.name)) continue;
|
|
151
|
+
const entryRel = rel ? `${rel}/${d.name}` : d.name;
|
|
152
|
+
if (entryRel.includes('\\') || entryRel.includes('\0')) throw new ChildWorkspaceError('child_workspace_unsupported_path');
|
|
153
|
+
state.entries += 1;
|
|
154
|
+
if (state.entries > MAX_INVENTORY_ENTRIES) throw new ChildWorkspaceError('child_workspace_capture_limit');
|
|
155
|
+
const abs = join(root, entryRel);
|
|
156
|
+
let info;
|
|
157
|
+
try { info = lstatSync(abs, { bigint: true }); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
|
|
158
|
+
if (info.isSymbolicLink()) throw new ChildWorkspaceError('child_workspace_symlink_entry');
|
|
159
|
+
// The digest hashes ONLY path, mode, kind and content digest — NOT mtimeNs
|
|
160
|
+
// or ino (a copy of the same content has a different mtime/ino but is the
|
|
161
|
+
// same workspace revision). The before/after identity (including mtime/ino)
|
|
162
|
+
// is a per-entry change-detection check within ONE scan, not part of the
|
|
163
|
+
// revision or the cross-scan comparison.
|
|
164
|
+
const before = entryIdentity(info);
|
|
165
|
+
hash.update(`\0path\0${entryRel}`);
|
|
166
|
+
hash.update(`\0mode:${Number(info.mode & 0o777n).toString(8)}`);
|
|
167
|
+
if (info.isDirectory()) {
|
|
168
|
+
hash.update('\0directory');
|
|
169
|
+
scanDir(root, entryRel, hash, state, rootReal);
|
|
170
|
+
state.list.push(entryRel);
|
|
171
|
+
state.byPath[entryRel] = new DirectoryEntry(entryRel, 'directory', Number(info.mode & 0o777n), null);
|
|
172
|
+
} else if (info.isFile()) {
|
|
173
|
+
if (state.hashedBytes + Number(info.size) > MAX_INVENTORY_BYTES) throw new ChildWorkspaceError('child_workspace_capture_limit');
|
|
174
|
+
const { raw, hex, readBytes } = hashFile(abs, Number(info.size));
|
|
175
|
+
if (readBytes !== Number(info.size)) throw new ChildWorkspaceError('child_workspace_changed_during_capture');
|
|
176
|
+
// Feed the RAW 32-byte file digest into the directory hash (matching
|
|
177
|
+
// workspace-tools.mjs), not the 64-char hex string.
|
|
178
|
+
hash.update('\0file\0');
|
|
179
|
+
hash.update(raw);
|
|
180
|
+
state.hashedBytes += readBytes;
|
|
181
|
+
state.list.push(entryRel);
|
|
182
|
+
state.byPath[entryRel] = new DirectoryEntry(entryRel, 'file', Number(info.mode & 0o777n), 'sha256:' + hex);
|
|
183
|
+
} else {
|
|
184
|
+
throw new ChildWorkspaceError('child_workspace_special_entry');
|
|
185
|
+
}
|
|
186
|
+
let after;
|
|
187
|
+
try { after = entryIdentity(lstatSync(abs, { bigint: true })); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
|
|
188
|
+
if (after !== before) throw new ChildWorkspaceError('child_workspace_changed_during_capture');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function entryIdentity(info) {
|
|
193
|
+
return `${info.mode}|${info.size}|${info.mtimeNs}|${info.ctimeNs}|${info.ino}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hashFile(abs, size) {
|
|
197
|
+
const fileHash = createHash('sha256');
|
|
198
|
+
const fd = openSync(abs, 'r');
|
|
199
|
+
let readBytes = 0;
|
|
200
|
+
try {
|
|
201
|
+
let remaining = size;
|
|
202
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
203
|
+
while (remaining > 0) {
|
|
204
|
+
const n = readSync(fd, buf, 0, Math.min(buf.length, remaining), null);
|
|
205
|
+
if (n === 0) break;
|
|
206
|
+
readBytes += n;
|
|
207
|
+
fileHash.update(buf.subarray(0, n));
|
|
208
|
+
remaining -= n;
|
|
209
|
+
}
|
|
210
|
+
} finally { closeSync(fd); }
|
|
211
|
+
// Return the RAW 32-byte digest for the directory hash (matching
|
|
212
|
+
// workspace-tools.mjs which feeds fileHash.digest() directly into the
|
|
213
|
+
// directory hash, NOT the 64-char hex string). Also return the hex form
|
|
214
|
+
// for the DirectoryEntry content_digest ('sha256:' + hex).
|
|
215
|
+
const raw = fileHash.digest();
|
|
216
|
+
return { raw, hex: raw.toString('hex'), readBytes };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Read a file payload bounded by MAX_PAYLOAD_BYTES, verifying its content
|
|
220
|
+
* digest matches the expected entry. Rejects symlinks, specials, and a changed
|
|
221
|
+
* digest (the file was modified after the inventory capture). */
|
|
222
|
+
function readPayload(root, rel, expected) {
|
|
223
|
+
const target = join(root, rel);
|
|
224
|
+
let info;
|
|
225
|
+
try { info = lstatSync(target, { bigint: true }); } catch { throw new ChildWorkspaceError('child_workspace_file_changed'); }
|
|
226
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new ChildWorkspaceError('child_workspace_file_changed');
|
|
227
|
+
const fd = openSync(target, 'r');
|
|
228
|
+
let value;
|
|
229
|
+
try {
|
|
230
|
+
value = Buffer.alloc(0);
|
|
231
|
+
const chunks = [];
|
|
232
|
+
let total = 0;
|
|
233
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
234
|
+
for (;;) {
|
|
235
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
236
|
+
if (n === 0) break;
|
|
237
|
+
total += n;
|
|
238
|
+
if (total > MAX_PAYLOAD_BYTES) throw new ChildWorkspaceError('child_workspace_file_changed');
|
|
239
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
240
|
+
}
|
|
241
|
+
value = Buffer.concat(chunks);
|
|
242
|
+
} finally { closeSync(fd); }
|
|
243
|
+
if ('sha256:' + sha256(value) !== expected.contentDigest) throw new ChildWorkspaceError('child_workspace_file_changed');
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- DirectoryChildWorkspaces ------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export class DirectoryChildWorkspaces {
|
|
250
|
+
/** @param {{source:string, storage:string, sourceDatabase?:string}} opts */
|
|
251
|
+
constructor({ source, storage, sourceDatabase } = {}) {
|
|
252
|
+
if (!source) throw new TypeError('DirectoryChildWorkspaces requires source');
|
|
253
|
+
if (!storage) throw new TypeError('DirectoryChildWorkspaces requires storage');
|
|
254
|
+
// Canonicalize roots: resolve symlinks for existing paths (macOS /var is a
|
|
255
|
+
// symlink to /private/var — this normalizes it). For a not-yet-created
|
|
256
|
+
// storage, resolve the closest existing ancestor and append the tail
|
|
257
|
+
// lexically. Pin the canonical forms; every prepare/load/merge re-checks
|
|
258
|
+
// the live realpath against these to detect a root or ancestor swap.
|
|
259
|
+
this.source = canonicalRoot(source);
|
|
260
|
+
this.storage = canonicalRoot(storage);
|
|
261
|
+
this.sourceDatabase = sourceDatabase ? resolve(sourceDatabase) : null;
|
|
262
|
+
// The storage must NOT be inside the source: a capture of the source would
|
|
263
|
+
// then recurse into the storage tree. Reject symlinked source/storage roots
|
|
264
|
+
// and a storage that is the source or beneath it. Use canonical paths so a
|
|
265
|
+
// symlinked alias cannot bypass this.
|
|
266
|
+
if (isSymlink(resolve(source)) || isSymlink(resolve(storage)))
|
|
267
|
+
throw new ChildWorkspaceError('child_workspace_root_symlink');
|
|
268
|
+
if (this.storage === this.source || isInside(this.storage, this.source))
|
|
269
|
+
throw new ChildWorkspaceError('child_workspace_storage_inside_source');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Re-verify the live canonical roots match the pinned roots. A symlink or
|
|
273
|
+
* ancestor swap after construction changes the realpath; that is a new root,
|
|
274
|
+
* not the one this module was bound to. */
|
|
275
|
+
_checkRoots() {
|
|
276
|
+
if (canonicalRoot(this.source) !== this.source) throw new ChildWorkspaceError('child_workspace_root_changed');
|
|
277
|
+
if (canonicalRoot(this.storage) !== this.storage) throw new ChildWorkspaceError('child_workspace_root_changed');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Verify a workspace object's root/baseline actually belong to THIS storage
|
|
281
|
+
* under the original identity and were not swapped to an external directory.
|
|
282
|
+
* Also verify the workspace's own manifest fields (source, base_revision,
|
|
283
|
+
* isolation_id) match this module's binding. A forged workspace.root must
|
|
284
|
+
* never publish outside the bound source. */
|
|
285
|
+
_validateWorkspace(workspace) {
|
|
286
|
+
if (!workspace || typeof workspace.isolationId !== 'string' || !HEX64.test(workspace.isolationId))
|
|
287
|
+
throw new ChildWorkspaceError('child_workspace_identity_invalid');
|
|
288
|
+
this._checkRoots();
|
|
289
|
+
const expectedTarget = canonicalRoot(join(this.storage, workspace.isolationId));
|
|
290
|
+
const expectedRoot = join(expectedTarget, 'work');
|
|
291
|
+
const expectedBaseline = join(expectedTarget, 'base');
|
|
292
|
+
// Use realpath (via canonicalRoot) so a symlink swap on the workspace root
|
|
293
|
+
// or an ancestor is detected — resolve() alone only lexical-normalizes and
|
|
294
|
+
// does NOT follow symlinks for the non-existent tail.
|
|
295
|
+
if (canonicalRoot(workspace.root) !== expectedRoot || canonicalRoot(workspace.baseline) !== expectedBaseline)
|
|
296
|
+
throw new ChildWorkspaceError('child_workspace_path_escape');
|
|
297
|
+
if (isSymlink(workspace.root) || isSymlink(workspace.baseline)
|
|
298
|
+
|| isSymlink(expectedTarget) || isSymlink(join(this.storage, workspace.isolationId)))
|
|
299
|
+
throw new ChildWorkspaceError('child_workspace_path_escape');
|
|
300
|
+
// The root and baseline must still be directories (not replaced with a file).
|
|
301
|
+
if (!isDir(workspace.root) || !isDir(workspace.baseline))
|
|
302
|
+
throw new ChildWorkspaceError('child_workspace_path_escape');
|
|
303
|
+
const original = this.load(workspace.isolationId);
|
|
304
|
+
if (workspace.baseRevision !== original.baseRevision)
|
|
305
|
+
throw new ChildWorkspaceError('child_workspace_identity_changed');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Freeze actual input files once, preserving dirty and untracked content.
|
|
309
|
+
* Idempotent: a re-prepare for the same identity reuses the existing child
|
|
310
|
+
* and never resets its changes. */
|
|
311
|
+
prepare(identity) {
|
|
312
|
+
if (typeof identity !== 'string' || !HEX64.test(identity)) throw new ChildWorkspaceError('child_workspace_identity_invalid');
|
|
313
|
+
this._checkRoots();
|
|
314
|
+
mkdirSync(this.storage, { recursive: true });
|
|
315
|
+
const target = join(this.storage, identity);
|
|
316
|
+
// Lock BOTH source and target (sorted canonical order) so the parent source
|
|
317
|
+
// cannot be written while we capture and copy its files. The lock is
|
|
318
|
+
// synchronous and released in a finally — never held across an await.
|
|
319
|
+
const release = lockPaths([this.source, target]);
|
|
320
|
+
try {
|
|
321
|
+
if (existsSync(target)) return this.load(identity);
|
|
322
|
+
const temporary = join(this.storage, `.child-input-${identity.slice(0, 12)}-${process.pid}`);
|
|
323
|
+
mkdirSync(temporary, { recursive: true });
|
|
324
|
+
try {
|
|
325
|
+
if (existsSync(target)) return this.load(identity);
|
|
326
|
+
const { revision, entries } = captureInventory(this.source);
|
|
327
|
+
mkdirSync(join(temporary, 'base'), { recursive: true });
|
|
328
|
+
mkdirSync(join(temporary, 'work'), { recursive: true });
|
|
329
|
+
const sortedEntries = Object.entries(entries).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
330
|
+
// Copy files first, then set directory modes in reverse so a parent
|
|
331
|
+
// mode is not overridden by a later mkdir.
|
|
332
|
+
for (const [, entry] of sortedEntries) {
|
|
333
|
+
if (entry.kind === 'directory') {
|
|
334
|
+
mkdirSync(join(temporary, 'base', entry.path), { recursive: true });
|
|
335
|
+
mkdirSync(join(temporary, 'work', entry.path), { recursive: true });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const [, entry] of sortedEntries) {
|
|
339
|
+
if (entry.kind === 'file') {
|
|
340
|
+
const payload = readPayload(this.source, entry.path, entry);
|
|
341
|
+
for (const tree of ['base', 'work']) {
|
|
342
|
+
const dest = join(temporary, tree, entry.path);
|
|
343
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
344
|
+
writeFileSync(dest, payload);
|
|
345
|
+
chmodSync(dest, entry.mode);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (let i = sortedEntries.length - 1; i >= 0; i--) {
|
|
350
|
+
const [, entry] = sortedEntries[i];
|
|
351
|
+
if (entry.kind === 'directory') {
|
|
352
|
+
chmodSync(join(temporary, 'base', entry.path), entry.mode);
|
|
353
|
+
chmodSync(join(temporary, 'work', entry.path), entry.mode);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Re-verify the source did not change during the copy (held under the
|
|
357
|
+
// source lock, but a concurrent non-coordinated writer could still change
|
|
358
|
+
// it; the double-capture catches that).
|
|
359
|
+
const current = captureInventory(this.source);
|
|
360
|
+
if (current.revision !== revision) throw new ChildWorkspaceError('child_workspace_source_changed');
|
|
361
|
+
writeFileSync(join(temporary, 'inputs.json'), JSON.stringify({
|
|
362
|
+
version: CHILD_FILE_WORKSPACE_VERSION, identity, source: this.source, base_revision: revision,
|
|
363
|
+
}));
|
|
364
|
+
try { renameSync(temporary, target); }
|
|
365
|
+
catch (error) {
|
|
366
|
+
if (error.code === 'ENOTEMPTY' || error.code === 'EEXIST') {
|
|
367
|
+
// Another process published the same identity; reuse it.
|
|
368
|
+
return this.load(identity);
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
return this.load(identity);
|
|
373
|
+
} finally {
|
|
374
|
+
if (existsSync(temporary)) { try { rmdirRecursive(temporary); } catch { /* best-effort */ } }
|
|
375
|
+
}
|
|
376
|
+
} finally { release(); }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Load an existing frozen workspace, verifying its manifest identity. */
|
|
380
|
+
load(identity) {
|
|
381
|
+
if (typeof identity !== 'string' || !HEX64.test(identity)) throw new ChildWorkspaceError('child_workspace_identity_invalid');
|
|
382
|
+
this._checkRoots();
|
|
383
|
+
const target = join(this.storage, identity);
|
|
384
|
+
if (!existsSync(target)) throw new ChildWorkspaceError('child_workspace_not_prepared');
|
|
385
|
+
const manifestPath = join(target, 'inputs.json');
|
|
386
|
+
if (isSymlink(manifestPath) || isSymlink(target)) throw new ChildWorkspaceError('child_workspace_identity_changed');
|
|
387
|
+
let data;
|
|
388
|
+
try { data = JSON.parse(readFileSync(manifestPath, 'utf-8')); } catch { throw new ChildWorkspaceError('child_workspace_identity_changed'); }
|
|
389
|
+
if (data.version !== CHILD_FILE_WORKSPACE_VERSION || data.identity !== identity
|
|
390
|
+
|| data.source !== this.source || isSymlink(join(target, 'work')) || !isDir(join(target, 'work'))
|
|
391
|
+
|| !REVISION_RE.test(data.base_revision) || !isDir(join(target, 'base'))
|
|
392
|
+
|| isSymlink(join(target, 'base'))) throw new ChildWorkspaceError('child_workspace_identity_changed');
|
|
393
|
+
return new ChildFileWorkspace(identity, join(target, 'work'), join(target, 'base'), data.base_revision);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Return an observer bound to the workspace root, sharing the double-capture
|
|
397
|
+
* revision algorithm with createNativeWorkspaceTools().observe. */
|
|
398
|
+
observer(workspace) {
|
|
399
|
+
return {
|
|
400
|
+
observe: () => {
|
|
401
|
+
// Reject a forged workspace.root — the observer must not vouch for an
|
|
402
|
+
// arbitrary external path. This validates ownership before capturing.
|
|
403
|
+
try { this._validateWorkspace(workspace); } catch (error) {
|
|
404
|
+
if (error instanceof ChildWorkspaceError)
|
|
405
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: error.code };
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const { revision } = captureInventory(workspace.root);
|
|
410
|
+
return { revision, changedPaths: [], status: 'ok', reason: null };
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error instanceof ChildWorkspaceError) return { revision: null, changedPaths: [], status: 'unavailable', reason: error.code };
|
|
413
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: 'capture_failed' };
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- publication inventory -------------------------------------------------
|
|
420
|
+
|
|
421
|
+
_publicationPath(workspace, revision) {
|
|
422
|
+
if (typeof revision !== 'string' || !REVISION_RE.test(revision)) throw new ChildWorkspaceError('child_publication_identity_invalid');
|
|
423
|
+
return join(dirname(workspace.root), 'publications', `${revision.slice(7)}.json`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Verify a caller-supplied previous publication against its immutable disk
|
|
427
|
+
* inventory. A missing/tampered/foreign inventory returns null (no grant). */
|
|
428
|
+
_verifyPreviousPublication(workspace, previous) {
|
|
429
|
+
if (!previous || typeof previous.revision !== 'string' || typeof previous.inventoryDigest !== 'string') return null;
|
|
430
|
+
const target = this._publicationPath(workspace, previous.revision);
|
|
431
|
+
if (!existsSync(target) || isSymlink(target) || isSymlink(dirname(target))) return null;
|
|
432
|
+
let value;
|
|
433
|
+
try { value = readFileSync(target); } catch { return null; }
|
|
434
|
+
if ('sha256:' + sha256(value) !== previous.inventoryDigest) return null;
|
|
435
|
+
let data;
|
|
436
|
+
try { data = JSON.parse(value); } catch { return null; }
|
|
437
|
+
if (data.version !== CHILD_FILE_PUBLICATION_VERSION || data.isolation_id !== workspace.isolationId
|
|
438
|
+
|| data.source !== this.source || data.base_revision !== workspace.baseRevision
|
|
439
|
+
|| data.child_revision !== previous.revision) return null;
|
|
440
|
+
const entries = Object.create(null);
|
|
441
|
+
for (const e of data.entries) entries[e.path] = new DirectoryEntry(e.path, e.kind, e.mode, e.content_digest);
|
|
442
|
+
return entries;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
_savePublication(workspace, revision, entries) {
|
|
446
|
+
const target = this._publicationPath(workspace, revision);
|
|
447
|
+
const value = Buffer.from(JSON.stringify({
|
|
448
|
+
version: CHILD_FILE_PUBLICATION_VERSION, isolation_id: workspace.isolationId,
|
|
449
|
+
source: this.source, base_revision: workspace.baseRevision,
|
|
450
|
+
child_revision: revision, entries: Object.values(entries).map(e => ({
|
|
451
|
+
path: e.path, kind: e.kind, mode: e.mode, content_digest: e.contentDigest,
|
|
452
|
+
})),
|
|
453
|
+
}), 'utf-8');
|
|
454
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
455
|
+
if (isSymlink(target) || isSymlink(dirname(target))) throw new ChildWorkspaceError('child_publication_inventory_changed');
|
|
456
|
+
if (existsSync(target)) {
|
|
457
|
+
if (readFileSync(target).toString('utf-8') !== value.toString('utf-8')) throw new ChildWorkspaceError('child_publication_inventory_changed');
|
|
458
|
+
} else {
|
|
459
|
+
publishWorkspaceBytes(target, value, { createOnly: true });
|
|
460
|
+
}
|
|
461
|
+
return { revision, inventory_digest: 'sha256:' + sha256(value) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Publish eligible files only when the parent still matches the base.
|
|
465
|
+
* A conflict publishes nothing. An OS failure reports the real applied
|
|
466
|
+
* prefix — this is NOT an atomic multi-file transaction.
|
|
467
|
+
* @param {object} [opts] merge options
|
|
468
|
+
* @param {string} opts.expectedRevision the current child workspace revision
|
|
469
|
+
* @param {ChildFilePublication} [opts.previous] a caller-supplied acknowledged publication
|
|
470
|
+
* @param {function():true} [opts.assertCurrent] a synchronous Host guard the
|
|
471
|
+
* module calls after acquiring the lock, after the precheck, and before
|
|
472
|
+
* each real publish/delete. It must return strictly === true; any other
|
|
473
|
+
* value (or throw) aborts the merge with no further writes. The Host owns
|
|
474
|
+
* the real ledger/fence/receipt; this module never assumes true by default. */
|
|
475
|
+
merge(workspace, { expectedRevision, previous, assertCurrent } = {}) {
|
|
476
|
+
// Reject a forged workspace whose root/baseline do not belong to this
|
|
477
|
+
// storage under the original identity — before any lock or write.
|
|
478
|
+
this._validateWorkspace(workspace);
|
|
479
|
+
const applied = [];
|
|
480
|
+
const guard = () => {
|
|
481
|
+
if (assertCurrent === undefined) return;
|
|
482
|
+
try {
|
|
483
|
+
if (typeof assertCurrent !== 'function') throw new ChildWorkspaceError('child_workspace_guard_failed');
|
|
484
|
+
const result = assertCurrent();
|
|
485
|
+
if (result !== true) {
|
|
486
|
+
if (result && typeof result.then === 'function') Promise.resolve(result).catch(() => {});
|
|
487
|
+
throw new ChildWorkspaceError('child_workspace_guard_failed');
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// A revoked lease after a published prefix cannot become a clean
|
|
491
|
+
// rejection inviting a blind retry. Preserve the actual prefix for
|
|
492
|
+
// the owning Native ToolPort's uncertain receipt.
|
|
493
|
+
const failure = new ChildWorkspaceError('child_workspace_guard_failed');
|
|
494
|
+
failure.applied = [...applied];
|
|
495
|
+
failure.effectStarted = applied.length > 0;
|
|
496
|
+
throw failure;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
guard();
|
|
500
|
+
// All conflict checks happen BEFORE any write. The critical section is
|
|
501
|
+
// synchronous; no boundary is loosened after an await (there is no await).
|
|
502
|
+
const release = lockPaths([this.source, workspace.root, workspace.baseline]);
|
|
503
|
+
try {
|
|
504
|
+
// The post-lock guard is INSIDE the try so a guard failure still releases
|
|
505
|
+
// all locks in the finally below. A guard failure must never leak a lock.
|
|
506
|
+
guard();
|
|
507
|
+
const baseCapture = captureInventory(workspace.baseline);
|
|
508
|
+
if (baseCapture.revision !== workspace.baseRevision) throw new ChildWorkspaceError('child_workspace_baseline_changed');
|
|
509
|
+
const childCapture = captureInventory(workspace.root);
|
|
510
|
+
if (childCapture.revision !== expectedRevision) throw new ChildWorkspaceError('child_workspace_after_delivery_changed');
|
|
511
|
+
const verifiedPrevious = previous ? this._verifyPreviousPublication(workspace, previous) : null;
|
|
512
|
+
const usedPrevious = !!verifiedPrevious;
|
|
513
|
+
const baseline = verifiedPrevious ?? baseCapture.entries;
|
|
514
|
+
const proposal = childCapture.entries;
|
|
515
|
+
// Files that are files in EITHER the baseline or the proposal.
|
|
516
|
+
const files = new Set();
|
|
517
|
+
for (const [path, entry] of Object.entries(baseline)) if (entry.kind === 'file') files.add(path);
|
|
518
|
+
for (const [path, entry] of Object.entries(proposal)) if (entry.kind === 'file') files.add(path);
|
|
519
|
+
const changed = [...files].filter(path => {
|
|
520
|
+
const b = baseline[path], p = proposal[path];
|
|
521
|
+
return !b || !p || !b.equals(p);
|
|
522
|
+
}).sort();
|
|
523
|
+
// A file becoming a directory or vice versa is a hard error.
|
|
524
|
+
for (const path of changed) {
|
|
525
|
+
const b = baseline[path], p = proposal[path];
|
|
526
|
+
if (b && p && b.kind !== p.kind) throw new ChildWorkspaceError('child_workspace_file_kind_changed');
|
|
527
|
+
}
|
|
528
|
+
const directories = new Set();
|
|
529
|
+
for (const [path, entry] of Object.entries(baseline)) if (entry.kind === 'directory') directories.add(path);
|
|
530
|
+
for (const [path, entry] of Object.entries(proposal)) if (entry.kind === 'directory') directories.add(path);
|
|
531
|
+
const changedDirectories = [...directories].filter(path => {
|
|
532
|
+
const b = baseline[path], p = proposal[path];
|
|
533
|
+
return !b || !p || !b.equals(p);
|
|
534
|
+
});
|
|
535
|
+
const removedDirectories = new Set(changedDirectories.filter(path => !proposal[path]));
|
|
536
|
+
// Re-capture the parent to detect conflicts with concurrent changes.
|
|
537
|
+
// The comparison is a strict three-way check: parent.get(path) must be in
|
|
538
|
+
// (baseline.get(path), proposal.get(path)), where null (absent) is a real
|
|
539
|
+
// state. A parent DELETION (null) while the child edited the file is a
|
|
540
|
+
// conflict — the child's edit must not resurrect a parent-deleted file.
|
|
541
|
+
// null === null via entryEquals; both absent is NOT a conflict.
|
|
542
|
+
const parentCapture = captureInventory(this.source);
|
|
543
|
+
const parent = parentCapture.entries;
|
|
544
|
+
const entryEquals = (a, b) => (!a && !b) || (!!a && !!b && a.equals(b));
|
|
545
|
+
const conflicts = [];
|
|
546
|
+
for (const path of changed) {
|
|
547
|
+
if (!entryEquals(parent[path], baseline[path]) && !entryEquals(parent[path], proposal[path]))
|
|
548
|
+
conflicts.push(path);
|
|
549
|
+
}
|
|
550
|
+
for (const path of changedDirectories) {
|
|
551
|
+
if (!entryEquals(parent[path], baseline[path]) && !entryEquals(parent[path], proposal[path]))
|
|
552
|
+
conflicts.push(path);
|
|
553
|
+
}
|
|
554
|
+
// A late parent file added inside a removed directory must be preserved.
|
|
555
|
+
for (const [path] of Object.entries(parent)) {
|
|
556
|
+
if (!baseline[path]) {
|
|
557
|
+
for (const dir of removedDirectories) {
|
|
558
|
+
if (path.startsWith(dir + '/')) { conflicts.push(path); break; }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (conflicts.length) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false, error_code: 'child_workspace_merge_conflict',
|
|
565
|
+
conflicts: [...new Set(conflicts)].sort(), applied: [],
|
|
566
|
+
comparison_revision: usedPrevious ? previous.revision : baseCapture.revision,
|
|
567
|
+
instruction: 'Read the current parent file and resolve the conflict with its ordinary authorized editing tools. Nothing was merged.',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
guard();
|
|
571
|
+
// Save the immutable publication inventory BEFORE applying, so a crash
|
|
572
|
+
// after the save but before a partial apply leaves a verifiable receipt.
|
|
573
|
+
const publication = this._savePublication(workspace, childCapture.revision, proposal);
|
|
574
|
+
const payloads = Object.create(null);
|
|
575
|
+
for (const path of changed) {
|
|
576
|
+
if (proposal[path]) payloads[path] = readPayload(workspace.root, path, proposal[path]);
|
|
577
|
+
}
|
|
578
|
+
const alreadyPresent = [];
|
|
579
|
+
// Create/mode directories first (shallow-to-deep), then files, then remove
|
|
580
|
+
// directories (deep-to-shallow) so an empty directory's children are gone.
|
|
581
|
+
const createOrMode = changedDirectories.filter(d => !removedDirectories.has(d))
|
|
582
|
+
.sort((a, b) => (a.split('/').length - b.split('/').length) || (a < b ? -1 : a > b ? 1 : 0));
|
|
583
|
+
const remove = [...removedDirectories].sort((a, b) => (b.split('/').length - a.split('/').length) || (a < b ? -1 : a > b ? 1 : 0));
|
|
584
|
+
const order = [...createOrMode, ...changed, ...remove];
|
|
585
|
+
for (const path of order) {
|
|
586
|
+
// Both absent (parent and proposal both deleted) is already-present
|
|
587
|
+
// (nothing to do); both present and equal is already-present. Use the
|
|
588
|
+
// same entryEquals as the conflict check so null is a real state.
|
|
589
|
+
if (entryEquals(parent[path], proposal[path])) { alreadyPresent.push(path); continue; }
|
|
590
|
+
const target = join(this.source, path);
|
|
591
|
+
guard();
|
|
592
|
+
try {
|
|
593
|
+
if (changedDirectories.includes(path) || removedDirectories.has(path)) {
|
|
594
|
+
if (!proposal[path]) {
|
|
595
|
+
// Never recursively delete: only remove an empty directory.
|
|
596
|
+
rmdirSync(target);
|
|
597
|
+
} else if (!parent[path]) {
|
|
598
|
+
mkdirSync(target, { recursive: true });
|
|
599
|
+
}
|
|
600
|
+
applied.push(path);
|
|
601
|
+
if (proposal[path]) chmodSync(target, proposal[path].mode);
|
|
602
|
+
} else if (proposal[path]) {
|
|
603
|
+
publishWorkspaceBytes(target, payloads[path], { createOnly: !baseline[path] });
|
|
604
|
+
applied.push(path);
|
|
605
|
+
chmodSync(target, proposal[path].mode);
|
|
606
|
+
} else {
|
|
607
|
+
unlinkSync(target);
|
|
608
|
+
applied.push(path);
|
|
609
|
+
}
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (error instanceof ChildWorkspaceError) throw error;
|
|
612
|
+
return {
|
|
613
|
+
ok: false, error_code: 'child_workspace_publish_failed',
|
|
614
|
+
applied, failed_path: path,
|
|
615
|
+
instruction: 'Inspect the reported files before continuing; this is not an atomic multi-file transaction.',
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
ok: true, applied, already_present: alreadyPresent,
|
|
621
|
+
base_revision: baseCapture.revision, child_revision: childCapture.revision,
|
|
622
|
+
comparison_revision: usedPrevious ? previous.revision : baseCapture.revision,
|
|
623
|
+
publication,
|
|
624
|
+
};
|
|
625
|
+
} finally { release(); }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---- helpers ----------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
/** Canonicalize a root path: resolve symlinks for the existing portion (like
|
|
632
|
+
* Python's Path.resolve()), normalizing macOS /var → /private/var aliases.
|
|
633
|
+
* For a not-yet-created path, resolve the closest existing ancestor and
|
|
634
|
+
* append the non-existent tail lexically. This is NOT a substitute for a
|
|
635
|
+
* trusted directory fd, but it detects a swap visible at the path string. */
|
|
636
|
+
function canonicalRoot(p) {
|
|
637
|
+
const resolved = resolve(p);
|
|
638
|
+
if (existsSync(resolved)) {
|
|
639
|
+
try { return realpathSync(resolved); } catch { return resolved; }
|
|
640
|
+
}
|
|
641
|
+
return realpathExisting(resolved);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function isSymlink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
645
|
+
function isDir(p) { try { return lstatSync(p).isDirectory(); } catch { return false; } }
|
|
646
|
+
function rmdirRecursive(p) {
|
|
647
|
+
let dirents;
|
|
648
|
+
try { dirents = readdirSync(p, { withFileTypes: true }); } catch { return; }
|
|
649
|
+
for (const d of dirents) {
|
|
650
|
+
const abs = join(p, d.name);
|
|
651
|
+
if (d.isDirectory()) rmdirRecursive(abs);
|
|
652
|
+
else try { unlinkSync(abs); } catch { /* gone */ }
|
|
653
|
+
}
|
|
654
|
+
try { rmdirSync(p); } catch { /* not empty */ }
|
|
655
|
+
}
|