@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.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/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,1740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Node workspace tools for the AgentKit Harness.
|
|
3
|
+
*
|
|
4
|
+
* Exports createNativeWorkspaceTools({workspaceRoot, stateDir, sessionId,
|
|
5
|
+
* authorize}) -> {definitions, execute, observe}. `definitions` is the complete
|
|
6
|
+
* ToolDefinition[] for the ten formal workspace tools ported from
|
|
7
|
+
* ksadk.toolsets.workspace. `execute` runs a ToolRequest and returns a
|
|
8
|
+
* ToolResult; `observe` returns a WorkspaceObservation over the workspace root.
|
|
9
|
+
*
|
|
10
|
+
* Faithful to the Python semantics that matter to the model and the safety
|
|
11
|
+
* boundary: pagination and content limits, read-after-modify and stale-read
|
|
12
|
+
* rejection, symlink/path-escape protection, atomic writes, cancellation and
|
|
13
|
+
* explicit errors. Host-owned concerns (effect intent/receipt, artifact
|
|
14
|
+
* persistence, source-read budget, diagnostics overlay, definition digest) are
|
|
15
|
+
* deliberately not reimplemented here. Python syntax checks use the pinned
|
|
16
|
+
* Rust/WASM parser in an isolated Node worker; nothing launches Python.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import {
|
|
20
|
+
chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync,
|
|
21
|
+
readFileSync, readlinkSync, readSync, realpathSync, rmdirSync, statSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { checkPythonSyntax, NATIVE_PYTHON_SYNTAX } from './python-syntax.mjs';
|
|
26
|
+
import { acquireWorkspaceMutationLock, workspaceMutationGuard } from './workspace-mutation-lock.mjs';
|
|
27
|
+
import { budgetWorkspaceSourcePage, validateSourcePageBudget } from './source-page.mjs';
|
|
28
|
+
import { prepareWorkspaceEdits } from './workspace-edit-match.mjs';
|
|
29
|
+
import { prepareWorkspacePatch } from './workspace-patch.mjs';
|
|
30
|
+
import { workspaceChangeDisplay } from './workspace-display.mjs';
|
|
31
|
+
import { readBoundedWorkspaceImage } from './workspace-image.mjs';
|
|
32
|
+
import { createWorkspaceCheckpointJournal, captureCheckpointFile, checkpointStatesEqual, WORKSPACE_CHECKPOINT_POLICY, CONVERSATION_WORKSPACE_CONTROL } from './workspace-checkpoints.mjs';
|
|
33
|
+
import { parseStructuredPatch } from '@kingsoftcloud/agentkit-harness-core/patch-parser';
|
|
34
|
+
import { canonical } from '@kingsoftcloud/agentkit-harness-core';
|
|
35
|
+
import { captureWorkspaceSubject } from './workspace-subject-observer.mjs';
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
WorkspacePathError, escapeRegex, fnmatch, publishWorkspaceBytes, sha256,
|
|
39
|
+
TWO_MB, resolveWorkspacePath, workspaceRelative,
|
|
40
|
+
} from './workspace-paths.mjs';
|
|
41
|
+
import {
|
|
42
|
+
applySingleEdit, buildDiff, changeCounts, diffLineCount, readWorkspaceText,
|
|
43
|
+
splitlines, validateReadState, validateSingleEditRequest,
|
|
44
|
+
} from './workspace-editing.mjs';
|
|
45
|
+
import {
|
|
46
|
+
WorkspaceStateStore, argumentsDigest, makeReadState, serializeReadState, deserializeReadState,
|
|
47
|
+
} from './workspace-state.mjs';
|
|
48
|
+
|
|
49
|
+
const MUTATING = new Set([
|
|
50
|
+
'write_workspace_file', 'write_workspace_files', 'edit_workspace_file',
|
|
51
|
+
'multi_edit_workspace_file', 'delete_workspace_file', 'apply_workspace_patch', 'rewind_workspace_files',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const READ_ONLY = new Set([
|
|
55
|
+
'workspace_status', 'list_workspace_files', 'read_workspace_file',
|
|
56
|
+
'lint_workspace_file', 'search_workspace_files',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const OBSERVE_MAX_ENTRIES = 2048;
|
|
60
|
+
const OBSERVE_MAX_HASHED = 64 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
class CaptureError extends Error {
|
|
63
|
+
constructor(reason) { super(reason); this.code = reason; this.name = 'CaptureError'; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Raised when the workspace root symlink is swapped to point outside its
|
|
67
|
+
* creation-time boundary. Pinned at factory construction; never re-resolved. */
|
|
68
|
+
class RootBoundaryError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'RootBoundaryError';
|
|
72
|
+
this.code = 'workspace_root_changed';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createNativeWorkspaceTools({
|
|
77
|
+
workspaceRoot, stateDir, sessionId, authorize, enablePatches = false, enableRewind = false,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!workspaceRoot) throw new TypeError('createNativeWorkspaceTools requires workspaceRoot');
|
|
80
|
+
if (!stateDir) throw new TypeError('createNativeWorkspaceTools requires stateDir');
|
|
81
|
+
const root = workspaceRoot;
|
|
82
|
+
const store = new WorkspaceStateStore(stateDir, sessionId);
|
|
83
|
+
const authorizeFn = authorize || defaultAuthorize;
|
|
84
|
+
const imageObservations = new WeakMap();
|
|
85
|
+
// Mutating implementations below are synchronous. Track publications within the
|
|
86
|
+
// current invocation so a later state-write failure cannot masquerade as a
|
|
87
|
+
// definite failure before execution. No tracking state crosses an await.
|
|
88
|
+
let activeMutations = null, activeGuard = null, activeCheckpoint = null, activePublications = null, activeReads = null, activeDisplayChanges = null;
|
|
89
|
+
function rememberDisplay(path, before, after) {
|
|
90
|
+
if (!before || !activeDisplayChanges) return;
|
|
91
|
+
const previous = activeDisplayChanges.get(path);
|
|
92
|
+
activeDisplayChanges.set(path, { before: previous?.before ?? before, after });
|
|
93
|
+
}
|
|
94
|
+
function rememberRead(state) {
|
|
95
|
+
store.recordReadState(state);
|
|
96
|
+
if (!state.partial) activeReads?.set(state.path, serializeReadState(state));
|
|
97
|
+
}
|
|
98
|
+
function assertCheckpointPath(target) {
|
|
99
|
+
if (activeCheckpoint && resolvePath(relativeOf(target)) !== target)
|
|
100
|
+
throw Object.assign(Error('checkpoint_path_changed'), { code: 'checkpoint_path_changed' });
|
|
101
|
+
}
|
|
102
|
+
function publish(target, bytes, options) {
|
|
103
|
+
activeGuard?.(); assertCheckpointPath(target);
|
|
104
|
+
const before = activeCheckpoint?.stage(relativeOf(target), { kind: 'file', bytes: bytes.toString('base64'), sha256: sha256(bytes),
|
|
105
|
+
...(options?.checkpointMode !== undefined ? { mode: options.checkpointMode } : {}) });
|
|
106
|
+
activeGuard?.(); assertCheckpointPath(target);
|
|
107
|
+
publishWorkspaceBytes(target, bytes, options);
|
|
108
|
+
rememberDisplay(relativeOf(target), before, bytes.length <= 128 * 1024
|
|
109
|
+
? { kind: 'file', bytes: bytes.toString('base64') } : { kind: 'unavailable' });
|
|
110
|
+
activeMutations?.add(relativeOf(target));
|
|
111
|
+
activePublications?.set(relativeOf(target), { kind: 'file', sha256: sha256(bytes), mode: before?.kind === 'file' ? before.mode : 0o666 & ~process.umask() });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pin the authorized root boundary at factory creation. The caller passes a
|
|
115
|
+
// trusted workspaceRoot; resolving its realpath once here establishes the
|
|
116
|
+
// directory this port is allowed to touch. We never re-resolve `root` later,
|
|
117
|
+
// so swapping the root path (or a parent) for a symlink that points outside
|
|
118
|
+
// cannot widen the boundary mid-life: execute and observe compare the live
|
|
119
|
+
// realpath against this pinned value and refuse on mismatch.
|
|
120
|
+
const pinnedRootReal = resolvePinnedRoot(root);
|
|
121
|
+
const checkpoints = createWorkspaceCheckpointJournal({ stateDir, sessionId: store.sessionId, workspaceRoot: pinnedRootReal,
|
|
122
|
+
resolvePath: path => resolvePath(path) });
|
|
123
|
+
function remove(target, directory = false) {
|
|
124
|
+
activeGuard?.(); assertCheckpointPath(target); const before = activeCheckpoint?.stage(relativeOf(target), { kind: 'absent' });
|
|
125
|
+
activeGuard?.(); assertCheckpointPath(target);
|
|
126
|
+
if (directory) rmdirSync(target); else unlinkSync(target);
|
|
127
|
+
rememberDisplay(relativeOf(target), before, { kind: 'absent' });
|
|
128
|
+
activeMutations?.add(relativeOf(target));
|
|
129
|
+
activePublications?.set(relativeOf(target), { kind: 'absent' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function compensateRewind(plan, publications, checkpoint, guard) {
|
|
133
|
+
guard(); checkpoint.compensating(); guard();
|
|
134
|
+
const originals = new Map(plan.files.map(file => [file.path, file]));
|
|
135
|
+
for (const [path, published] of [...publications].reverse()) {
|
|
136
|
+
const file = originals.get(path), target = file?.canonicalPath, desired = file?.current;
|
|
137
|
+
const check = () => { guard(); if (!target || resolvePath(path) !== target)
|
|
138
|
+
throw Object.assign(Error('checkpoint_path_changed'), { code: 'checkpoint_path_changed' }); };
|
|
139
|
+
check();
|
|
140
|
+
if (!desired || !checkpointStatesEqual(captureCheckpointFile(target), published))
|
|
141
|
+
throw Object.assign(Error('checkpoint_compensation_conflict'), { code: 'checkpoint_compensation_conflict' });
|
|
142
|
+
// Restore only our own last publication. Do not stage these inverse writes
|
|
143
|
+
// as a new user operation or overwrite changes made by another editor.
|
|
144
|
+
if (published.kind !== 'absent' && published.kind !== desired.kind) {
|
|
145
|
+
check(); if (published.kind === 'directory') rmdirSync(target); else unlinkSync(target);
|
|
146
|
+
}
|
|
147
|
+
if (desired.kind === 'file') {
|
|
148
|
+
check(); publishWorkspaceBytes(target, Buffer.from(desired.bytes, 'base64'));
|
|
149
|
+
check(); chmodSync(target, desired.mode);
|
|
150
|
+
} else if (desired.kind === 'directory') {
|
|
151
|
+
check(); mkdirSync(target, { recursive: true, mode: desired.mode });
|
|
152
|
+
check(); chmodSync(target, desired.mode);
|
|
153
|
+
}
|
|
154
|
+
check();
|
|
155
|
+
if (!checkpointStatesEqual(captureCheckpointFile(target), desired))
|
|
156
|
+
throw Object.assign(Error('checkpoint_compensation_conflict'), { code: 'checkpoint_compensation_conflict' });
|
|
157
|
+
}
|
|
158
|
+
guard(); checkpoint.compensated(); guard();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolvePinnedRoot(r) {
|
|
162
|
+
// If the root exists, lock its realpath. If not yet created, lock the
|
|
163
|
+
// lexical path so it can be created later but is still boundary-checked.
|
|
164
|
+
try {
|
|
165
|
+
if (existsSync(r)) {
|
|
166
|
+
const real = realpathSync(r);
|
|
167
|
+
if (!lstatSync(r).isSymbolicLink()) return real;
|
|
168
|
+
// A symlinked root at creation is allowed but pinned to its target;
|
|
169
|
+
// a later swap to a different target is rejected by checkRootBoundary.
|
|
170
|
+
return real;
|
|
171
|
+
}
|
|
172
|
+
} catch { /* fall through to lexical */ }
|
|
173
|
+
return r;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function checkRootBoundary() {
|
|
177
|
+
// The live root must still resolve to the same real path it was pinned to.
|
|
178
|
+
// A symlink swap (or a parent rewrite) that changes the realpath is refused
|
|
179
|
+
// for both execute and observe, regardless of authorization outcome.
|
|
180
|
+
if (!existsSync(root)) {
|
|
181
|
+
const err = new RootBoundaryError('workspace root no longer exists');
|
|
182
|
+
err.code = 'workspace_missing';
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
let live;
|
|
186
|
+
try { live = realpathSync(root); }
|
|
187
|
+
catch {
|
|
188
|
+
const err = new RootBoundaryError('workspace root is not resolvable');
|
|
189
|
+
err.code = 'workspace_missing';
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
if (live !== pinnedRootReal) {
|
|
193
|
+
throw new RootBoundaryError('workspace root was replaced or repointed');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
function resolvePath(relativePath) {
|
|
199
|
+
return resolveWorkspacePath(root, pinnedRootReal, relativePath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function relativeOf(target) {
|
|
203
|
+
return workspaceRelative(pinnedRootReal, target);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- tool implementations -------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function workspaceStatus() {
|
|
209
|
+
const real = pinnedRootReal;
|
|
210
|
+
mkdirSync(real, { recursive: true });
|
|
211
|
+
const all = [];
|
|
212
|
+
collectFiles(real, '', all);
|
|
213
|
+
all.sort();
|
|
214
|
+
const files = all.slice(0, 50).map((rel) => {
|
|
215
|
+
let size = 0;
|
|
216
|
+
try { size = Number(lstatSync(join(real, rel), { bigint: true }).size); } catch { /* gone */ }
|
|
217
|
+
return { path: rel, size };
|
|
218
|
+
});
|
|
219
|
+
return { ok: true, workspace_root: real, file_count_sampled: files.length, files };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function collectFiles(base, rel, out) {
|
|
223
|
+
let dirents;
|
|
224
|
+
try { dirents = readdirSync(rel ? join(base, rel) : base, { withFileTypes: true }); } catch { return; }
|
|
225
|
+
for (const d of dirents) {
|
|
226
|
+
const entryRel = rel ? `${rel}/${d.name}` : d.name;
|
|
227
|
+
if (d.isDirectory() && !d.isSymbolicLink()) {
|
|
228
|
+
collectFiles(base, entryRel, out);
|
|
229
|
+
} else if (d.isFile()) {
|
|
230
|
+
out.push(entryRel);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function listWorkspaceFiles(args, { request } = {}) {
|
|
236
|
+
const path = String(args.path ?? '.');
|
|
237
|
+
const glob = args.glob ?? null;
|
|
238
|
+
const recursive = Boolean(args.recursive);
|
|
239
|
+
const includeDirs = args.include_dirs !== false;
|
|
240
|
+
const maxResults = Number(args.max_results ?? 500);
|
|
241
|
+
const sortBy = String(args.sort_by ?? 'name').trim().toLowerCase();
|
|
242
|
+
const target = resolvePath(path);
|
|
243
|
+
if (!existsSync(target)) return { ok: false, error_message: `workspace path not found: ${path}` };
|
|
244
|
+
const items = []; // {abs, name}
|
|
245
|
+
const targetInfo = lstatSync(target, { bigint: true });
|
|
246
|
+
if (targetInfo.isFile()) {
|
|
247
|
+
items.push({ abs: target, name: target.split('/').pop() });
|
|
248
|
+
} else if (targetInfo.isDirectory()) {
|
|
249
|
+
const stack = [target];
|
|
250
|
+
while (stack.length) {
|
|
251
|
+
const cur = stack.pop();
|
|
252
|
+
let dirents;
|
|
253
|
+
try { dirents = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
254
|
+
for (const d of dirents) {
|
|
255
|
+
const abs = join(cur, d.name);
|
|
256
|
+
items.push({ abs, name: d.name });
|
|
257
|
+
if (recursive && d.isDirectory() && !d.isSymbolicLink()) stack.push(abs);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let filtered = items;
|
|
262
|
+
if (glob) {
|
|
263
|
+
filtered = items.filter((it) => {
|
|
264
|
+
const rel = relativeOf(it.abs);
|
|
265
|
+
return fnmatch(rel, glob) || fnmatch(it.name, glob);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const enriched = filtered.map((it) => {
|
|
269
|
+
let info = null;
|
|
270
|
+
try { info = lstatSync(it.abs, { bigint: true }); } catch { /* gone */ }
|
|
271
|
+
const isDir = info ? info.isDirectory() : false;
|
|
272
|
+
const isFile = info ? info.isFile() : false;
|
|
273
|
+
return {
|
|
274
|
+
rel: relativeOf(it.abs),
|
|
275
|
+
name: it.name,
|
|
276
|
+
isFile,
|
|
277
|
+
isDir,
|
|
278
|
+
size: isFile && info ? Number(info.size) : 0,
|
|
279
|
+
mtimeNs: info ? info.mtimeNs : 0n,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
let visible = enriched;
|
|
283
|
+
if (!includeDirs) visible = enriched.filter((e) => e.isFile);
|
|
284
|
+
sortEntries(visible, sortBy);
|
|
285
|
+
const limit = Math.max(1, Math.min(maxResults || 500, 5000));
|
|
286
|
+
const sliced = visible.slice(0, limit);
|
|
287
|
+
const entries = sliced.map((e) => ({
|
|
288
|
+
name: e.name,
|
|
289
|
+
path: e.rel,
|
|
290
|
+
type: e.isDir ? 'directory' : 'file',
|
|
291
|
+
size: e.size,
|
|
292
|
+
mtime_ns: request?.definition?.directoryObservationPolicy ? e.mtimeNs : Number(e.mtimeNs),
|
|
293
|
+
}));
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
path,
|
|
297
|
+
recursive,
|
|
298
|
+
entries,
|
|
299
|
+
truncated: visible.length > entries.length,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sortEntries(entries, sortBy) {
|
|
304
|
+
if (sortBy === 'mtime') {
|
|
305
|
+
entries.sort((a, b) => (a.mtimeNs < b.mtimeNs ? -1 : a.mtimeNs > b.mtimeNs ? 1 : a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
306
|
+
} else if (sortBy === 'size') {
|
|
307
|
+
entries.sort((a, b) => (a.size - b.size) || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
308
|
+
} else {
|
|
309
|
+
entries.sort((a, b) => {
|
|
310
|
+
const ka = a.rel.toLowerCase();
|
|
311
|
+
const kb = b.rel.toLowerCase();
|
|
312
|
+
if (ka < kb) return -1;
|
|
313
|
+
if (ka > kb) return 1;
|
|
314
|
+
return (a.isFile === b.isFile) ? 0 : a.isFile ? 1 : -1;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readWorkspaceFile(args, { request } = {}) {
|
|
320
|
+
const sourceBudget = request?.definition?.sourceObservationPolicy ? validateSourcePageBudget(request.sourceReadBudget) : null;
|
|
321
|
+
const path = String(args.path ?? '');
|
|
322
|
+
const includeLineNumbers = args.include_line_numbers !== false;
|
|
323
|
+
const target = resolvePath(path);
|
|
324
|
+
let stat;
|
|
325
|
+
try { stat = statSync(target, { bigint: true }); } catch {
|
|
326
|
+
return { ok: false, error_message: `workspace file not found: ${path}` };
|
|
327
|
+
}
|
|
328
|
+
if (!stat.isFile()) return { ok: false, error_message: `workspace file not found: ${path}` };
|
|
329
|
+
if (request?.definition?.workspaceMediaPolicy === 'bounded-image-read/v1') {
|
|
330
|
+
const image = readBoundedWorkspaceImage(target, relativeOf(target), () => {
|
|
331
|
+
activeGuard?.();
|
|
332
|
+
if (resolvePath(path) !== target) throw Object.assign(Error('workspace_image_changed'), { code: 'workspace_image_changed' });
|
|
333
|
+
});
|
|
334
|
+
if (image) { imageObservations.set(image.content, image.context); return image.content; }
|
|
335
|
+
}
|
|
336
|
+
if (Number(stat.size) > TWO_MB) return { ok: false, error_message: 'file is larger than 2MB' };
|
|
337
|
+
const { text, error } = readWorkspaceText(target);
|
|
338
|
+
if (error) return { ...error, path };
|
|
339
|
+
const lines = splitlines(text);
|
|
340
|
+
const totalLines = lines.length;
|
|
341
|
+
const start = Math.max(1, Number(args.start_line ?? 1));
|
|
342
|
+
const end = totalLines ? Math.min(totalLines, Number(args.end_line ?? totalLines)) : 0;
|
|
343
|
+
if (end && start > end) {
|
|
344
|
+
const rel = relativeOf(target);
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error_message: 'start_line must be <= end_line',
|
|
348
|
+
path: rel,
|
|
349
|
+
requested_range: { start, end: args.end_line },
|
|
350
|
+
total_lines: totalLines,
|
|
351
|
+
suggested_action: `Re-read the current file with read_workspace_file(path='${rel}', start_line=${Math.max(1, Math.min(start, totalLines))}) before editing.`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const selected = totalLines ? lines.slice(start - 1, end) : [];
|
|
355
|
+
const content = selected.map((line, i) => includeLineNumbers ? `${start + i} | ${line}` : line).join('\n');
|
|
356
|
+
if (sourceBudget) {
|
|
357
|
+
const rel = relativeOf(target);
|
|
358
|
+
const result = budgetWorkspaceSourcePage({ ok: true, path: rel, size: Number(stat.size), size_bytes: Number(stat.size),
|
|
359
|
+
mtime_ns: stat.mtimeNs, start_line: start, end_line: end, line_count: selected.length,
|
|
360
|
+
read_range: { start, end }, total_lines: totalLines, partial: start !== 1 || end !== totalLines, suggested_action: '' },
|
|
361
|
+
content, args.max_chars, sourceBudget);
|
|
362
|
+
if (result.ok && !result.artifact_only) rememberRead(makeReadState({ path: rel, mtimeNs: stat.mtimeNs,
|
|
363
|
+
sizeBytes: Number(stat.size), startLine: start, endLine: result.end_line ?? start, totalLines, partial: result.partial,
|
|
364
|
+
canonicalPath: target, contentSha256: sha256(Buffer.from(text, 'utf-8')) }));
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
const limit = Math.max(1000, Math.min(Number(args.max_chars ?? 20000), 100000));
|
|
368
|
+
let returnedEnd = end;
|
|
369
|
+
let returnedCount = selected.length;
|
|
370
|
+
let partial = start !== 1 || end !== totalLines;
|
|
371
|
+
let previewChars = content.length;
|
|
372
|
+
let truncated = false;
|
|
373
|
+
let window = {};
|
|
374
|
+
let visible = content;
|
|
375
|
+
if (content.length > limit) {
|
|
376
|
+
const preview = content.slice(0, limit);
|
|
377
|
+
const complete = preview.length > 0 && content[limit] === '\n';
|
|
378
|
+
const boundary = preview.lastIndexOf('\n');
|
|
379
|
+
if (complete) {
|
|
380
|
+
returnedCount = preview.split('\n').length;
|
|
381
|
+
} else if (boundary >= 0) {
|
|
382
|
+
returnedCount = preview.slice(0, boundary).split('\n').length;
|
|
383
|
+
} else {
|
|
384
|
+
returnedCount = 0;
|
|
385
|
+
}
|
|
386
|
+
visible = complete ? preview : (returnedCount ? preview.slice(0, boundary) : '');
|
|
387
|
+
returnedEnd = returnedCount ? start + returnedCount - 1 : null;
|
|
388
|
+
previewChars = visible.length;
|
|
389
|
+
truncated = true;
|
|
390
|
+
partial = true;
|
|
391
|
+
window = {
|
|
392
|
+
source_read_range: { start, end },
|
|
393
|
+
next_start_line: returnedCount ? returnedEnd + 1 : null,
|
|
394
|
+
suggested_action: returnedCount
|
|
395
|
+
? `Continue with read_workspace_file(path='${relativeOf(target)}', start_line=${returnedEnd + 1}); the remaining lines were not returned.`
|
|
396
|
+
: 'A source line exceeds the output window. Use bound search or another permitted inspection tool; the unread remainder cannot be skipped.',
|
|
397
|
+
};
|
|
398
|
+
if (!returnedCount) window.partial_line = start;
|
|
399
|
+
}
|
|
400
|
+
const rel = relativeOf(target);
|
|
401
|
+
rememberRead(makeReadState({
|
|
402
|
+
path: rel,
|
|
403
|
+
mtimeNs: stat.mtimeNs,
|
|
404
|
+
sizeBytes: Number(stat.size),
|
|
405
|
+
startLine: start,
|
|
406
|
+
endLine: returnedEnd !== null ? returnedEnd : start,
|
|
407
|
+
totalLines,
|
|
408
|
+
partial,
|
|
409
|
+
canonicalPath: target,
|
|
410
|
+
contentSha256: sha256(Buffer.from(text, 'utf-8')),
|
|
411
|
+
}));
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
path: rel,
|
|
415
|
+
size: Number(stat.size),
|
|
416
|
+
size_bytes: Number(stat.size),
|
|
417
|
+
mtime_ns: Number(stat.mtimeNs),
|
|
418
|
+
start_line: start,
|
|
419
|
+
end_line: returnedEnd,
|
|
420
|
+
line_count: returnedCount,
|
|
421
|
+
read_range: returnedEnd !== null ? { start, end: returnedEnd } : null,
|
|
422
|
+
total_lines: totalLines,
|
|
423
|
+
suggested_action: '',
|
|
424
|
+
partial,
|
|
425
|
+
content: visible,
|
|
426
|
+
preview_chars: previewChars,
|
|
427
|
+
truncated,
|
|
428
|
+
...window,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function writePrecondition(path, overwrite) {
|
|
433
|
+
const target = resolvePath(path);
|
|
434
|
+
const rel = relativeOf(target);
|
|
435
|
+
if (!existsSync(target)) return { target, rel, error: null, previous: '' };
|
|
436
|
+
if (!overwrite) {
|
|
437
|
+
return { target, rel, error: { ok: false, path: rel, error_type: 'file_exists', error_message: `workspace file already exists: ${path}` }, previous: '' };
|
|
438
|
+
}
|
|
439
|
+
const { text: current, error } = readWorkspaceText(target);
|
|
440
|
+
if (error) return { target, rel, error: { ...error, path: rel }, previous: '' };
|
|
441
|
+
const state = store.getReadState(rel);
|
|
442
|
+
if (state && state.partial) {
|
|
443
|
+
return {
|
|
444
|
+
target, rel, previous: '',
|
|
445
|
+
error: {
|
|
446
|
+
ok: false, path: rel, error_type: 'file_not_fully_read',
|
|
447
|
+
error_message: 'existing file must be read completely before overwriting',
|
|
448
|
+
suggested_action: `Call read_workspace_file(path='${rel}') without a line range, then retry.`,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
let validation = validateReadState(target, rel, { state, content: current });
|
|
453
|
+
if (validation && validation.error_type === 'file_not_read') {
|
|
454
|
+
validation = {
|
|
455
|
+
...validation,
|
|
456
|
+
error_message: 'existing file must be read before overwriting',
|
|
457
|
+
suggested_action: `Call read_workspace_file(path='${rel}') before overwriting it.`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (validation) return { target, rel, error: validation, previous: '' };
|
|
461
|
+
return { target, rel, error: null, previous: current };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function writeWorkspaceFile(args) {
|
|
465
|
+
const path = String(args.path ?? '');
|
|
466
|
+
const content = String(args.content ?? '');
|
|
467
|
+
const overwrite = args.overwrite !== false;
|
|
468
|
+
const target = resolvePath(path);
|
|
469
|
+
const createOnly = !existsSync(target);
|
|
470
|
+
const { target: t, rel, error, previous } = writePrecondition(path, overwrite);
|
|
471
|
+
if (error) return error;
|
|
472
|
+
try {
|
|
473
|
+
publish(t, Buffer.from(content, 'utf-8'), { createOnly });
|
|
474
|
+
} catch (e) {
|
|
475
|
+
if (e.code === 'EEXIST') {
|
|
476
|
+
return { ok: false, path: rel, error_type: 'file_exists', error_message: 'workspace file was created before publication', suggested_action: 'Read the new file before proposing an overwrite.' };
|
|
477
|
+
}
|
|
478
|
+
return { ok: false, path: rel, error_type: e.code?.startsWith('checkpoint_') ? e.code : 'publish_failed', error_message: e.message };
|
|
479
|
+
}
|
|
480
|
+
const stat = statSync(t, { bigint: true });
|
|
481
|
+
refreshReadState(rel, stat, content, t);
|
|
482
|
+
return { ok: true, path: rel, absolute_path: t, size: Number(stat.size), ...changeCounts(previous, content) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function writeWorkspaceFiles(args) {
|
|
486
|
+
const files = args.files;
|
|
487
|
+
const overwrite = args.overwrite !== false;
|
|
488
|
+
if (!Array.isArray(files) || !files.length) return { ok: false, error_message: 'files must be a non-empty list' };
|
|
489
|
+
const prepared = [];
|
|
490
|
+
for (const item of files.slice(0, 100)) {
|
|
491
|
+
if (!item || typeof item !== 'object') return { ok: false, error_message: 'each file item must be an object' };
|
|
492
|
+
const p = String(item.path ?? '').trim();
|
|
493
|
+
if (!p) return { ok: false, error_message: 'each file item requires path' };
|
|
494
|
+
prepared.push({ path: p, content: String(item.content ?? '') });
|
|
495
|
+
}
|
|
496
|
+
for (const { path } of prepared) {
|
|
497
|
+
const { error } = writePrecondition(path, overwrite);
|
|
498
|
+
if (error) return error;
|
|
499
|
+
}
|
|
500
|
+
const written = [];
|
|
501
|
+
for (const { path, content } of prepared) {
|
|
502
|
+
const result = writeWorkspaceFile({ path, content, overwrite });
|
|
503
|
+
if (!result.ok) return { ...result, written, partial: written.length > 0, failed_path: path };
|
|
504
|
+
written.push({
|
|
505
|
+
path: result.path,
|
|
506
|
+
size: result.size,
|
|
507
|
+
diff_additions: result.diff_additions,
|
|
508
|
+
diff_deletions: result.diff_deletions,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return { ok: true, written, truncated: files.length > written.length };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function editWorkspaceFile(args, { preparedEdit } = {}) {
|
|
515
|
+
const path = String(args.path ?? '');
|
|
516
|
+
const oldText = args.old_text;
|
|
517
|
+
const newText = args.new_text;
|
|
518
|
+
const expectedReplacements = Number(args.expected_replacements ?? 1);
|
|
519
|
+
const replaceAll = Boolean(args.replace_all);
|
|
520
|
+
const target = resolvePath(path);
|
|
521
|
+
const rel = relativeOf(target);
|
|
522
|
+
const requestError = preparedEdit?.emptyOldText ? null : validateSingleEditRequest(oldText, rel);
|
|
523
|
+
if (requestError) return requestError;
|
|
524
|
+
if (preparedEdit?.create) {
|
|
525
|
+
// Preparation observed absence. Recheck under the mutation lock, then use
|
|
526
|
+
// create-only publication to refuse a concurrent external creation too.
|
|
527
|
+
if (!absent(target)) return stalePreparedEdit(rel);
|
|
528
|
+
const createdText = preparedEdit.createdText;
|
|
529
|
+
try { publish(target, Buffer.from(createdText, 'utf-8'), { createOnly: true }); }
|
|
530
|
+
catch (error) { return { ok: false, path: rel, error_type: error.code === 'EEXIST' ? 'file_modified_since_read' : error.code || 'publish_failed',
|
|
531
|
+
error_message: error.message, suggested_action: 'Read the current file and retry.' }; }
|
|
532
|
+
const stat = statSync(target, { bigint: true }); refreshReadState(rel, stat, createdText, target);
|
|
533
|
+
const diff = buildDiff(rel, '', createdText);
|
|
534
|
+
return { ok: true, path: rel, absolute_path: target, replacements: 1, used_quote_normalization: false,
|
|
535
|
+
diff, diff_sha256: sha256(Buffer.from(diff)), diff_line_count: diffLineCount(diff), ...changeCounts('', createdText),
|
|
536
|
+
size: Number(stat.size), mtime_ns: Number(stat.mtimeNs) };
|
|
537
|
+
}
|
|
538
|
+
const observed = preparedEdit?.pinned ?? store.getReadState(rel);
|
|
539
|
+
const pinned = observed ? { ...observed } : null;
|
|
540
|
+
let readError = validateReadState(target, rel, { state: pinned });
|
|
541
|
+
if (readError) return readError;
|
|
542
|
+
const { text, error } = readWorkspaceText(target);
|
|
543
|
+
if (error) return { ...error, path };
|
|
544
|
+
if (text === null) return { ok: false, path, error_message: 'workspace file read failed' };
|
|
545
|
+
readError = validateReadState(target, rel, { state: pinned, content: text });
|
|
546
|
+
if (readError) return readError;
|
|
547
|
+
if (preparedEdit && text !== preparedEdit.text) return stalePreparedEdit(rel);
|
|
548
|
+
const editResult = preparedEdit ? preparedEdit.results[0] : applySingleEdit(text, oldText, newText, { expectedReplacements, replaceAll, relative: rel });
|
|
549
|
+
if (!editResult.ok) return editResult;
|
|
550
|
+
const updated = String(editResult.updated);
|
|
551
|
+
const diff = buildDiff(rel, text, updated);
|
|
552
|
+
const { stat: newStat, error: commitError } = commitEdit(target, rel, updated, pinned);
|
|
553
|
+
if (commitError) return commitError;
|
|
554
|
+
return {
|
|
555
|
+
ok: true,
|
|
556
|
+
path: rel,
|
|
557
|
+
absolute_path: target,
|
|
558
|
+
replacements: editResult.replacements,
|
|
559
|
+
used_quote_normalization: editResult.used_quote_normalization,
|
|
560
|
+
...(editResult.match_strategy ? { match_strategy: editResult.match_strategy, match_candidate_count: editResult.match_candidate_count } : {}),
|
|
561
|
+
diff,
|
|
562
|
+
diff_sha256: sha256(Buffer.from(diff, 'utf-8')),
|
|
563
|
+
diff_line_count: diffLineCount(diff),
|
|
564
|
+
...changeCounts(text, updated),
|
|
565
|
+
size: Number(newStat.size),
|
|
566
|
+
mtime_ns: Number(newStat.mtimeNs),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function multiEditWorkspaceFile(args, { preparedEdit } = {}) {
|
|
571
|
+
const path = String(args.path ?? '');
|
|
572
|
+
const edits = args.edits;
|
|
573
|
+
const replaceAll = Boolean(args.replace_all);
|
|
574
|
+
const target = resolvePath(path);
|
|
575
|
+
const rel = relativeOf(target);
|
|
576
|
+
if (!Array.isArray(edits) || !edits.length) {
|
|
577
|
+
return { ok: false, path: rel, error_type: 'invalid_edits', error_message: 'edits must be a non-empty list' };
|
|
578
|
+
}
|
|
579
|
+
for (let index = 0; index < edits.length; index++) {
|
|
580
|
+
const edit = edits[index];
|
|
581
|
+
if (!edit || typeof edit !== 'object') {
|
|
582
|
+
return { ok: false, path: rel, error_type: 'invalid_edit', error_message: `edit at index ${index} must be an object`, failed_edit_index: index };
|
|
583
|
+
}
|
|
584
|
+
const reqErr = validateSingleEditRequest(edit.old_text, rel);
|
|
585
|
+
if (reqErr) return { ...reqErr, failed_edit_index: index, edit_count: edits.length };
|
|
586
|
+
}
|
|
587
|
+
const observed = preparedEdit?.pinned ?? store.getReadState(rel);
|
|
588
|
+
const pinned = observed ? { ...observed } : null;
|
|
589
|
+
let readError = validateReadState(target, rel, { state: pinned });
|
|
590
|
+
if (readError) return readError;
|
|
591
|
+
const { text, error } = readWorkspaceText(target);
|
|
592
|
+
if (error) return { ...error, path };
|
|
593
|
+
const original = text ?? '';
|
|
594
|
+
if (preparedEdit && original !== preparedEdit.text) return stalePreparedEdit(rel);
|
|
595
|
+
readError = validateReadState(target, rel, { state: pinned, content: original });
|
|
596
|
+
if (readError) return readError;
|
|
597
|
+
let updated = original;
|
|
598
|
+
const diagnostics = [];
|
|
599
|
+
let totalReplacements = 0;
|
|
600
|
+
for (let index = 0; index < edits.length; index++) {
|
|
601
|
+
const edit = edits[index];
|
|
602
|
+
const r = preparedEdit ? preparedEdit.results[index] : applySingleEdit(updated, edit.old_text, edit.new_text, {
|
|
603
|
+
expectedReplacements: Number(edit.expected_replacements ?? 1),
|
|
604
|
+
replaceAll: Boolean(edit.replace_all ?? replaceAll),
|
|
605
|
+
relative: rel,
|
|
606
|
+
});
|
|
607
|
+
if (!r.ok) return { ...r, failed_edit_index: index, edit_count: edits.length };
|
|
608
|
+
updated = String(r.updated);
|
|
609
|
+
totalReplacements += Number(r.replacements);
|
|
610
|
+
diagnostics.push({ index, replacements: r.replacements, used_quote_normalization: r.used_quote_normalization, matches: r.matches || [],
|
|
611
|
+
...(r.match_strategy ? { match_strategy: r.match_strategy, match_candidate_count: r.match_candidate_count } : {}) });
|
|
612
|
+
}
|
|
613
|
+
const diff = buildDiff(rel, original, updated);
|
|
614
|
+
const { stat: newStat, error: commitError } = commitEdit(target, rel, updated, pinned);
|
|
615
|
+
if (commitError) return commitError;
|
|
616
|
+
return {
|
|
617
|
+
ok: true,
|
|
618
|
+
path: rel,
|
|
619
|
+
absolute_path: target,
|
|
620
|
+
edit_count: edits.length,
|
|
621
|
+
replacements: totalReplacements,
|
|
622
|
+
edits: diagnostics,
|
|
623
|
+
diff,
|
|
624
|
+
diff_sha256: sha256(Buffer.from(diff, 'utf-8')),
|
|
625
|
+
diff_line_count: diffLineCount(diff),
|
|
626
|
+
...changeCounts(original, updated),
|
|
627
|
+
size: Number(newStat.size),
|
|
628
|
+
mtime_ns: Number(newStat.mtimeNs),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function stalePreparedEdit(relative) {
|
|
633
|
+
return { ok: false, path: relative, error_type: 'file_modified_since_read',
|
|
634
|
+
error_message: 'workspace file changed while preparing the edit', suggested_action: 'Read the current file and retry.' };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function emptyEditContent(text, replacement) {
|
|
638
|
+
let crlf = 0, total = 0;
|
|
639
|
+
for (let index = 0; index < text.length; index++) if (text[index] === '\n') {
|
|
640
|
+
total++; if (text[index - 1] === '\r') crlf++;
|
|
641
|
+
}
|
|
642
|
+
// Use the dominant EOL, with LF on ties
|
|
643
|
+
// and new files. Keep v1/nonempty editing semantics unchanged.
|
|
644
|
+
const ending = crlf > total - crlf ? '\r\n' : '\n';
|
|
645
|
+
return replacement.replace(/\r\n/g, '\n').replace(/\n/g, ending);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function prepareEdit(request, guard) {
|
|
649
|
+
const args = request.call.arguments, target = resolvePath(args.path), relative = relativeOf(target);
|
|
650
|
+
const edits = request.call.name === 'edit_workspace_file' ? [args] : args.edits;
|
|
651
|
+
if (!Array.isArray(edits) || !edits.length || edits.some(edit => !edit || typeof edit.old_text !== 'string' || typeof edit.new_text !== 'string'))
|
|
652
|
+
return { error: { ok: false, path: relative, error_type: 'invalid_edits', error_message: 'Non-empty edits with old_text and new_text strings are required.' } };
|
|
653
|
+
const emptyOldText = request.definition.workspaceEditPolicy === 'zcode-match/v2'
|
|
654
|
+
&& request.call.name === 'edit_workspace_file' && args.old_text === '';
|
|
655
|
+
if (emptyOldText) {
|
|
656
|
+
if (args.new_text === '') return { error: { ok: false, path: relative, error_type: 'no_change', error_message: 'old_text and new_text are identical' } };
|
|
657
|
+
if (!args.replace_all && Number(args.expected_replacements ?? 1) !== 1) return { error: { ok: false, path: relative,
|
|
658
|
+
error_type: 'ambiguous_edit', error_message: 'Empty old_text replaces one file, but expected_replacements is not 1.',
|
|
659
|
+
occurrences: 1, expected_replacements: args.expected_replacements } };
|
|
660
|
+
if (absent(target)) { guard(); return { create: true, emptyOldText: true, createdText: emptyEditContent('', args.new_text) }; }
|
|
661
|
+
}
|
|
662
|
+
const creationRead = emptyOldText ? readWorkspaceText(target) : null;
|
|
663
|
+
if (creationRead?.error) return { error: { ...creationRead.error, path: relative } };
|
|
664
|
+
if (creationRead && creationRead.text.trim() !== '') return { error: { ok: false, path: relative, error_type: 'file_exists_no_old_text',
|
|
665
|
+
error_message: 'Cannot create a new file: the existing file contains nonblank text.' } };
|
|
666
|
+
const state = store.getReadState(relative), pinned = state ? { ...state } : null;
|
|
667
|
+
if (emptyOldText && pinned?.partial) return { error: { ok: false, path: relative, error_type: 'file_not_fully_read',
|
|
668
|
+
error_message: 'An existing whitespace-only file must be read completely before replacing it.' } };
|
|
669
|
+
let error = validateReadState(target, relative, { state: pinned });
|
|
670
|
+
if (error) return { error };
|
|
671
|
+
const read = creationRead ?? readWorkspaceText(target);
|
|
672
|
+
if (read.error) return { error: { ...read.error, path: relative } };
|
|
673
|
+
error = validateReadState(target, relative, { state: pinned, content: read.text });
|
|
674
|
+
if (error) return { error };
|
|
675
|
+
if (emptyOldText) {
|
|
676
|
+
// Existing empty files still require the same full, fresh read authority.
|
|
677
|
+
const updated = emptyEditContent(read.text, args.new_text);
|
|
678
|
+
guard(); return { text: read.text, pinned, emptyOldText: true,
|
|
679
|
+
results: [{ ok: true, updated, replacements: 1, used_quote_normalization: false }] };
|
|
680
|
+
}
|
|
681
|
+
const results = await prepareWorkspaceEdits({ text: read.text, relative, edits, replaceAll: Boolean(args.replace_all) }, { signal: request.signal });
|
|
682
|
+
guard();
|
|
683
|
+
if (!results) return { error: { ok: false, path: relative, error_type: 'edit_match_budget_exceeded', executed: false,
|
|
684
|
+
error_message: 'Edit matching exceeded its local time or memory budget. No file was changed. Use a smaller unique exact snippet.' } };
|
|
685
|
+
return { text: read.text, pinned, results };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function patchFailure(code, message) { return { ok: false, error_type: code, error_message: message, executed: false }; }
|
|
689
|
+
function absent(target) {
|
|
690
|
+
try { lstatSync(target); return false; } catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
691
|
+
}
|
|
692
|
+
async function preparePatch(request, guard) {
|
|
693
|
+
const patch = request.call.arguments?.patch_text;
|
|
694
|
+
if (typeof patch !== 'string' || patch.length > 262144) return { error: patchFailure('apply_patch_invalid_patch', 'patch_text must be a string of at most 262144 characters') };
|
|
695
|
+
let sections;
|
|
696
|
+
try { sections = parseStructuredPatch(patch); }
|
|
697
|
+
catch (error) { return { error: patchFailure('apply_patch_invalid_patch', error.message) }; }
|
|
698
|
+
if (!sections.length) return { error: patchFailure('apply_patch_empty_patch', 'Patch has no file changes') };
|
|
699
|
+
if (sections.length > 64) return { error: patchFailure('patch_budget_exceeded', 'At most 64 file sections are allowed') };
|
|
700
|
+
const changes = [], paths = new Set(); let bytes = 0;
|
|
701
|
+
for (const section of sections) {
|
|
702
|
+
const target = resolvePath(section.path), rel = relativeOf(target);
|
|
703
|
+
const moveTarget = section.movePath === undefined ? undefined : resolvePath(section.movePath);
|
|
704
|
+
for (const path of [target, ...(moveTarget ? [moveTarget] : [])]) {
|
|
705
|
+
if (paths.has(path) || [...paths].some(other => path.startsWith(other + '/') || other.startsWith(path + '/')))
|
|
706
|
+
return { error: patchFailure('patch_path_conflict', 'A file path is repeated, aliases another path or overlaps another file in the patch') };
|
|
707
|
+
paths.add(path);
|
|
708
|
+
}
|
|
709
|
+
if ((section.type === 'add' && !absent(target)) || (moveTarget && !absent(moveTarget)))
|
|
710
|
+
return { error: patchFailure('apply_patch_file_exists', 'An add or move destination already exists') };
|
|
711
|
+
let text = '', pinned, mode;
|
|
712
|
+
if (section.type !== 'add') {
|
|
713
|
+
const read = readWorkspaceText(target);
|
|
714
|
+
if (read.error) return { error: patchFailure('apply_patch_file_not_exist', `${rel}: ${read.error.error_message}`) };
|
|
715
|
+
text = read.text; bytes += Buffer.byteLength(text);
|
|
716
|
+
if (bytes > 16 * 1024 * 1024) return { error: patchFailure('patch_budget_exceeded', 'Patch source exceeds 16 MiB') };
|
|
717
|
+
const stat = statSync(target, { bigint: true }), lines = splitlines(text).length;
|
|
718
|
+
mode = Number(stat.mode) & 0o777;
|
|
719
|
+
pinned = makeReadState({ path: rel, canonicalPath: target, contentSha256: sha256(Buffer.from(text)), mtimeNs: stat.mtimeNs,
|
|
720
|
+
sizeBytes: Number(stat.size), startLine: 1, endLine: lines, totalLines: lines, partial: false });
|
|
721
|
+
}
|
|
722
|
+
if (section.type === 'update' && !section.chunks.length)
|
|
723
|
+
return { error: patchFailure('apply_patch_invalid_patch', 'Update file section has no hunks') };
|
|
724
|
+
changes.push({ section, target, rel, moveTarget, text, pinned, mode });
|
|
725
|
+
}
|
|
726
|
+
const result = await prepareWorkspacePatch(changes.map(({ section, text }) => ({ section, text })), { signal: request.signal });
|
|
727
|
+
guard();
|
|
728
|
+
if (!result) return { error: patchFailure('patch_budget_exceeded', 'Patch verification exceeded its local time or memory budget. No files changed.') };
|
|
729
|
+
if (result.error) return { error: patchFailure(result.code, result.error) };
|
|
730
|
+
for (let index = 0; index < changes.length; index++) {
|
|
731
|
+
const change = changes[index];
|
|
732
|
+
// The reference operates on logical LF text and writes using each file's
|
|
733
|
+
// original encoding/EOL. Native currently supports UTF-8 and LF/CRLF.
|
|
734
|
+
change.updated = change.text.includes('\r\n') ? result.changes[index].replaceAll('\n', '\r\n') : result.changes[index];
|
|
735
|
+
}
|
|
736
|
+
return { changes };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function verifyPatchChange(change) {
|
|
740
|
+
if (resolvePath(change.section.path) !== change.target || change.moveTarget && resolvePath(change.section.movePath) !== change.moveTarget)
|
|
741
|
+
return patchFailure('patch_path_changed', 'A patch path changed during verification');
|
|
742
|
+
if (change.section.type === 'add') {
|
|
743
|
+
if (!absent(change.target)) return patchFailure('apply_patch_file_exists', 'An add destination was created during verification');
|
|
744
|
+
} else {
|
|
745
|
+
const read = readWorkspaceText(change.target);
|
|
746
|
+
if (read.error) return { ...read.error, path: change.rel };
|
|
747
|
+
const stale = validateReadState(change.target, change.rel, { state: change.pinned, content: read.text });
|
|
748
|
+
if (stale) return stale;
|
|
749
|
+
}
|
|
750
|
+
if (change.moveTarget && !absent(change.moveTarget)) return patchFailure('apply_patch_file_exists', 'A move destination was created during verification');
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function applyWorkspacePatch(_args, { preparedPatch }) {
|
|
755
|
+
if (!preparedPatch) return patchFailure('apply_patch_not_prepared', 'Patch must be verified before publication');
|
|
756
|
+
// Validate the complete captured preimage again under the publication lock.
|
|
757
|
+
for (const change of preparedPatch.changes) { const error = verifyPatchChange(change); if (error) return error; }
|
|
758
|
+
const files = [];
|
|
759
|
+
for (const change of preparedPatch.changes) {
|
|
760
|
+
activeGuard?.();
|
|
761
|
+
const stale = verifyPatchChange(change);
|
|
762
|
+
if (stale) {
|
|
763
|
+
if (activeMutations.size) throw Object.assign(Error(stale.error_message), { code: 'patch_publication_incomplete' });
|
|
764
|
+
return stale;
|
|
765
|
+
}
|
|
766
|
+
const type = change.moveTarget ? 'move' : change.section.type;
|
|
767
|
+
if (type === 'delete') { remove(change.target); }
|
|
768
|
+
else {
|
|
769
|
+
const target = change.moveTarget ?? change.target;
|
|
770
|
+
publish(target, Buffer.from(change.updated), { createOnly: type === 'add' || type === 'move',
|
|
771
|
+
...(type === 'move' ? { checkpointMode: change.mode } : {}) });
|
|
772
|
+
if (type === 'move') { activeGuard?.(); assertCheckpointPath(target); chmodSync(target, change.mode); remove(change.target); }
|
|
773
|
+
refreshReadState(relativeOf(target), statSync(target, { bigint: true }), change.updated, target);
|
|
774
|
+
}
|
|
775
|
+
const path = change.moveTarget ? relativeOf(change.moveTarget) : change.rel;
|
|
776
|
+
const diff = buildDiff(path, change.text, change.updated);
|
|
777
|
+
files.push({ path: change.rel, type, ...(change.moveTarget ? { move_path: path } : {}), diff,
|
|
778
|
+
diff_sha256: sha256(Buffer.from(diff)), ...changeCounts(change.text, change.updated) });
|
|
779
|
+
}
|
|
780
|
+
return { ok: true, files, changed_paths: [...activeMutations], summary: files.map(file => `${file.type}: ${file.path}${file.move_path ? ` -> ${file.move_path}` : ''}`).join('\n') };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function commitEdit(target, rel, updated, pinned) {
|
|
784
|
+
const { text: current, error } = readWorkspaceText(target);
|
|
785
|
+
if (error) return { stat: null, error: { ...error, path: rel } };
|
|
786
|
+
if (current === null) return { stat: null, error: { ok: false, path: rel, error_message: 'workspace file read failed' } };
|
|
787
|
+
const err = validateReadState(target, rel, { state: pinned, content: current });
|
|
788
|
+
if (err) return { stat: null, error: err };
|
|
789
|
+
try {
|
|
790
|
+
publish(target, Buffer.from(updated, 'utf-8'));
|
|
791
|
+
} catch (e) {
|
|
792
|
+
return { stat: null, error: { ok: false, path: rel, error_type: e.code?.startsWith('checkpoint_') ? e.code : 'publish_failed', error_message: e.message } };
|
|
793
|
+
}
|
|
794
|
+
const stat = statSync(target, { bigint: true });
|
|
795
|
+
refreshReadState(rel, stat, updated, target);
|
|
796
|
+
return { stat, error: null };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function refreshReadState(rel, stat, text, target) {
|
|
800
|
+
const lines = splitlines(text);
|
|
801
|
+
rememberRead(makeReadState({
|
|
802
|
+
path: rel,
|
|
803
|
+
mtimeNs: stat.mtimeNs,
|
|
804
|
+
sizeBytes: Number(stat.size),
|
|
805
|
+
startLine: 1,
|
|
806
|
+
endLine: lines.length,
|
|
807
|
+
totalLines: lines.length,
|
|
808
|
+
partial: false,
|
|
809
|
+
canonicalPath: target,
|
|
810
|
+
contentSha256: sha256(Buffer.from(text, 'utf-8')),
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function lintWorkspaceFile(args, { signal }) {
|
|
815
|
+
const path = String(args.path ?? '');
|
|
816
|
+
const language = args.language ?? 'auto';
|
|
817
|
+
const target = resolvePath(path);
|
|
818
|
+
const rel = relativeOf(target);
|
|
819
|
+
const { text, error } = readWorkspaceText(target);
|
|
820
|
+
if (error) return { ...error, path };
|
|
821
|
+
const lang = detectLanguage(target, language);
|
|
822
|
+
const issues = [];
|
|
823
|
+
if (lang === 'python') {
|
|
824
|
+
// Capture bytes under the same workspace checks as all text tools. The
|
|
825
|
+
// parser receives no filesystem or host execution authority.
|
|
826
|
+
return checkPythonSyntax(text || '', { signal }).then(issues => ({
|
|
827
|
+
ok: issues.length === 0, path: rel, language: lang, issues,
|
|
828
|
+
lint_model: 'built_in_lightweight', syntax_engine: { ...NATIVE_PYTHON_SYNTAX },
|
|
829
|
+
}));
|
|
830
|
+
} else if (lang === 'json') {
|
|
831
|
+
try {
|
|
832
|
+
JSON.parse(text ?? '');
|
|
833
|
+
} catch (e) {
|
|
834
|
+
issues.push({ severity: 'error', ...jsonErrorPosition(e.message, text), message: e.message });
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
genericTextChecks(text, issues);
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
ok: !issues.some((i) => i.severity === 'error'),
|
|
841
|
+
path: rel,
|
|
842
|
+
language: lang,
|
|
843
|
+
issues,
|
|
844
|
+
lint_model: 'built_in_lightweight',
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function genericTextChecks(text, issues) {
|
|
849
|
+
const lines = splitlines(text || '');
|
|
850
|
+
for (let i = 0; i < lines.length; i++) {
|
|
851
|
+
const line = lines[i];
|
|
852
|
+
const nul = line.indexOf('\x00');
|
|
853
|
+
if (nul >= 0) issues.push({ severity: 'error', line: i + 1, column: nul + 1, message: 'NUL byte found' });
|
|
854
|
+
if (line !== line.replace(/\s+$/, '')) {
|
|
855
|
+
issues.push({ severity: 'warning', line: i + 1, column: line.length, message: 'trailing whitespace' });
|
|
856
|
+
if (issues.length >= 20) break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function jsonErrorPosition(message, text) {
|
|
862
|
+
const match = /position (\d+)/.exec(message || '');
|
|
863
|
+
if (!match) return { line: 0, column: 0 };
|
|
864
|
+
const pos = Number(match[1]);
|
|
865
|
+
const upto = (text || '').slice(0, pos);
|
|
866
|
+
const parts = upto.split('\n');
|
|
867
|
+
return { line: parts.length, column: parts[parts.length - 1].length + 1 };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function detectLanguage(target, language) {
|
|
871
|
+
const value = String(language || 'auto').trim().toLowerCase();
|
|
872
|
+
if (value && value !== 'auto') {
|
|
873
|
+
const aliases = { py: 'python', python3: 'python', js: 'javascript' };
|
|
874
|
+
return aliases[value] || value;
|
|
875
|
+
}
|
|
876
|
+
const ext = (target.match(/\.([^.]+)$/) || [])[1]?.toLowerCase();
|
|
877
|
+
if (ext === 'py') return 'python';
|
|
878
|
+
if (ext === 'json') return 'json';
|
|
879
|
+
if (ext === 'md' || ext === 'markdown') return 'markdown';
|
|
880
|
+
if (ext === 'txt' || ext === 'log') return 'text';
|
|
881
|
+
return 'text';
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function searchWorkspaceFiles(args, ctx) {
|
|
885
|
+
const query = String(args.query ?? '');
|
|
886
|
+
const path = String(args.path ?? '.');
|
|
887
|
+
const isRegex = Boolean(args.is_regex);
|
|
888
|
+
const glob = args.glob ?? null;
|
|
889
|
+
const caseSensitive = Boolean(args.case_sensitive);
|
|
890
|
+
const contextLines = Number(args.context_lines ?? 0);
|
|
891
|
+
const maxResults = Number(args.max_results ?? 100);
|
|
892
|
+
const needle = query.trim();
|
|
893
|
+
if (!needle) return { ok: false, error_message: 'query is required' };
|
|
894
|
+
const base = resolvePath(path);
|
|
895
|
+
if (!existsSync(base)) return { ok: false, error_code: 'workspace_path_not_found', error_message: `workspace path not found: ${path}` };
|
|
896
|
+
const candidates = [];
|
|
897
|
+
if (lstatSync(base, { bigint: true }).isFile()) {
|
|
898
|
+
candidates.push(base);
|
|
899
|
+
} else {
|
|
900
|
+
// Real files beneath the base subtree; symlinked directories are not
|
|
901
|
+
// followed so the search cannot escape the workspace root.
|
|
902
|
+
const stack = [base];
|
|
903
|
+
while (stack.length) {
|
|
904
|
+
const cur = stack.pop();
|
|
905
|
+
let dirents;
|
|
906
|
+
try { dirents = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
907
|
+
for (const d of dirents) {
|
|
908
|
+
const abs = join(cur, d.name);
|
|
909
|
+
if (d.isFile()) candidates.push(abs);
|
|
910
|
+
else if (d.isDirectory() && !d.isSymbolicLink()) stack.push(abs);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
let filtered = candidates;
|
|
915
|
+
if (glob) {
|
|
916
|
+
filtered = candidates.filter((c) => {
|
|
917
|
+
const rel = relativeOf(c);
|
|
918
|
+
return fnmatch(rel, glob) || fnmatch(c.split('/').pop(), glob);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
let pattern;
|
|
922
|
+
try {
|
|
923
|
+
pattern = new RegExp(isRegex ? needle : escapeRegex(needle), caseSensitive ? '' : 'i');
|
|
924
|
+
} catch (e) {
|
|
925
|
+
return { ok: false, error_code: 'invalid_search_pattern', error_message: e.message };
|
|
926
|
+
}
|
|
927
|
+
const limit = Math.max(1, Math.min(maxResults || 100, 5000));
|
|
928
|
+
const contextLimit = Math.max(0, Math.min(contextLines || 0, 20));
|
|
929
|
+
const results = [];
|
|
930
|
+
let truncated = false;
|
|
931
|
+
outer:
|
|
932
|
+
for (const item of filtered) {
|
|
933
|
+
if (ctx?.signal?.aborted) return { ok: false, error_code: 'canceled', error_message: 'workspace search canceled' };
|
|
934
|
+
let size;
|
|
935
|
+
try { size = Number(lstatSync(item, { bigint: true }).size); } catch { continue; }
|
|
936
|
+
if (size > 1024 * 1024) continue;
|
|
937
|
+
let payload;
|
|
938
|
+
try { payload = readFileSync(item); } catch { continue; }
|
|
939
|
+
let text;
|
|
940
|
+
try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(payload); }
|
|
941
|
+
catch { continue; }
|
|
942
|
+
const lines = splitlines(text);
|
|
943
|
+
for (let i = 0; i < lines.length; i++) {
|
|
944
|
+
const line = lines[i];
|
|
945
|
+
if (!pattern.test(line)) continue;
|
|
946
|
+
if (results.length >= limit) { truncated = true; break outer; }
|
|
947
|
+
results.push({
|
|
948
|
+
path: relativeOf(item),
|
|
949
|
+
line: i + 1,
|
|
950
|
+
text: line,
|
|
951
|
+
snippet: line,
|
|
952
|
+
context_before: lines.slice(Math.max(0, i - contextLimit), i),
|
|
953
|
+
context_after: lines.slice(i + 1, i + 1 + contextLimit),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
ok: true,
|
|
959
|
+
query,
|
|
960
|
+
searched_path: path,
|
|
961
|
+
results,
|
|
962
|
+
match_count: results.length,
|
|
963
|
+
truncated,
|
|
964
|
+
context_lines: contextLimit,
|
|
965
|
+
search_backend: 'node',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function deleteWorkspaceFile(args) {
|
|
970
|
+
const path = String(args.path ?? '');
|
|
971
|
+
const target = resolvePath(path);
|
|
972
|
+
const real = pinnedRootReal;
|
|
973
|
+
if (target === real) return { ok: false, error_message: 'refuse to delete workspace root' };
|
|
974
|
+
if (!existsSync(target)) return { ok: false, error_message: `workspace path not found: ${path}` };
|
|
975
|
+
const info = lstatSync(target, { bigint: true });
|
|
976
|
+
try {
|
|
977
|
+
activeGuard?.();
|
|
978
|
+
if (info.isDirectory()) {
|
|
979
|
+
remove(target, true);
|
|
980
|
+
} else {
|
|
981
|
+
remove(target);
|
|
982
|
+
}
|
|
983
|
+
activeMutations?.add(relativeOf(target));
|
|
984
|
+
} catch (e) {
|
|
985
|
+
return { ok: false, error_message: `workspace delete failed: ${e.message}` };
|
|
986
|
+
}
|
|
987
|
+
return { ok: true, deleted: relativeOf(target) };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---- definitions ----------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
const definitions = workspaceToolDefinitions();
|
|
993
|
+
if (enablePatches) definitions.push(workspacePatchDefinition());
|
|
994
|
+
if (enableRewind) {
|
|
995
|
+
for (const definition of definitions) if (MUTATING.has(definition.name)) {
|
|
996
|
+
definition.workspaceCheckpointPolicy = WORKSPACE_CHECKPOINT_POLICY;
|
|
997
|
+
definition.recoveryPolicy = 'receipt-observation/v1';
|
|
998
|
+
definition.workspaceRepairPolicy = 'preimage-compensation/v1';
|
|
999
|
+
}
|
|
1000
|
+
definitions.push(...workspaceRewindDefinitions());
|
|
1001
|
+
definitions.find(tool => tool.name === 'rewind_workspace_files').workspaceRepairPolicy = 'preimage-compensation/v1';
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const impls = {
|
|
1005
|
+
workspace_status: workspaceStatus,
|
|
1006
|
+
list_workspace_files: listWorkspaceFiles,
|
|
1007
|
+
read_workspace_file: readWorkspaceFile,
|
|
1008
|
+
write_workspace_file: writeWorkspaceFile,
|
|
1009
|
+
write_workspace_files: writeWorkspaceFiles,
|
|
1010
|
+
edit_workspace_file: editWorkspaceFile,
|
|
1011
|
+
multi_edit_workspace_file: multiEditWorkspaceFile,
|
|
1012
|
+
lint_workspace_file: lintWorkspaceFile,
|
|
1013
|
+
search_workspace_files: searchWorkspaceFiles,
|
|
1014
|
+
delete_workspace_file: deleteWorkspaceFile,
|
|
1015
|
+
...(enablePatches ? { apply_workspace_patch: applyWorkspacePatch } : {}),
|
|
1016
|
+
...(enableRewind ? {
|
|
1017
|
+
list_workspace_checkpoints: () => ({ ok: true, checkpoints: checkpoints.list() }),
|
|
1018
|
+
preview_workspace_rewind: (args, { checkpointCommitted }) => checkpoints.preview(args.checkpoint_id, checkpointCommitted).content,
|
|
1019
|
+
rewind_workspace_files: (_args, { preparedRewind }) => {
|
|
1020
|
+
for (const file of preparedRewind.files) {
|
|
1021
|
+
activeGuard?.(); const target = resolvePath(file.path);
|
|
1022
|
+
if (target !== file.canonicalPath) throw Object.assign(Error('checkpoint_path_changed'), { code: 'checkpoint_path_changed' });
|
|
1023
|
+
if (!checkpointStatesEqual(captureCheckpointFile(target), file.current)) throw Object.assign(Error('checkpoint_external_modified'), { code: 'checkpoint_external_modified' });
|
|
1024
|
+
if (checkpointStatesEqual(file.current, file.desired)) continue;
|
|
1025
|
+
if (file.desired.kind === 'absent') {
|
|
1026
|
+
if (existsSync(target)) remove(target, lstatSync(target).isDirectory());
|
|
1027
|
+
} else if (file.desired.kind === 'directory') {
|
|
1028
|
+
if (file.current.kind === 'file') remove(target);
|
|
1029
|
+
activeCheckpoint?.stage(file.path, file.desired); activeGuard?.(); assertCheckpointPath(target); mkdirSync(target, { recursive: true, mode: file.desired.mode });
|
|
1030
|
+
activeMutations.add(file.path); activePublications?.set(file.path, { kind: 'directory', mode: file.desired.mode & ~process.umask() });
|
|
1031
|
+
activeGuard?.(); assertCheckpointPath(target); chmodSync(target, file.desired.mode); activePublications?.set(file.path, file.desired);
|
|
1032
|
+
} else {
|
|
1033
|
+
if (file.current.kind === 'directory') remove(target, true);
|
|
1034
|
+
const bytes = Buffer.from(file.desired.bytes, 'base64'); publish(target, bytes, { checkpointMode: file.desired.mode });
|
|
1035
|
+
activeGuard?.(); assertCheckpointPath(target); chmodSync(target, file.desired.mode); activePublications?.set(file.path, file.desired);
|
|
1036
|
+
try {
|
|
1037
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1038
|
+
refreshReadState(file.path, statSync(target, { bigint: true }), text, target);
|
|
1039
|
+
} catch (error) { if (!(error instanceof TypeError)) throw error; }
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return { ok: true, rewound_checkpoint_ids: preparedRewind.ids, changed_paths: [...activeMutations] };
|
|
1043
|
+
},
|
|
1044
|
+
} : {}),
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// ---- execute --------------------------------------------------------------
|
|
1048
|
+
|
|
1049
|
+
async function execute(request, { assertCurrent, checkpointCommitted } = {}) {
|
|
1050
|
+
if (!request || typeof request !== 'object') {
|
|
1051
|
+
return { status: 'error', content: { code: 'invalid_request', message: 'request object required' } };
|
|
1052
|
+
}
|
|
1053
|
+
if (request.sessionId !== store.sessionId) return { status: 'error', content: { code: 'workspace_session_mismatch' } };
|
|
1054
|
+
const call = request.call;
|
|
1055
|
+
if (!call || !call.name) {
|
|
1056
|
+
return { status: 'error', content: { code: 'invalid_request', message: 'request.call.name required' } };
|
|
1057
|
+
}
|
|
1058
|
+
const name = call.name;
|
|
1059
|
+
const impl = impls[name];
|
|
1060
|
+
if (!impl) return { status: 'error', content: { code: 'unknown_tool', message: `unknown tool: ${name}` } };
|
|
1061
|
+
const signal = request.signal;
|
|
1062
|
+
const mutating = MUTATING.has(name);
|
|
1063
|
+
let guard;
|
|
1064
|
+
try { guard = workspaceMutationGuard(request, mutating ? assertCurrent : undefined, checkRootBoundary); }
|
|
1065
|
+
catch (error) { return { status: 'error', content: { code: error.code || 'invalid_request' } }; }
|
|
1066
|
+
|
|
1067
|
+
// The root boundary is pinned at factory creation. Re-check it on every
|
|
1068
|
+
// execute: a symlink swap (or parent rewrite) that changes the live
|
|
1069
|
+
// realpath is refused before authorization, so a swapped root can never
|
|
1070
|
+
// authorize a write even if the caller later approves.
|
|
1071
|
+
try { checkRootBoundary(); }
|
|
1072
|
+
catch (e) {
|
|
1073
|
+
if (e instanceof RootBoundaryError) {
|
|
1074
|
+
return { status: 'error', content: { code: e.code, message: e.message } };
|
|
1075
|
+
}
|
|
1076
|
+
throw e;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (mutating) {
|
|
1080
|
+
if (signal?.aborted) return canceled();
|
|
1081
|
+
if (request.interaction != null) {
|
|
1082
|
+
const reply = handleApprovalReply(request);
|
|
1083
|
+
if (reply.error) return { status: 'error', content: reply.error };
|
|
1084
|
+
} else {
|
|
1085
|
+
let decision;
|
|
1086
|
+
try { decision = await authorizeFn(request); } catch (e) {
|
|
1087
|
+
return { status: 'error', content: { code: 'authorize_failed', message: e.message } };
|
|
1088
|
+
}
|
|
1089
|
+
// Re-check the boundary after the authorization await: the root could
|
|
1090
|
+
// have been swapped while the caller was deciding approval.
|
|
1091
|
+
try { checkRootBoundary(); }
|
|
1092
|
+
catch (e) {
|
|
1093
|
+
if (e instanceof RootBoundaryError) {
|
|
1094
|
+
return { status: 'error', content: { code: e.code, message: e.message } };
|
|
1095
|
+
}
|
|
1096
|
+
throw e;
|
|
1097
|
+
}
|
|
1098
|
+
if (decision === 'deny') return { status: 'error', content: { code: 'policy_denied' } };
|
|
1099
|
+
if (decision === 'approval_required') {
|
|
1100
|
+
const pending = store.addPending({
|
|
1101
|
+
callId: call.callId, name, argumentsDigest: argumentsDigest(call.arguments),
|
|
1102
|
+
});
|
|
1103
|
+
return {
|
|
1104
|
+
status: 'approval_required',
|
|
1105
|
+
interactionId: pending.interactionId,
|
|
1106
|
+
content: {
|
|
1107
|
+
code: 'approval_required',
|
|
1108
|
+
interactionId: pending.interactionId,
|
|
1109
|
+
call: { name, callId: call.callId },
|
|
1110
|
+
operation_key: request.operationKey,
|
|
1111
|
+
message: `approval required for ${name}`,
|
|
1112
|
+
},
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
if (decision !== 'allow') {
|
|
1116
|
+
return { status: 'error', content: { code: 'authorize_unexpected', message: `authorize returned ${String(decision)}` } };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (signal?.aborted) return canceled();
|
|
1122
|
+
|
|
1123
|
+
let result, preparedEdit, preparedPatch;
|
|
1124
|
+
if (name === 'apply_workspace_patch') {
|
|
1125
|
+
if (request.definition?.workspacePatchPolicy !== 'zcode-patch/v1') return { status: 'error', content: { code: 'invalid_workspace_patch_policy' } };
|
|
1126
|
+
try {
|
|
1127
|
+
preparedPatch = await preparePatch(request, guard);
|
|
1128
|
+
if (preparedPatch.error) return { status: 'error', content: preparedPatch.error };
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
if (signal?.aborted) return canceled();
|
|
1131
|
+
return { status: 'error', content: { ok: false, error_type: error.code || 'apply_patch_verification_failed', error_message: error.message, executed: false } };
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Matching runs before the publication lock, with strings only. The locked
|
|
1135
|
+
// commit rechecks the captured read state and content after this await.
|
|
1136
|
+
if (request.definition?.workspaceEditPolicy !== undefined) {
|
|
1137
|
+
if (!['zcode-match/v1', 'zcode-match/v2'].includes(request.definition.workspaceEditPolicy) || !['edit_workspace_file', 'multi_edit_workspace_file'].includes(name))
|
|
1138
|
+
return { status: 'error', content: { code: 'invalid_workspace_edit_policy' } };
|
|
1139
|
+
try {
|
|
1140
|
+
preparedEdit = await prepareEdit(request, guard);
|
|
1141
|
+
if (preparedEdit.error) return { status: 'error', content: preparedEdit.error };
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
if (signal?.aborted) return canceled();
|
|
1144
|
+
return { status: 'error', content: { ok: false, error_type: error instanceof WorkspacePathError ? 'path_escape' : error.code || 'edit_match_failed',
|
|
1145
|
+
error_message: error.message, executed: false } };
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
const mutations = new Set(), publications = new Map(), reads = new Map(), displayChanges = new Map(), previousDisplayChanges = activeDisplayChanges,
|
|
1149
|
+
previousMutations = activeMutations, previousGuard = activeGuard,
|
|
1150
|
+
previousCheckpoint = activeCheckpoint, previousPublications = activePublications, previousReads = activeReads;
|
|
1151
|
+
let release, checkpoint, preparedRewind;
|
|
1152
|
+
try {
|
|
1153
|
+
if (mutating) release = acquireWorkspaceMutationLock(pinnedRootReal);
|
|
1154
|
+
guard();
|
|
1155
|
+
activeMutations = mutations; activeGuard = guard; activeReads = reads; activeDisplayChanges = displayChanges;
|
|
1156
|
+
if (name === 'rewind_workspace_files') {
|
|
1157
|
+
activePublications = publications;
|
|
1158
|
+
if (request.definition?.workspaceCheckpointPolicy !== WORKSPACE_CHECKPOINT_POLICY) throw Object.assign(Error('checkpoint_policy_missing'), { code: 'checkpoint_policy_missing' });
|
|
1159
|
+
preparedRewind = checkpoints.preview(call.arguments?.checkpoint_id, checkpointCommitted);
|
|
1160
|
+
if (!preparedRewind.content.can_apply || preparedRewind.content.preview_token !== call.arguments?.preview_token)
|
|
1161
|
+
return { status: 'error', content: { ...preparedRewind.content, ok: false, error_type: 'checkpoint_rewind_conflict', executed: false } };
|
|
1162
|
+
}
|
|
1163
|
+
if (mutating && enableRewind && request.definition?.workspaceCheckpointPolicy === WORKSPACE_CHECKPOINT_POLICY)
|
|
1164
|
+
activeCheckpoint = checkpoint = checkpoints.begin(request, preparedRewind?.ids);
|
|
1165
|
+
result = impl(call.arguments ?? {}, { signal, request, preparedEdit, preparedPatch, preparedRewind, checkpointCommitted });
|
|
1166
|
+
guard();
|
|
1167
|
+
if (checkpoint) {
|
|
1168
|
+
if (result?.ok !== true && mutations.size) throw Object.assign(Error('checkpoint_partial_effect'), { code: 'checkpoint_partial_effect' });
|
|
1169
|
+
checkpoint.finish(mutations);
|
|
1170
|
+
if (mutations.size) result.checkpoint_id = checkpoint.id;
|
|
1171
|
+
guard();
|
|
1172
|
+
}
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
let compensationError;
|
|
1175
|
+
if (name === 'rewind_workspace_files' && checkpoint?.canCompensate && mutations.size && !signal?.aborted
|
|
1176
|
+
&& !['workspace_execution_context_changed', 'workspace_execution_authority_changed', 'workspace_root_changed', 'workspace_missing'].includes(e.code)) {
|
|
1177
|
+
try {
|
|
1178
|
+
compensateRewind(preparedRewind, publications, checkpoint, guard);
|
|
1179
|
+
return { status: 'error', workspaceChangedPaths: [...mutations], content: { ok: false,
|
|
1180
|
+
error_type: 'checkpoint_rewind_compensated', cause: e.code || 'rewind_failed', compensated: true, executed: true, paths: [...mutations] } };
|
|
1181
|
+
} catch (error) { compensationError = error; }
|
|
1182
|
+
}
|
|
1183
|
+
if (checkpoint && !mutations.size) { try { checkpoint.fail(); } catch {} }
|
|
1184
|
+
if (signal?.aborted && !mutations.size) return canceled();
|
|
1185
|
+
if (e instanceof WorkspacePathError && !mutations.size) {
|
|
1186
|
+
return { status: 'error', content: { ok: false, error_type: 'path_escape', error_message: e.message } };
|
|
1187
|
+
}
|
|
1188
|
+
if (mutations.size) return { status: 'uncertain', workspaceChangedPaths: [...mutations],
|
|
1189
|
+
content: { ok: false, error_type: compensationError ? 'checkpoint_compensation_incomplete'
|
|
1190
|
+
: name === 'apply_workspace_patch' ? 'patch_publication_incomplete' : 'state_persist_failed',
|
|
1191
|
+
...(compensationError ? { compensation_reason: compensationError.code || 'compensation_failed' } : {}), executed: true, paths: [...mutations] } };
|
|
1192
|
+
return { status: 'error', content: { ok: false, error_type: e.code || 'internal_error', error_message: e.message } };
|
|
1193
|
+
} finally { activeMutations = previousMutations; activeGuard = previousGuard; activeCheckpoint = previousCheckpoint; activeDisplayChanges = previousDisplayChanges;
|
|
1194
|
+
activePublications = previousPublications; activeReads = previousReads; release?.(); }
|
|
1195
|
+
// The only asynchronous implementation parses captured read-only bytes.
|
|
1196
|
+
// Restore mutation tracking before any await so concurrent writers cannot
|
|
1197
|
+
// attribute their publications to this invocation.
|
|
1198
|
+
if (result && typeof result.then === 'function') {
|
|
1199
|
+
try { result = await result; }
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
if (signal?.aborted) return canceled();
|
|
1202
|
+
return { status: 'error', content: { ok: false, error_type: error.code || 'python_syntax_unavailable',
|
|
1203
|
+
error_message: error.message } };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (signal?.aborted && !(result && result.ok)) return canceled();
|
|
1207
|
+
|
|
1208
|
+
if (result && result.ok === true) {
|
|
1209
|
+
const out = { status: 'ok', content: result };
|
|
1210
|
+
if (imageObservations.has(result)) out.context = imageObservations.get(result);
|
|
1211
|
+
// Durable Host facts, excluded from model/display output. Restore only
|
|
1212
|
+
// exact full observations from successful selected historical calls.
|
|
1213
|
+
if (reads.size && ['read_workspace_file', 'write_workspace_file', 'write_workspace_files',
|
|
1214
|
+
'edit_workspace_file', 'multi_edit_workspace_file', 'apply_workspace_patch'].includes(name))
|
|
1215
|
+
out.metadata = { nativeWorkspaceReads: { contract: 'native-workspace-read-observation/v1', tool: name, states: [...reads.values()] } };
|
|
1216
|
+
const presentation = displayChanges.size ? workspaceChangeDisplay(displayChanges) : null;
|
|
1217
|
+
if (presentation) out.metadata = { ...out.metadata, nativeTaskDisplay: presentation };
|
|
1218
|
+
// Only mutating tools report workspace-changed paths; reads must not.
|
|
1219
|
+
if (mutating) {
|
|
1220
|
+
const changed = extractChangedPaths(result);
|
|
1221
|
+
if (changed.length) out.workspaceChangedPaths = changed;
|
|
1222
|
+
}
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
return { status: 'error', content: result, ...(mutations.size ? { workspaceChangedPaths: [...mutations] } : {}) };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function handleApprovalReply(request) {
|
|
1229
|
+
const interaction = request.interaction;
|
|
1230
|
+
const call = request.call;
|
|
1231
|
+
if (!interaction || typeof interaction !== 'object') {
|
|
1232
|
+
return { error: { code: 'interaction_required', message: 'approval interaction required' } };
|
|
1233
|
+
}
|
|
1234
|
+
if (!call?.callId || interaction.callId !== call.callId) {
|
|
1235
|
+
return { error: { code: 'interaction_binding_mismatch', message: 'interaction.callId does not match request.call.callId' } };
|
|
1236
|
+
}
|
|
1237
|
+
if (!interaction.interactionId) {
|
|
1238
|
+
return { error: { code: 'interaction_binding_mismatch', message: 'interaction.interactionId missing' } };
|
|
1239
|
+
}
|
|
1240
|
+
const pending = store.getPending(interaction.interactionId);
|
|
1241
|
+
if (!pending) return { error: { code: 'interaction_unknown_or_expired', message: 'approval interaction unknown or expired' } };
|
|
1242
|
+
if (pending.callId !== call.callId || pending.name !== call.name || argumentsDigest(call.arguments) !== pending.argumentsDigest) {
|
|
1243
|
+
return { error: { code: 'interaction_binding_mismatch', message: 'pending approval does not match this call' } };
|
|
1244
|
+
}
|
|
1245
|
+
const decision = String(interaction.decision || '').toLowerCase();
|
|
1246
|
+
if (decision === 'deny' || decision === 'denied') {
|
|
1247
|
+
store.consumePending(interaction.interactionId);
|
|
1248
|
+
return { error: { code: 'policy_denied', message: 'approval denied' } };
|
|
1249
|
+
}
|
|
1250
|
+
if (decision !== 'allow' && decision !== 'approve' && decision !== 'approved') {
|
|
1251
|
+
return { error: { code: 'interaction_binding_mismatch', message: 'approval decision missing or unrecognized' } };
|
|
1252
|
+
}
|
|
1253
|
+
store.consumePending(interaction.interactionId);
|
|
1254
|
+
return { approved: true };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ---- observe --------------------------------------------------------------
|
|
1258
|
+
|
|
1259
|
+
async function observe(request) {
|
|
1260
|
+
if (!request || typeof request !== 'object') {
|
|
1261
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: 'invalid_request' };
|
|
1262
|
+
}
|
|
1263
|
+
if (request.sessionId !== store.sessionId) return { revision: null, changedPaths: [], status: 'unavailable', reason: 'workspace_session_mismatch' };
|
|
1264
|
+
// The root boundary is pinned at factory creation; a swapped root is not
|
|
1265
|
+
// observed even if it still exists at a new real path.
|
|
1266
|
+
try { checkRootBoundary(); }
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
if (e instanceof RootBoundaryError) {
|
|
1269
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: e.code };
|
|
1270
|
+
}
|
|
1271
|
+
throw e;
|
|
1272
|
+
}
|
|
1273
|
+
const real = pinnedRootReal;
|
|
1274
|
+
if (!existsSync(real)) {
|
|
1275
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: 'workspace_missing' };
|
|
1276
|
+
}
|
|
1277
|
+
try {
|
|
1278
|
+
if (request.subject !== undefined) {
|
|
1279
|
+
const observation = await captureWorkspaceSubject({ root: real, subject: request.subject, signal: request.signal });
|
|
1280
|
+
checkRootBoundary();
|
|
1281
|
+
return observation;
|
|
1282
|
+
}
|
|
1283
|
+
const revision = computeRevision(real);
|
|
1284
|
+
return { revision, changedPaths: [], status: 'ok', reason: null };
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
if (e instanceof CaptureError) {
|
|
1287
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: e.code };
|
|
1288
|
+
}
|
|
1289
|
+
request.signal?.throwIfAborted();
|
|
1290
|
+
if (request.subject !== undefined) return { revision: null, changedPaths: [], status: 'unavailable',
|
|
1291
|
+
reason: ['capture_limit_exceeded', 'workspace_changed_during_capture', 'workspace_root_changed',
|
|
1292
|
+
'workspace_path_outside_scope', 'unsupported_workspace_entry', 'workspace_subject_file_required',
|
|
1293
|
+
'invalid_workspace_observation_subject', 'invalid_delivery_artifact', 'invalid_delivery_artifacts',
|
|
1294
|
+
'invalid_web_delivery_scope', 'delivery_scope_too_large'].includes(e.code) ? e.code : 'capture_failed',
|
|
1295
|
+
subject: request.subject };
|
|
1296
|
+
return { revision: null, changedPaths: [], status: 'unavailable', reason: 'capture_failed' };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Compute a workspace revision by scanning the directory tree twice and
|
|
1302
|
+
* requiring the two captures to agree. A single scan would treat a file
|
|
1303
|
+
* truncated or rewritten mid-scan as a stable revision; the double capture
|
|
1304
|
+
* mirrors ksadk.harness.workspace_adapters.native_directory, which rejects a
|
|
1305
|
+
* capture whose two passes differ in digest, entry list or per-entry
|
|
1306
|
+
* identity. A short read (fewer bytes than lstat reported) is a change and
|
|
1307
|
+
* makes the capture unavailable.
|
|
1308
|
+
*
|
|
1309
|
+
* Remaining non-atomic limit: two captures are sequential, not a single
|
|
1310
|
+
* filesystem transaction. A change that happens entirely between the two
|
|
1311
|
+
* scans, or within a single scan with no carry-over, can still produce a
|
|
1312
|
+
* stable but momentary revision. The Host's effect/receipt layer, not this
|
|
1313
|
+
* port, owns the authoritative mutation ledger; observe is a best-effort
|
|
1314
|
+
* snapshot that refuses to confirm a workspace it saw changing.
|
|
1315
|
+
*/
|
|
1316
|
+
function computeRevision(real) {
|
|
1317
|
+
return 'sha256:' + captureStable(real).digest;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function captureStable(real) {
|
|
1321
|
+
const first = captureInventory(real);
|
|
1322
|
+
const second = captureInventory(real);
|
|
1323
|
+
if (first.digest !== second.digest
|
|
1324
|
+
|| first.entryCount !== second.entryCount
|
|
1325
|
+
|| !entriesEqual(first.entries, second.entries)) {
|
|
1326
|
+
throw new CaptureError('workspace_changed_during_capture');
|
|
1327
|
+
}
|
|
1328
|
+
return second;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function captureInventory(real) {
|
|
1332
|
+
const hash = createHash('sha256');
|
|
1333
|
+
hash.update('ksadk.directory-workspace/v1\0');
|
|
1334
|
+
const state = { entries: 0, hashedBytes: 0, list: [], byPath: Object.create(null) };
|
|
1335
|
+
scanDir(real, '', hash, state);
|
|
1336
|
+
state.list.sort();
|
|
1337
|
+
return { digest: hash.digest('hex'), entryCount: state.entries, entries: state.list, byPath: state.byPath };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** Compare two sorted entry-identity lists for exact equality. */
|
|
1341
|
+
function entriesEqual(a, b) {
|
|
1342
|
+
if (a.length !== b.length) return false;
|
|
1343
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1344
|
+
return true;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function scanDir(real, rel, hash, state) {
|
|
1348
|
+
const dir = rel ? join(real, rel) : real;
|
|
1349
|
+
let dirents;
|
|
1350
|
+
try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { throw new CaptureError('capture_failed'); }
|
|
1351
|
+
dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1352
|
+
for (const d of dirents) {
|
|
1353
|
+
const entryRel = rel ? `${rel}/${d.name}` : d.name;
|
|
1354
|
+
if (entryRel.includes('\\') || entryRel.includes('\0')) throw new CaptureError('unsupported_workspace_path');
|
|
1355
|
+
state.entries += 1;
|
|
1356
|
+
if (state.entries > OBSERVE_MAX_ENTRIES) throw new CaptureError('capture_limit_exceeded');
|
|
1357
|
+
const abs = join(real, entryRel);
|
|
1358
|
+
let info;
|
|
1359
|
+
try { info = lstatSync(abs, { bigint: true }); } catch { throw new CaptureError('capture_failed'); }
|
|
1360
|
+
// Re-stat after opening files below would race; record the pre-read identity
|
|
1361
|
+
// and verify it again at the end of the entry so a mid-entry change is caught.
|
|
1362
|
+
const before = entryIdentity(info);
|
|
1363
|
+
hash.update(`\0path\0${entryRel}`);
|
|
1364
|
+
hash.update(`\0mode:${Number(info.mode & 0o777n).toString(8)}`);
|
|
1365
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
1366
|
+
hash.update('\0directory');
|
|
1367
|
+
scanDir(real, entryRel, hash, state);
|
|
1368
|
+
} else if (info.isSymbolicLink()) {
|
|
1369
|
+
let target;
|
|
1370
|
+
try { target = readlinkSync(abs); } catch { throw new CaptureError('capture_failed'); }
|
|
1371
|
+
const tb = Buffer.from(target, 'utf-8');
|
|
1372
|
+
state.hashedBytes += tb.length;
|
|
1373
|
+
if (state.hashedBytes > OBSERVE_MAX_HASHED) throw new CaptureError('capture_limit_exceeded');
|
|
1374
|
+
hash.update('\0symlink\0');
|
|
1375
|
+
hash.update(tb);
|
|
1376
|
+
state.list.push(`${entryRel}\tsymlink\t${before}`);
|
|
1377
|
+
} else if (info.isFile()) {
|
|
1378
|
+
if (state.hashedBytes + Number(info.size) > OBSERVE_MAX_HASHED) throw new CaptureError('capture_limit_exceeded');
|
|
1379
|
+
const fileHash = createHash('sha256');
|
|
1380
|
+
const fd = openSync(abs, 'r');
|
|
1381
|
+
let readBytes = 0;
|
|
1382
|
+
try {
|
|
1383
|
+
let remaining = Number(info.size);
|
|
1384
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
1385
|
+
while (remaining > 0) {
|
|
1386
|
+
const n = readSync(fd, buf, 0, Math.min(buf.length, remaining), null);
|
|
1387
|
+
if (n === 0) break; // short read: file shrank under us
|
|
1388
|
+
readBytes += n;
|
|
1389
|
+
state.hashedBytes += n;
|
|
1390
|
+
if (state.hashedBytes > OBSERVE_MAX_HASHED) throw new CaptureError('capture_limit_exceeded');
|
|
1391
|
+
fileHash.update(buf.subarray(0, n));
|
|
1392
|
+
remaining -= n;
|
|
1393
|
+
}
|
|
1394
|
+
} finally { closeSync(fd); }
|
|
1395
|
+
// A short read (fewer bytes than lstat reported) means the file changed
|
|
1396
|
+
// during the read; treat the capture as changing rather than locking
|
|
1397
|
+
// in a truncated digest as if it were the whole file.
|
|
1398
|
+
if (readBytes !== Number(info.size)) throw new CaptureError('workspace_changed_during_capture');
|
|
1399
|
+
// Compute the file digest once; Hash.digest() finalizes the hash and a
|
|
1400
|
+
// second call throws "Digest already called".
|
|
1401
|
+
const fileDigest = fileHash.digest();
|
|
1402
|
+
hash.update('\0file\0');
|
|
1403
|
+
hash.update(fileDigest);
|
|
1404
|
+
state.list.push(`${entryRel}\tfile\t${before}\t${fileDigest.toString('hex')}`);
|
|
1405
|
+
} else {
|
|
1406
|
+
throw new CaptureError('unsupported_workspace_entry');
|
|
1407
|
+
}
|
|
1408
|
+
// Verify the entry's identity is unchanged since the start of this entry;
|
|
1409
|
+
// a rename/replace/truncate that landed during the read is a change.
|
|
1410
|
+
let after;
|
|
1411
|
+
try { after = entryIdentity(lstatSync(abs, { bigint: true })); } catch { throw new CaptureError('capture_failed'); }
|
|
1412
|
+
if (after !== before) throw new CaptureError('workspace_changed_during_capture');
|
|
1413
|
+
state.byPath[entryRel] = info.isDirectory() ? `directory:${before}` : state.list.at(-1);
|
|
1414
|
+
if (state.hashedBytes > OBSERVE_MAX_HASHED) throw new CaptureError('capture_limit_exceeded');
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** Stable identity tuple for an entry: mode, size, mtimeNs, ctimeNs, ino. */
|
|
1419
|
+
function entryIdentity(info) {
|
|
1420
|
+
return `${info.mode}|${info.size}|${info.mtimeNs}|${info.ctimeNs}|${info.ino}`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function recordReceipt(request, result, { assertCurrent } = {}) {
|
|
1424
|
+
if (!enableRewind || request.definition?.workspaceCheckpointPolicy !== WORKSPACE_CHECKPOINT_POLICY
|
|
1425
|
+
|| request.definition?.recoveryPolicy !== 'receipt-observation/v1' || !['ok', 'error'].includes(result.status)) return;
|
|
1426
|
+
const guard = workspaceMutationGuard(request, assertCurrent, checkRootBoundary);
|
|
1427
|
+
guard(); checkpoints.recordReceipt(request, result); guard();
|
|
1428
|
+
}
|
|
1429
|
+
async function observeReceipt(request, { assertCurrent } = {}) {
|
|
1430
|
+
const signal = request.signal, sealed = canonical({ ...request, signal: null });
|
|
1431
|
+
const check = () => {
|
|
1432
|
+
checkRootBoundary(); signal?.throwIfAborted();
|
|
1433
|
+
if (request.sessionId !== store.sessionId || request.signal !== signal || canonical({ ...request, signal: null }) !== sealed
|
|
1434
|
+
|| request.contract !== 'agentkit.tool-receipt-observation/v1' || request.definition?.workspaceCheckpointPolicy !== WORKSPACE_CHECKPOINT_POLICY
|
|
1435
|
+
|| request.definition?.recoveryPolicy !== 'receipt-observation/v1' || typeof assertCurrent !== 'function' || assertCurrent(request) !== true)
|
|
1436
|
+
throw Object.assign(Error('checkpoint_receipt_authority_changed'), { code: 'checkpoint_receipt_authority_changed' });
|
|
1437
|
+
};
|
|
1438
|
+
check(); const result = checkpoints.readReceipt(request); check();
|
|
1439
|
+
return { contract: request.contract, originalOperationKey: request.originalOperationKey, callId: request.call.callId, result };
|
|
1440
|
+
}
|
|
1441
|
+
async function repairWorkspace(request, { assertCurrent } = {}) {
|
|
1442
|
+
const envelope = result => ({ contract: request.contract, originalOperationKey: request.originalOperationKey,
|
|
1443
|
+
callId: request.call.callId, result });
|
|
1444
|
+
if (request.contract !== 'agentkit.workspace-compensation/v1' || request.sessionId !== store.sessionId
|
|
1445
|
+
|| request.definition?.workspaceRepairPolicy !== 'preimage-compensation/v1'
|
|
1446
|
+
|| request.definition?.workspaceCheckpointPolicy !== WORKSPACE_CHECKPOINT_POLICY || typeof assertCurrent !== 'function')
|
|
1447
|
+
throw Object.assign(Error('checkpoint_repair_authority_missing'), { code: 'checkpoint_repair_authority_missing' });
|
|
1448
|
+
const guard = workspaceMutationGuard(request, assertCurrent, checkRootBoundary);
|
|
1449
|
+
let release;
|
|
1450
|
+
try {
|
|
1451
|
+
guard(); release = acquireWorkspaceMutationLock(pinnedRootReal); guard();
|
|
1452
|
+
const plan = checkpoints.prepareRepair(request); guard();
|
|
1453
|
+
if (plan.result) return envelope(plan.result);
|
|
1454
|
+
for (const file of plan.files) {
|
|
1455
|
+
const check = () => { guard(); if (resolvePath(file.path) !== file.canonicalPath)
|
|
1456
|
+
throw Object.assign(Error('checkpoint_path_changed'), { code: 'checkpoint_path_changed' }); };
|
|
1457
|
+
check(); const current = captureCheckpointFile(file.canonicalPath);
|
|
1458
|
+
if (!checkpointStatesEqual(current, file.current)) throw Object.assign(Error('checkpoint_external_modified'), { code: 'checkpoint_external_modified' });
|
|
1459
|
+
if (checkpointStatesEqual(current, file.before)) continue;
|
|
1460
|
+
if (current.kind !== 'absent' && current.kind !== file.before.kind) {
|
|
1461
|
+
check(); if (current.kind === 'directory') rmdirSync(file.canonicalPath); else unlinkSync(file.canonicalPath);
|
|
1462
|
+
}
|
|
1463
|
+
if (file.before.kind === 'file') {
|
|
1464
|
+
check(); publishWorkspaceBytes(file.canonicalPath, Buffer.from(file.before.bytes, 'base64'));
|
|
1465
|
+
check(); chmodSync(file.canonicalPath, file.before.mode);
|
|
1466
|
+
} else if (file.before.kind === 'directory') {
|
|
1467
|
+
check(); mkdirSync(file.canonicalPath, { recursive: true, mode: file.before.mode });
|
|
1468
|
+
check(); chmodSync(file.canonicalPath, file.before.mode);
|
|
1469
|
+
}
|
|
1470
|
+
check();
|
|
1471
|
+
}
|
|
1472
|
+
guard(); plan.complete(); guard();
|
|
1473
|
+
return envelope({ status: 'error', workspaceChangedPaths: plan.files.map(file => file.path), content: {
|
|
1474
|
+
ok: false, error_type: 'workspace_operation_compensated', compensated: true,
|
|
1475
|
+
original_operation_key: request.originalOperationKey, paths: plan.files.map(file => file.path),
|
|
1476
|
+
message: 'The unconfirmed workspace operation was explicitly compensated. Its original success is not asserted.' } });
|
|
1477
|
+
} finally { release?.(); }
|
|
1478
|
+
}
|
|
1479
|
+
function recordRepairReceipt(request, result, { assertCurrent } = {}) {
|
|
1480
|
+
const guard = workspaceMutationGuard(request, assertCurrent, checkRootBoundary);
|
|
1481
|
+
guard(); checkpoints.recordRepairReceipt(request, result); guard();
|
|
1482
|
+
}
|
|
1483
|
+
// Explicit authenticated Host action, with its own journaled intent/result.
|
|
1484
|
+
// Reuses the workspace publisher/lock, never fabricates a Native tool claim.
|
|
1485
|
+
function conversationWorkspace(control, { signal, assertCurrent, checkpointCommitted, selectCheckpoint }, apply) {
|
|
1486
|
+
if (!enableRewind || control?.contract !== CONVERSATION_WORKSPACE_CONTROL || control.sessionId !== store.sessionId
|
|
1487
|
+
|| !(signal instanceof AbortSignal) || typeof assertCurrent !== 'function' || typeof selectCheckpoint !== 'function'
|
|
1488
|
+
|| typeof checkpointCommitted !== 'function') throw Object.assign(Error('checkpoint_control_authority_missing'), { code: 'checkpoint_control_authority_missing' });
|
|
1489
|
+
const frozen = canonical(control);
|
|
1490
|
+
const guard = () => {
|
|
1491
|
+
signal.throwIfAborted(); checkRootBoundary();
|
|
1492
|
+
const valid = assertCurrent();
|
|
1493
|
+
if (valid && typeof valid.then === 'function') Promise.resolve(valid).catch(() => {});
|
|
1494
|
+
if (valid !== true || canonical(control) !== frozen) throw Object.assign(Error('workspace_execution_authority_changed'), { code: 'workspace_execution_authority_changed' });
|
|
1495
|
+
signal.throwIfAborted();
|
|
1496
|
+
};
|
|
1497
|
+
guard(); const release = acquireWorkspaceMutationLock(pinnedRootReal);
|
|
1498
|
+
const previous = [activeMutations, activeGuard, activeCheckpoint, activePublications, activeReads];
|
|
1499
|
+
const mutations = new Set(), publications = new Map();
|
|
1500
|
+
let checkpoint, plan;
|
|
1501
|
+
try {
|
|
1502
|
+
guard();
|
|
1503
|
+
const previousControl = apply ? checkpoints.controlRecord(control) : null;
|
|
1504
|
+
if (previousControl?.status === 'completed') { const result = checkpoints.completeControl(control); guard(); return result; }
|
|
1505
|
+
if (previousControl) {
|
|
1506
|
+
if (['prepared', 'compensating'].includes(previousControl.status)) {
|
|
1507
|
+
const repair = checkpoints.prepareControlRepair(control); guard();
|
|
1508
|
+
restoreRepairPlan(repair, guard); guard(); repair.complete(); guard();
|
|
1509
|
+
}
|
|
1510
|
+
throw Object.assign(Error('checkpoint_control_compensated'), { code: 'checkpoint_control_compensated' });
|
|
1511
|
+
}
|
|
1512
|
+
const rows = checkpoints.controlRows();
|
|
1513
|
+
if (rows.some(row => ['prepared', 'compensating'].includes(row.status))) throw Object.assign(Error('checkpoint_outcome_unknown'), { code: 'checkpoint_outcome_unknown' });
|
|
1514
|
+
const checkpointId = selectCheckpoint(rows); guard();
|
|
1515
|
+
plan = checkpointId ? checkpoints.preview(checkpointId, checkpointCommitted) : { ids: [], files: [], content: { can_apply: true, unsafe_files: [] } };
|
|
1516
|
+
const { requestDigest: _digest, workspacePreviewToken: _token, ...identity } = control;
|
|
1517
|
+
const previewToken = sha256(canonical({ identity, checkpointId, plan: plan.content.preview_token ?? null }));
|
|
1518
|
+
const preview = { contract: CONVERSATION_WORKSPACE_CONTROL, checkpointId, previewToken, canApply: plan.content.can_apply,
|
|
1519
|
+
files: plan.content.files ?? [], unsafeFiles: plan.content.unsafe_files };
|
|
1520
|
+
if (!apply) return preview;
|
|
1521
|
+
if (!preview.canApply || previewToken !== control.workspacePreviewToken) throw Object.assign(Error('checkpoint_rewind_conflict'), { code: 'checkpoint_rewind_conflict' });
|
|
1522
|
+
if (!checkpointId) return { contract: CONVERSATION_WORKSPACE_CONTROL, requestDigest: control.requestDigest,
|
|
1523
|
+
checkpointId: null, rewoundCheckpointIds: [], changedPaths: [], previewToken };
|
|
1524
|
+
guard(); checkpoint = checkpoints.beginControl(control, plan);
|
|
1525
|
+
activeMutations = mutations; activeGuard = guard; activeCheckpoint = checkpoint; activePublications = publications; activeReads = new Map();
|
|
1526
|
+
impls.rewind_workspace_files({}, { preparedRewind: plan }); guard();
|
|
1527
|
+
checkpoint.finish(mutations); guard();
|
|
1528
|
+
const result = checkpoints.completeControl(control); guard(); return result;
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
if (checkpoint?.canCompensate && mutations.size && !signal.aborted
|
|
1531
|
+
&& !['workspace_execution_authority_changed', 'workspace_root_changed', 'workspace_missing'].includes(error.code)) {
|
|
1532
|
+
compensateRewind(plan, publications, checkpoint, guard);
|
|
1533
|
+
} else if (checkpoint && !mutations.size) checkpoint.fail();
|
|
1534
|
+
throw error;
|
|
1535
|
+
} finally {
|
|
1536
|
+
[activeMutations, activeGuard, activeCheckpoint, activePublications, activeReads] = previous;
|
|
1537
|
+
release();
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function restoreRepairPlan(plan, guard) {
|
|
1541
|
+
for (const file of plan.files) {
|
|
1542
|
+
const check = () => { guard(); if (resolvePath(file.path) !== file.canonicalPath)
|
|
1543
|
+
throw Object.assign(Error('checkpoint_path_changed'), { code: 'checkpoint_path_changed' }); };
|
|
1544
|
+
check(); const current = captureCheckpointFile(file.canonicalPath);
|
|
1545
|
+
if (!checkpointStatesEqual(current, file.current)) throw Object.assign(Error('checkpoint_external_modified'), { code: 'checkpoint_external_modified' });
|
|
1546
|
+
if (checkpointStatesEqual(current, file.before)) continue;
|
|
1547
|
+
if (current.kind !== 'absent' && current.kind !== file.before.kind) {
|
|
1548
|
+
check(); if (current.kind === 'directory') rmdirSync(file.canonicalPath); else unlinkSync(file.canonicalPath);
|
|
1549
|
+
}
|
|
1550
|
+
if (file.before.kind === 'file') {
|
|
1551
|
+
check(); publishWorkspaceBytes(file.canonicalPath, Buffer.from(file.before.bytes, 'base64')); check(); chmodSync(file.canonicalPath, file.before.mode);
|
|
1552
|
+
} else if (file.before.kind === 'directory') {
|
|
1553
|
+
check(); mkdirSync(file.canonicalPath, { recursive: true, mode: file.before.mode }); check(); chmodSync(file.canonicalPath, file.before.mode);
|
|
1554
|
+
}
|
|
1555
|
+
check();
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return { definitions, execute, observe, recordReceipt, observeReceipt, repairWorkspace, recordRepairReceipt,
|
|
1559
|
+
pendingConversationRewind: accepted => {
|
|
1560
|
+
const rows = checkpoints.pendingControlRows().filter(row => row.controlBinding && row.status !== 'failed'
|
|
1561
|
+
&& !accepted.some(marker => marker.commandId === row.controlBinding.commandId && marker.requestDigest === row.controlBinding.requestDigest));
|
|
1562
|
+
if (rows.length > 1) throw Object.assign(Error('checkpoint_control_duplicate'), { code: 'checkpoint_control_duplicate' });
|
|
1563
|
+
return rows[0] ? structuredClone(rows[0].controlBinding) : null;
|
|
1564
|
+
},
|
|
1565
|
+
previewConversationRewind: (control, options) => conversationWorkspace(control, options, false),
|
|
1566
|
+
rewindConversation: (control, options) => conversationWorkspace(control, options, true),
|
|
1567
|
+
adoptConversationBranch: (branch, restore) => store.adoptConversationBranch(branch, () => {
|
|
1568
|
+
const candidates = typeof restore === 'function' ? restore() : restore ?? [];
|
|
1569
|
+
return candidates.filter(raw => {
|
|
1570
|
+
const state = deserializeReadState(raw);
|
|
1571
|
+
if (!state || state.partial) return false;
|
|
1572
|
+
try { return resolvePath(state.path) === state.canonicalPath; } catch { return false; }
|
|
1573
|
+
});
|
|
1574
|
+
}),
|
|
1575
|
+
// Host-only change capture for a governed command. It shares the bounded,
|
|
1576
|
+
// stable observer; paths remain structured even when names contain tabs.
|
|
1577
|
+
capture(request) {
|
|
1578
|
+
if (request.sessionId !== store.sessionId) throw new CaptureError('workspace_session_mismatch');
|
|
1579
|
+
checkRootBoundary(); const captured = captureStable(pinnedRootReal);
|
|
1580
|
+
return { revision: 'sha256:' + captured.digest, entries: { ...captured.byPath } };
|
|
1581
|
+
},
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function canceled() {
|
|
1586
|
+
return { status: 'error', content: { code: 'canceled', message: 'tool execution canceled' } };
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function extractChangedPaths(result) {
|
|
1590
|
+
if (Array.isArray(result.changed_paths)) return result.changed_paths;
|
|
1591
|
+
if (result.path) return [result.path];
|
|
1592
|
+
if (result.deleted) return [result.deleted];
|
|
1593
|
+
if (Array.isArray(result.written)) return result.written.map((w) => w.path).filter(Boolean);
|
|
1594
|
+
return [];
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function defaultAuthorize(request) {
|
|
1598
|
+
const name = request?.call?.name;
|
|
1599
|
+
if (name && READ_ONLY.has(name)) return 'allow';
|
|
1600
|
+
return 'approval_required';
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ---- ToolDefinition[] -------------------------------------------------------
|
|
1604
|
+
|
|
1605
|
+
/** Shared execution/catalog definitions. Reading metadata performs no I/O. */
|
|
1606
|
+
export function workspaceToolDefinitions() {
|
|
1607
|
+
return [
|
|
1608
|
+
def('workspace_status', 'Return the current AgentEngine workspace status.', {
|
|
1609
|
+
type: 'object', properties: {}, additionalProperties: false,
|
|
1610
|
+
}, { readOnly: true, concurrentSafe: true, contextRole: 'none', timeoutMs: 30000 }),
|
|
1611
|
+
def('list_workspace_files', 'List files under the AgentEngine workspace.', {
|
|
1612
|
+
type: 'object',
|
|
1613
|
+
properties: {
|
|
1614
|
+
path: { type: 'string', default: '.' },
|
|
1615
|
+
glob: { type: ['string', 'null'] },
|
|
1616
|
+
recursive: { type: 'boolean', default: false },
|
|
1617
|
+
include_dirs: { type: 'boolean', default: true },
|
|
1618
|
+
max_results: { type: 'integer', default: 500, minimum: 1, maximum: 5000 },
|
|
1619
|
+
sort_by: { type: 'string', default: 'name', enum: ['name', 'mtime', 'size'] },
|
|
1620
|
+
},
|
|
1621
|
+
additionalProperties: false,
|
|
1622
|
+
}, { readOnly: true, concurrentSafe: true, contextRole: 'workspace_list', timeoutMs: 30000, recoveryPolicy: 'safe-read/v1' }),
|
|
1623
|
+
def('read_workspace_file', 'Read a UTF-8 workspace file. Inspect the result before editing it. Do not issue a dependent read and edit in the same parallel tool-call batch.', {
|
|
1624
|
+
type: 'object',
|
|
1625
|
+
properties: {
|
|
1626
|
+
path: { type: 'string' },
|
|
1627
|
+
start_line: { type: ['integer', 'null'], minimum: 1 },
|
|
1628
|
+
end_line: { type: ['integer', 'null'], minimum: 1 },
|
|
1629
|
+
max_chars: { type: ['integer', 'null'], minimum: 1000, maximum: 100000 },
|
|
1630
|
+
include_line_numbers: { type: 'boolean', default: true },
|
|
1631
|
+
},
|
|
1632
|
+
required: ['path'], additionalProperties: false,
|
|
1633
|
+
}, { readOnly: true, concurrentSafe: true, contextRole: 'workspace_read', timeoutMs: 30000, recoveryPolicy: 'safe-read/v1' }),
|
|
1634
|
+
def('write_workspace_file', 'Write a UTF-8 text file inside the AgentEngine workspace. Creating a new file needs no prior read; replacing an existing file requires a complete current read or a successful write in this session.', {
|
|
1635
|
+
type: 'object',
|
|
1636
|
+
properties: {
|
|
1637
|
+
path: { type: 'string' },
|
|
1638
|
+
content: { type: 'string' },
|
|
1639
|
+
overwrite: { type: 'boolean', default: true },
|
|
1640
|
+
},
|
|
1641
|
+
required: ['path', 'content'], additionalProperties: false,
|
|
1642
|
+
}, { readOnly: false, concurrentSafe: false, contextRole: 'none', permissions: ['workspace:write'], timeoutMs: 30000 }),
|
|
1643
|
+
def('write_workspace_files', 'Write multiple UTF-8 text files inside the AgentEngine workspace. Preconditions for the batch are checked before the first file is changed.', {
|
|
1644
|
+
type: 'object',
|
|
1645
|
+
properties: {
|
|
1646
|
+
files: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
|
|
1647
|
+
overwrite: { type: 'boolean', default: true },
|
|
1648
|
+
},
|
|
1649
|
+
required: ['files'], additionalProperties: false,
|
|
1650
|
+
}, { readOnly: false, concurrentSafe: false, contextRole: 'none', permissions: ['workspace:write'], timeoutMs: 60000 }),
|
|
1651
|
+
def('edit_workspace_file', 'Replace exact text after reading or successfully writing the file. The prior operation must complete; do not batch dependent reads/writes and edits.', {
|
|
1652
|
+
type: 'object',
|
|
1653
|
+
properties: {
|
|
1654
|
+
path: { type: 'string' },
|
|
1655
|
+
old_text: { type: 'string' },
|
|
1656
|
+
new_text: { type: 'string' },
|
|
1657
|
+
expected_replacements: { type: 'integer', default: 1, minimum: 1 },
|
|
1658
|
+
replace_all: { type: 'boolean', default: false },
|
|
1659
|
+
},
|
|
1660
|
+
required: ['path', 'old_text', 'new_text'], additionalProperties: false,
|
|
1661
|
+
}, { readOnly: false, concurrentSafe: false, contextRole: 'none', permissions: ['workspace:write'], timeoutMs: 30000 }),
|
|
1662
|
+
def('multi_edit_workspace_file', 'Apply atomic exact edits after reading or successfully writing the file. The prior operation must complete; do not batch dependent reads/writes and edits.', {
|
|
1663
|
+
type: 'object',
|
|
1664
|
+
properties: {
|
|
1665
|
+
path: { type: 'string' },
|
|
1666
|
+
edits: { type: 'array', items: { type: 'object', properties: { old_text: { type: 'string' }, new_text: { type: 'string' }, expected_replacements: { type: 'integer', default: 1, minimum: 1 }, replace_all: { type: 'boolean', default: false } }, required: ['old_text', 'new_text'] } },
|
|
1667
|
+
replace_all: { type: 'boolean', default: false },
|
|
1668
|
+
},
|
|
1669
|
+
required: ['path', 'edits'], additionalProperties: false,
|
|
1670
|
+
}, { readOnly: false, concurrentSafe: false, contextRole: 'none', permissions: ['workspace:write'], timeoutMs: 30000 }),
|
|
1671
|
+
def('lint_workspace_file', 'Run lightweight built-in lint checks for a UTF-8 workspace text file.', {
|
|
1672
|
+
type: 'object',
|
|
1673
|
+
properties: {
|
|
1674
|
+
path: { type: 'string' },
|
|
1675
|
+
language: { type: 'string', default: 'auto' },
|
|
1676
|
+
},
|
|
1677
|
+
required: ['path'], additionalProperties: false,
|
|
1678
|
+
}, { readOnly: true, concurrentSafe: true, contextRole: 'none', timeoutMs: 30000 }),
|
|
1679
|
+
def('search_workspace_files', 'Search UTF-8 text files in the AgentEngine workspace.', {
|
|
1680
|
+
type: 'object',
|
|
1681
|
+
properties: {
|
|
1682
|
+
query: { type: 'string' },
|
|
1683
|
+
path: { type: 'string', default: '.' },
|
|
1684
|
+
is_regex: { type: 'boolean', default: false },
|
|
1685
|
+
glob: { type: ['string', 'null'] },
|
|
1686
|
+
case_sensitive: { type: 'boolean', default: false },
|
|
1687
|
+
context_lines: { type: 'integer', default: 0, minimum: 0, maximum: 20 },
|
|
1688
|
+
max_results: { type: 'integer', default: 100, minimum: 1, maximum: 5000 },
|
|
1689
|
+
},
|
|
1690
|
+
required: ['query'], additionalProperties: false,
|
|
1691
|
+
}, { readOnly: true, concurrentSafe: true, contextRole: 'workspace_search', timeoutMs: 60000, recoveryPolicy: 'safe-read/v1' }),
|
|
1692
|
+
def('delete_workspace_file', 'Delete a file or empty directory inside the AgentEngine workspace.', {
|
|
1693
|
+
type: 'object',
|
|
1694
|
+
properties: { path: { type: 'string' } },
|
|
1695
|
+
required: ['path'], additionalProperties: false,
|
|
1696
|
+
}, { readOnly: false, concurrentSafe: false, contextRole: 'none', permissions: ['workspace:delete'], timeoutMs: 30000 }),
|
|
1697
|
+
];
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/** Shared by Native execution and its local Studio catalog; no workspace access. */
|
|
1701
|
+
export function workspaceRewindDefinitions() {
|
|
1702
|
+
const checkpoint = { type: 'string', pattern: '^[0-9a-f-]{36}$' };
|
|
1703
|
+
const read = { readOnly: true, concurrentSafe: true, permissions: ['workspace:read'] };
|
|
1704
|
+
return [
|
|
1705
|
+
def('list_workspace_checkpoints', 'List this Native Session\'s durable file checkpoints. Prepared/unknown or already rewound records are not permission to replay effects. Checkpoint contents are private and are not returned.',
|
|
1706
|
+
{ type: 'object', additionalProperties: false, properties: {} }, read),
|
|
1707
|
+
def('preview_workspace_rewind', 'Preview file-only rewind from a checkpoint, including later committed changes in this Session. Refuses external modifications and uncommitted effects. Does not rewind conversation or execute files. Use the returned preview_token for an approved rewind.',
|
|
1708
|
+
{ type: 'object', additionalProperties: false, required: ['checkpoint_id'], properties: { checkpoint_id: checkpoint } }, read),
|
|
1709
|
+
{ ...def('rewind_workspace_files', 'Restore files from an exact successful preview. This changes workspace files, requires normal write/delete permission and is forbidden in Plan mode. It never reruns the original tools. A changed preview or unknown effect blocks the whole rewind; partial publication is uncertain and must be reconciled.',
|
|
1710
|
+
{ type: 'object', additionalProperties: false, required: ['checkpoint_id', 'preview_token'], properties: { checkpoint_id: checkpoint, preview_token: { type: 'string', pattern: '^[0-9a-f]{64}$' } } },
|
|
1711
|
+
{ readOnly: false, concurrentSafe: false, permissions: ['workspace:write', 'workspace:delete'] }),
|
|
1712
|
+
workspaceCheckpointPolicy: WORKSPACE_CHECKPOINT_POLICY, recoveryPolicy: 'receipt-observation/v1' },
|
|
1713
|
+
];
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** Shared by Native execution and its local Studio catalog; no workspace access. */
|
|
1717
|
+
export function workspacePatchDefinition() {
|
|
1718
|
+
return def('apply_workspace_patch', 'Apply a structured patch to workspace files. Use *** Begin Patch / *** End Patch, Add File, Delete File, Update File and optional Move to headers. Hunks use @@ with space/context, -/removed and +/added lines. All hunks are verified before writing. Existing move destinations and overlapping paths are refused. A multi-file publication is not atomic; an uncertain result requires reconciliation, never blind replay.', {
|
|
1719
|
+
type: 'object', additionalProperties: false, required: ['patch_text'], properties: { patch_text: { type: 'string', maxLength: 262144 } },
|
|
1720
|
+
}, { workspacePatchPolicy: 'zcode-patch/v1', readOnly: false, concurrentSafe: false,
|
|
1721
|
+
contextRole: 'none', permissions: ['workspace:write', 'workspace:delete'], timeoutMs: 30000 });
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function def(name, description, parameters, opts) {
|
|
1725
|
+
return {
|
|
1726
|
+
name,
|
|
1727
|
+
description,
|
|
1728
|
+
parameters,
|
|
1729
|
+
readOnly: opts.readOnly,
|
|
1730
|
+
concurrentSafe: opts.concurrentSafe,
|
|
1731
|
+
permissions: opts.permissions || [],
|
|
1732
|
+
timeoutMs: opts.timeoutMs || 30000,
|
|
1733
|
+
contextRole: opts.contextRole || 'none',
|
|
1734
|
+
observationRole: 'none',
|
|
1735
|
+
progressObservation: false,
|
|
1736
|
+
contextRetention: 'normal',
|
|
1737
|
+
...(opts.recoveryPolicy ? { recoveryPolicy: opts.recoveryPolicy } : {}),
|
|
1738
|
+
...(opts.workspacePatchPolicy ? { workspacePatchPolicy: opts.workspacePatchPolicy } : {}),
|
|
1739
|
+
};
|
|
1740
|
+
}
|