@temporalio/agent-harness 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -2
- package/dist/src/ai-sdks/google-genai-plugin/index.d.ts +6 -0
- package/dist/src/ai-sdks/google-genai-plugin/index.js +24 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-conversation.d.ts +35 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-conversation.js +178 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-models.d.ts +8 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-models.js +4 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-workflow.d.ts +8 -0
- package/dist/src/ai-sdks/google-genai-plugin/interactions-workflow.js +15 -0
- package/dist/src/ai-sdks/google-genai-plugin/temporal-async-client.d.ts +10 -0
- package/dist/src/ai-sdks/google-genai-plugin/temporal-async-client.js +9 -0
- package/dist/src/ai-sdks/google-genai-plugin/temporal-interactions.d.ts +31 -0
- package/dist/src/ai-sdks/google-genai-plugin/temporal-interactions.js +43 -0
- package/dist/src/ai-sdks/google-genai-plugin/workflow.d.ts +16 -0
- package/dist/src/ai-sdks/google-genai-plugin/workflow.js +14 -0
- package/dist/src/ai-sdks/jev/evaluator.d.ts +38 -0
- package/dist/src/ai-sdks/jev/evaluator.js +155 -0
- package/dist/src/ai-sdks/openai-agents-harness.d.ts +94 -0
- package/dist/src/ai-sdks/openai-agents-harness.js +347 -0
- package/dist/src/harness/agent-protocol/agent-interface.d.ts +369 -0
- package/dist/src/harness/agent-protocol/agent-interface.js +222 -0
- package/dist/src/harness/agent-protocol/events.d.ts +10 -0
- package/dist/src/harness/agent-protocol/events.js +6 -0
- package/dist/src/harness/agent-protocol/index.d.ts +5 -0
- package/dist/src/harness/agent-protocol/index.js +7 -0
- package/dist/src/harness/agent-protocol/schema.d.ts +34 -0
- package/dist/src/harness/agent-protocol/schema.js +53 -0
- package/dist/src/harness/agent-protocol/subagent-interface.d.ts +25 -0
- package/dist/src/harness/agent-protocol/subagent-interface.js +17 -0
- package/dist/src/harness/agent-schema.d.ts +4 -0
- package/dist/src/harness/agent-schema.js +106 -0
- package/dist/src/harness/agent-workflow/errors.d.ts +26 -0
- package/dist/src/harness/agent-workflow/errors.js +67 -0
- package/dist/src/harness/agent-workflow/types.d.ts +88 -0
- package/dist/src/harness/agent-workflow/types.js +1 -0
- package/dist/src/harness/agent-workflow.d.ts +79 -0
- package/dist/src/harness/agent-workflow.js +824 -0
- package/dist/src/harness/agent.d.ts +137 -0
- package/dist/src/harness/agent.js +272 -0
- package/dist/src/harness/code-mode/activity-fs.d.ts +46 -0
- package/dist/src/harness/code-mode/activity-fs.js +102 -0
- package/dist/src/harness/code-mode/base64.d.ts +2 -0
- package/dist/src/harness/code-mode/base64.js +41 -0
- package/dist/src/harness/code-mode/driver.d.ts +78 -0
- package/dist/src/harness/code-mode/driver.js +266 -0
- package/dist/src/harness/code-mode/index.d.ts +4 -0
- package/dist/src/harness/code-mode/index.js +6 -0
- package/dist/src/harness/code-mode/mount-decl.d.ts +43 -0
- package/dist/src/harness/code-mode/mount-decl.js +100 -0
- package/dist/src/harness/code-mode/okf-tool.d.ts +10 -0
- package/dist/src/harness/code-mode/okf-tool.js +89 -0
- package/dist/src/harness/code-mode/okf.d.ts +91 -0
- package/dist/src/harness/code-mode/okf.js +315 -0
- package/dist/src/harness/code-mode/stubs.d.ts +13 -0
- package/dist/src/harness/code-mode/stubs.js +203 -0
- package/dist/src/harness/code-mode/texts.d.ts +4 -0
- package/dist/src/harness/code-mode/texts.js +6 -0
- package/dist/src/harness/code-mode/tool.d.ts +37 -0
- package/dist/src/harness/code-mode/tool.js +79 -0
- package/dist/src/harness/code-mode/vfs.d.ts +209 -0
- package/dist/src/harness/code-mode/vfs.js +862 -0
- package/dist/src/harness/py-repr.d.ts +2 -0
- package/dist/src/harness/py-repr.js +24 -0
- package/dist/src/harness/state/index.d.ts +40 -0
- package/dist/src/harness/state/index.js +142 -0
- package/dist/src/harness/stream-context.d.ts +8 -0
- package/dist/src/harness/stream-context.js +9 -0
- package/dist/src/harness/subagent-toolset.d.ts +29 -0
- package/dist/src/harness/subagent-toolset.js +87 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +13 -0
- package/dist/src/sandbox.d.ts +12 -0
- package/package.json +95 -3
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/// <reference path="../../sandbox.d.ts" />
|
|
2
|
+
// The Code Mode virtual filesystem: mounts a Python script reads and writes with `open()` and
|
|
3
|
+
// `pathlib`. A `Mount` puts a `FileSystem` backend at a virtual path. Backend methods are async and
|
|
4
|
+
// run in workflow code, so a backend can reach storage through an activity, a Nexus operation or a
|
|
5
|
+
// child workflow, or hold its files in workflow memory like `InMemoryFileSystem`. Nothing here
|
|
6
|
+
// touches the worker's disk: workflow tasks may land on any worker, and whatever a script reads
|
|
7
|
+
// during a step is read again on replay.
|
|
8
|
+
//
|
|
9
|
+
// Every file operation a script makes ends its step (see worker.ts). The driver maps Monty's
|
|
10
|
+
// operation onto backend calls (`performFileOp`), each made through `runner.runTool` as one of the
|
|
11
|
+
// `fs_*` tools below, so file access gets the agent's approval policy and tool lifecycle events
|
|
12
|
+
// like any host call.
|
|
13
|
+
//
|
|
14
|
+
// Workflow-safe.
|
|
15
|
+
import { ApplicationFailure, inWorkflowContext, workflowInfo } from "@temporalio/workflow";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { injected, tool } from "../agent.js";
|
|
18
|
+
import { decodeBase64, encodeBase64 } from "./base64.js";
|
|
19
|
+
const WRITE_METHODS = ["write", "mkdir", "delete", "rename"];
|
|
20
|
+
export const ROOT = ".";
|
|
21
|
+
// ---------------------------------------------------------------- paths
|
|
22
|
+
/** `path` with "." and ".." collapsed and no trailing slash; null if ".." climbs out of the root. */
|
|
23
|
+
function normalize(path) {
|
|
24
|
+
const parts = [];
|
|
25
|
+
for (const part of path.split("/")) {
|
|
26
|
+
if (part === "" || part === ".")
|
|
27
|
+
continue;
|
|
28
|
+
if (part === "..") {
|
|
29
|
+
if (parts.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
parts.pop();
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
parts.push(part);
|
|
35
|
+
}
|
|
36
|
+
return (path.startsWith("/") ? "/" : "") + parts.join("/");
|
|
37
|
+
}
|
|
38
|
+
const parentOf = (path) => path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ROOT;
|
|
39
|
+
const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
40
|
+
/** Every directory above `path`, outermost first, the mount's root left out. */
|
|
41
|
+
function ancestors(path) {
|
|
42
|
+
const parts = path.split("/");
|
|
43
|
+
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------- errors
|
|
46
|
+
const STRERROR = {
|
|
47
|
+
1: "Operation not permitted",
|
|
48
|
+
2: "No such file or directory",
|
|
49
|
+
13: "Permission denied",
|
|
50
|
+
17: "File exists",
|
|
51
|
+
18: "Invalid cross-device link",
|
|
52
|
+
20: "Not a directory",
|
|
53
|
+
21: "Is a directory",
|
|
54
|
+
22: "Invalid argument",
|
|
55
|
+
28: "No space left on device",
|
|
56
|
+
39: "Directory not empty",
|
|
57
|
+
};
|
|
58
|
+
const ERRNO = {
|
|
59
|
+
EPERM: 1,
|
|
60
|
+
ENOENT: 2,
|
|
61
|
+
EACCES: 13,
|
|
62
|
+
EEXIST: 17,
|
|
63
|
+
EXDEV: 18,
|
|
64
|
+
ENOTDIR: 20,
|
|
65
|
+
EISDIR: 21,
|
|
66
|
+
EINVAL: 22,
|
|
67
|
+
ENOSPC: 28,
|
|
68
|
+
ENOTEMPTY: 39,
|
|
69
|
+
};
|
|
70
|
+
/** A failure the script sees as the built-in `type`, with Python's message for `errno` and `path`. */
|
|
71
|
+
export function osError(type, errno, path, strerror = STRERROR[errno] ?? "Error") {
|
|
72
|
+
return ApplicationFailure.create({
|
|
73
|
+
type,
|
|
74
|
+
nonRetryable: true,
|
|
75
|
+
message: `[Errno ${errno}] ${strerror}: '${path}'`,
|
|
76
|
+
details: [{ errno, strerror, path }],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* An OS error thrown by activity filesystem code (disk, a walk). The activity wrapper turns it
|
|
81
|
+
* into an `ApplicationFailure` the script sees as the built-in of the same name.
|
|
82
|
+
*/
|
|
83
|
+
export class FsError extends Error {
|
|
84
|
+
fsType;
|
|
85
|
+
errno;
|
|
86
|
+
strerror;
|
|
87
|
+
path;
|
|
88
|
+
constructor(fsType, errno, strerror, path) {
|
|
89
|
+
super(strerror);
|
|
90
|
+
this.name = fsType;
|
|
91
|
+
this.fsType = fsType;
|
|
92
|
+
this.errno = errno;
|
|
93
|
+
this.strerror = strerror;
|
|
94
|
+
this.path = path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const OS_ERRORS = new Set([
|
|
98
|
+
"OSError",
|
|
99
|
+
"FileNotFoundError",
|
|
100
|
+
"FileExistsError",
|
|
101
|
+
"IsADirectoryError",
|
|
102
|
+
"NotADirectoryError",
|
|
103
|
+
"PermissionError",
|
|
104
|
+
]);
|
|
105
|
+
// ---------------------------------------------------------------- mounts
|
|
106
|
+
/**
|
|
107
|
+
* A `FileSystem` at a virtual path inside the sandbox. `path` is absolute and normalized
|
|
108
|
+
* (`/skills`, not `/skills/`). `readOnly` is enforced before any backend call. `description` is
|
|
109
|
+
* shown to the model next to the mount, so say what is there and how to use it.
|
|
110
|
+
*/
|
|
111
|
+
export class Mount {
|
|
112
|
+
path;
|
|
113
|
+
backend;
|
|
114
|
+
readOnly;
|
|
115
|
+
description;
|
|
116
|
+
/** Set for an activity-backed mount, so successful operations update its FileIndex. */
|
|
117
|
+
tracked;
|
|
118
|
+
constructor(path, backend, { readOnly = false, description = "", tracked = null, } = {}) {
|
|
119
|
+
if (!path.startsWith("/") || normalize(path) !== path)
|
|
120
|
+
throw new TypeError(`mount path '${path}' must be an absolute, normalized POSIX path, like '/workspace'`);
|
|
121
|
+
if (path === "/")
|
|
122
|
+
throw new TypeError("a mount cannot be the root '/'; mount at a named directory");
|
|
123
|
+
for (const method of ["stat", "read", "list"]) {
|
|
124
|
+
if (typeof backend[method] !== "function")
|
|
125
|
+
throw new TypeError(`mount '${path}': the backend has no ${method}() method, so it is not a FileSystem`);
|
|
126
|
+
}
|
|
127
|
+
if (!readOnly) {
|
|
128
|
+
const missing = WRITE_METHODS.filter((m) => typeof backend[m] !== "function");
|
|
129
|
+
if (missing.length)
|
|
130
|
+
throw new TypeError(`mount '${path}' is writable, but its backend has no ${missing.join("(), ")}() method. Implement them, or mount it with readOnly: true.`);
|
|
131
|
+
}
|
|
132
|
+
this.path = path;
|
|
133
|
+
this.backend = backend;
|
|
134
|
+
this.readOnly = readOnly;
|
|
135
|
+
this.description = description;
|
|
136
|
+
this.tracked = tracked;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Reject mounts that repeat or nest, so every path belongs to at most one mount. */
|
|
140
|
+
export function validateMounts(mounts) {
|
|
141
|
+
const ordered = [...mounts].sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
142
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
143
|
+
const [outer, inner] = [ordered[i - 1], ordered[i]];
|
|
144
|
+
if (inner.path === outer.path || inner.path.startsWith(`${outer.path}/`))
|
|
145
|
+
throw new TypeError(`mounts '${outer.path}' and '${inner.path}' overlap`);
|
|
146
|
+
}
|
|
147
|
+
return mounts;
|
|
148
|
+
}
|
|
149
|
+
/** The mount `path` falls in and the path relative to it, matching whole segments; null outside every mount. */
|
|
150
|
+
export function route(mounts, path) {
|
|
151
|
+
for (const mount of mounts) {
|
|
152
|
+
if (path === mount.path)
|
|
153
|
+
return { mount, relative: ROOT };
|
|
154
|
+
if (path.startsWith(`${mount.path}/`))
|
|
155
|
+
return { mount, relative: path.slice(mount.path.length + 1) };
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------- the operation a step stopped at
|
|
160
|
+
/** Monty's file operations. Every other OS call (clock, entropy, environment) is not a file one. */
|
|
161
|
+
export const FILE_OPERATIONS = new Set([
|
|
162
|
+
"open",
|
|
163
|
+
"Path.exists",
|
|
164
|
+
"Path.is_file",
|
|
165
|
+
"Path.is_dir",
|
|
166
|
+
"Path.is_symlink",
|
|
167
|
+
"Path.read_text",
|
|
168
|
+
"Path.read_bytes",
|
|
169
|
+
"Path.write_text",
|
|
170
|
+
"Path.write_bytes",
|
|
171
|
+
"Path.append_text",
|
|
172
|
+
"Path.append_bytes",
|
|
173
|
+
"Path.mkdir",
|
|
174
|
+
"Path.unlink",
|
|
175
|
+
"Path.rmdir",
|
|
176
|
+
"Path.iterdir",
|
|
177
|
+
"Path.stat",
|
|
178
|
+
"Path.rename",
|
|
179
|
+
"Path.resolve",
|
|
180
|
+
"Path.absolute",
|
|
181
|
+
]);
|
|
182
|
+
const WRITE_OPERATIONS = new Set([
|
|
183
|
+
"Path.write_text",
|
|
184
|
+
"Path.write_bytes",
|
|
185
|
+
"Path.append_text",
|
|
186
|
+
"Path.append_bytes",
|
|
187
|
+
"Path.mkdir",
|
|
188
|
+
"Path.unlink",
|
|
189
|
+
"Path.rmdir",
|
|
190
|
+
"Path.rename",
|
|
191
|
+
]);
|
|
192
|
+
/** An `open()`'s mode, passed by position or as `mode=`. */
|
|
193
|
+
function openMode({ args, kwargs }) {
|
|
194
|
+
const mode = kwargs.mode ?? args[0];
|
|
195
|
+
return typeof mode === "string" ? mode : "r";
|
|
196
|
+
}
|
|
197
|
+
function isWrite(op) {
|
|
198
|
+
if (op.operation === "open") {
|
|
199
|
+
const mode = openMode(op);
|
|
200
|
+
return mode[0] !== "r" || mode.includes("+");
|
|
201
|
+
}
|
|
202
|
+
return WRITE_OPERATIONS.has(op.operation);
|
|
203
|
+
}
|
|
204
|
+
export const encodeBytes = (data) => ({
|
|
205
|
+
__monty__: "bytes",
|
|
206
|
+
base64: encodeBase64(data),
|
|
207
|
+
});
|
|
208
|
+
const isEncodedBytes = (v) => typeof v === "object" && v !== null && v.__monty__ === "bytes";
|
|
209
|
+
const utf8 = new TextEncoder();
|
|
210
|
+
const fromUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
211
|
+
const toBytes = (data) => isEncodedBytes(data) ? decodeBase64(data.base64) : utf8.encode(String(data));
|
|
212
|
+
const dataLength = (data) => isEncodedBytes(data) ? decodeBase64(data.base64).length : [...String(data)].length;
|
|
213
|
+
// ---------------------------------------------------------------- the fs_* tools
|
|
214
|
+
const FsInput = z.object({ mount: z.string(), path: z.string() });
|
|
215
|
+
const backendInjection = { backend: injected() };
|
|
216
|
+
const fsSeed = tool({
|
|
217
|
+
name: "fs_seed",
|
|
218
|
+
description: "Load an in-memory filesystem's initial files, before the first operation on its mount.",
|
|
219
|
+
inputSchema: z.object({ mount: z.string() }),
|
|
220
|
+
injected: backendInjection,
|
|
221
|
+
execute: async (_input, { backend }) => {
|
|
222
|
+
const [count, size] = await backend.seedNow();
|
|
223
|
+
return `seeded ${count} files (${size} bytes)`;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
const fsStat = tool({
|
|
227
|
+
name: "fs_stat",
|
|
228
|
+
description: "Look up a path's metadata in a Code Mode filesystem mount.",
|
|
229
|
+
inputSchema: FsInput,
|
|
230
|
+
injected: backendInjection,
|
|
231
|
+
execute: async ({ path }, { backend }) => backend.stat(path),
|
|
232
|
+
});
|
|
233
|
+
const fsRead = tool({
|
|
234
|
+
name: "fs_read",
|
|
235
|
+
description: "Read a file from a Code Mode filesystem mount.",
|
|
236
|
+
inputSchema: FsInput,
|
|
237
|
+
injected: backendInjection,
|
|
238
|
+
execute: async ({ path }, { backend }) => {
|
|
239
|
+
// Text reads as text in the tool's events; anything else crosses as base64.
|
|
240
|
+
const data = await backend.read(path);
|
|
241
|
+
try {
|
|
242
|
+
return fromUtf8.decode(data);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return encodeBytes(data);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const fsList = tool({
|
|
250
|
+
name: "fs_list",
|
|
251
|
+
description: "List a directory in a Code Mode filesystem mount.",
|
|
252
|
+
inputSchema: FsInput,
|
|
253
|
+
injected: backendInjection,
|
|
254
|
+
execute: async ({ path }, { backend }) => backend.list(path),
|
|
255
|
+
});
|
|
256
|
+
const fsWrite = tool({
|
|
257
|
+
name: "fs_write",
|
|
258
|
+
description: "Write a file in a Code Mode filesystem mount, replacing any existing content. Binary content arrives base64-encoded.",
|
|
259
|
+
inputSchema: FsInput.extend({
|
|
260
|
+
content: z.string(),
|
|
261
|
+
encoding: z.enum(["utf-8", "base64"]).default("utf-8"),
|
|
262
|
+
}),
|
|
263
|
+
injected: backendInjection,
|
|
264
|
+
execute: async ({ path, content, encoding }, { backend }) => {
|
|
265
|
+
const data = encoding === "base64" ? decodeBase64(content) : utf8.encode(content);
|
|
266
|
+
await backend.write(path, data);
|
|
267
|
+
return data.length;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const fsMkdir = tool({
|
|
271
|
+
name: "fs_mkdir",
|
|
272
|
+
description: "Create a directory in a Code Mode filesystem mount.",
|
|
273
|
+
inputSchema: FsInput,
|
|
274
|
+
injected: backendInjection,
|
|
275
|
+
execute: async ({ path }, { backend }) => {
|
|
276
|
+
await backend.mkdir(path);
|
|
277
|
+
return null;
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const fsDelete = tool({
|
|
281
|
+
name: "fs_delete",
|
|
282
|
+
description: "Delete a file or an empty directory in a Code Mode filesystem mount.",
|
|
283
|
+
inputSchema: FsInput,
|
|
284
|
+
injected: backendInjection,
|
|
285
|
+
execute: async ({ path }, { backend }) => {
|
|
286
|
+
await backend.delete(path);
|
|
287
|
+
return null;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const fsRename = tool({
|
|
291
|
+
name: "fs_rename",
|
|
292
|
+
description: "Move a file or directory to another path in the same Code Mode filesystem mount.",
|
|
293
|
+
inputSchema: FsInput.extend({ target: z.string() }),
|
|
294
|
+
injected: backendInjection,
|
|
295
|
+
execute: async ({ path, target }, { backend }) => {
|
|
296
|
+
await backend.rename(path, target);
|
|
297
|
+
return null;
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
/** A mount's backend as a `FileSystem` whose every call is an fs_* tool call. */
|
|
301
|
+
function toolCalls(mount, run) {
|
|
302
|
+
const m = mount.path;
|
|
303
|
+
const track = mount.tracked;
|
|
304
|
+
const call = (t, input) => run(t, input, mount.backend);
|
|
305
|
+
return {
|
|
306
|
+
stat: async (path) => {
|
|
307
|
+
const found = (await call(fsStat, { mount: m, path }));
|
|
308
|
+
track?.noteStat(path, found);
|
|
309
|
+
return found;
|
|
310
|
+
},
|
|
311
|
+
read: async (path) => {
|
|
312
|
+
const data = toBytes(await call(fsRead, { mount: m, path }));
|
|
313
|
+
track?.noteRead(path, data.length);
|
|
314
|
+
return data;
|
|
315
|
+
},
|
|
316
|
+
list: async (path) => {
|
|
317
|
+
const names = (await call(fsList, { mount: m, path }));
|
|
318
|
+
track?.noteList(path, names);
|
|
319
|
+
return names;
|
|
320
|
+
},
|
|
321
|
+
write: async (path, data) => {
|
|
322
|
+
let content;
|
|
323
|
+
let encoding = "utf-8";
|
|
324
|
+
try {
|
|
325
|
+
content = fromUtf8.decode(data);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
[content, encoding] = [encodeBase64(data), "base64"];
|
|
329
|
+
}
|
|
330
|
+
await call(fsWrite, { mount: m, path, content, encoding });
|
|
331
|
+
track?.noteWrite(path, data.length);
|
|
332
|
+
},
|
|
333
|
+
mkdir: async (path) => {
|
|
334
|
+
await call(fsMkdir, { mount: m, path });
|
|
335
|
+
track?.noteMkdir(path);
|
|
336
|
+
},
|
|
337
|
+
delete: async (path) => {
|
|
338
|
+
await call(fsDelete, { mount: m, path });
|
|
339
|
+
track?.noteRemove(path);
|
|
340
|
+
},
|
|
341
|
+
rename: async (path, target) => {
|
|
342
|
+
await call(fsRename, { mount: m, path, target });
|
|
343
|
+
track?.noteRename(path, target);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// ---------------------------------------------------------------- carrying out an operation
|
|
348
|
+
/**
|
|
349
|
+
* What the script's file operation returns (encoded for the worker), or the failure it raises.
|
|
350
|
+
* Routing, read-only mounts and renames across mounts are decided here, before any backend call.
|
|
351
|
+
* A backend's error that names a mount-relative path is raised naming the virtual path instead.
|
|
352
|
+
*/
|
|
353
|
+
export async function performFileOp(mounts, op, call) {
|
|
354
|
+
const virtual = normalize(op.path);
|
|
355
|
+
const routed = virtual?.startsWith("/") ? route(mounts, virtual) : null;
|
|
356
|
+
if (virtual === null || routed === null)
|
|
357
|
+
return { __monty__: "not_handled" };
|
|
358
|
+
const { mount, relative } = routed;
|
|
359
|
+
if (op.operation === "Path.is_symlink")
|
|
360
|
+
return false;
|
|
361
|
+
if (op.operation === "Path.resolve" || op.operation === "Path.absolute")
|
|
362
|
+
return virtual;
|
|
363
|
+
if (mount.readOnly && isWrite(op))
|
|
364
|
+
throw osError("PermissionError", ERRNO.EACCES, virtual);
|
|
365
|
+
let target = null;
|
|
366
|
+
if (op.operation === "Path.rename") {
|
|
367
|
+
const destination = normalize(String(op.args[0]));
|
|
368
|
+
const routedTarget = destination === null ? null : route(mounts, destination);
|
|
369
|
+
if (routedTarget === null || routedTarget.mount !== mount)
|
|
370
|
+
throw osError("OSError", ERRNO.EXDEV, destination ?? String(op.args[0]));
|
|
371
|
+
target = routedTarget.relative;
|
|
372
|
+
}
|
|
373
|
+
const fs = toolCalls(mount, async (t, input, backend) => {
|
|
374
|
+
if (backend instanceof InMemoryFileSystem && backend.needsSeed)
|
|
375
|
+
await call(fsSeed, { mount: mount.path }, backend);
|
|
376
|
+
return call(t, input, backend);
|
|
377
|
+
});
|
|
378
|
+
try {
|
|
379
|
+
return await perform(op, fs, relative, virtual, target);
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
const detail = e instanceof ApplicationFailure && OS_ERRORS.has(e.type ?? "")
|
|
383
|
+
? e.details?.[0]
|
|
384
|
+
: undefined;
|
|
385
|
+
if (detail && !detail.path.startsWith("/"))
|
|
386
|
+
throw osError(e instanceof ApplicationFailure ? e.type : "OSError", detail.errno, `${mount.path}/${detail.path}`.replace(/\/\.$/, ""), detail.strerror);
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function perform(op, fs, path, virtual, target) {
|
|
391
|
+
const name = op.operation;
|
|
392
|
+
const existing = async () => {
|
|
393
|
+
const found = await fs.stat(path);
|
|
394
|
+
if (found === null)
|
|
395
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, virtual);
|
|
396
|
+
return found;
|
|
397
|
+
};
|
|
398
|
+
switch (name) {
|
|
399
|
+
case "Path.exists":
|
|
400
|
+
return (await fs.stat(path)) !== null;
|
|
401
|
+
case "Path.is_file":
|
|
402
|
+
case "Path.is_dir": {
|
|
403
|
+
const found = await fs.stat(path);
|
|
404
|
+
return found !== null && found.is_dir === (name === "Path.is_dir");
|
|
405
|
+
}
|
|
406
|
+
case "Path.stat":
|
|
407
|
+
throw ApplicationFailure.create({
|
|
408
|
+
type: "NotImplementedError",
|
|
409
|
+
nonRetryable: true,
|
|
410
|
+
message: "Path.stat() is not available in this sandbox; use Path.exists(), Path.is_file() or Path.is_dir()",
|
|
411
|
+
});
|
|
412
|
+
case "open": {
|
|
413
|
+
const mode = openMode(op);
|
|
414
|
+
const found = await fs.stat(path);
|
|
415
|
+
if (mode[0] === "x" && found !== null)
|
|
416
|
+
throw osError("FileExistsError", ERRNO.EEXIST, virtual);
|
|
417
|
+
if (found?.is_dir)
|
|
418
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, virtual);
|
|
419
|
+
if (mode[0] === "r" && found === null)
|
|
420
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, virtual);
|
|
421
|
+
if (mode[0] === "w" || ((mode[0] === "a" || mode[0] === "x") && found === null))
|
|
422
|
+
await fs.write(path, new Uint8Array());
|
|
423
|
+
return { __monty__: "handle", path: virtual, mode };
|
|
424
|
+
}
|
|
425
|
+
case "Path.read_text":
|
|
426
|
+
case "Path.read_bytes": {
|
|
427
|
+
if ((await existing()).is_dir)
|
|
428
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, virtual);
|
|
429
|
+
const data = await fs.read(path);
|
|
430
|
+
return name === "Path.read_text" ? new TextDecoder().decode(data) : encodeBytes(data);
|
|
431
|
+
}
|
|
432
|
+
case "Path.write_text":
|
|
433
|
+
case "Path.write_bytes":
|
|
434
|
+
await fs.write(path, toBytes(op.args[0]));
|
|
435
|
+
return dataLength(op.args[0]);
|
|
436
|
+
case "Path.append_text":
|
|
437
|
+
case "Path.append_bytes": {
|
|
438
|
+
const found = await fs.stat(path);
|
|
439
|
+
if (found?.is_dir)
|
|
440
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, virtual);
|
|
441
|
+
const before = found !== null ? await fs.read(path) : new Uint8Array();
|
|
442
|
+
const added = toBytes(op.args[0]);
|
|
443
|
+
const joined = new Uint8Array(before.length + added.length);
|
|
444
|
+
joined.set(before);
|
|
445
|
+
joined.set(added, before.length);
|
|
446
|
+
await fs.write(path, joined);
|
|
447
|
+
return dataLength(op.args[0]);
|
|
448
|
+
}
|
|
449
|
+
case "Path.iterdir": {
|
|
450
|
+
if (!(await existing()).is_dir)
|
|
451
|
+
throw osError("NotADirectoryError", ERRNO.ENOTDIR, virtual);
|
|
452
|
+
return [...(await fs.list(path))].sort().map((entry) => `${virtual}/${entry}`);
|
|
453
|
+
}
|
|
454
|
+
case "Path.mkdir": {
|
|
455
|
+
const found = await fs.stat(path);
|
|
456
|
+
if (found !== null) {
|
|
457
|
+
if (op.kwargs.exist_ok && found.is_dir)
|
|
458
|
+
return null;
|
|
459
|
+
throw osError("FileExistsError", ERRNO.EEXIST, virtual);
|
|
460
|
+
}
|
|
461
|
+
const missing = [];
|
|
462
|
+
for (const ancestor of ancestors(path)) {
|
|
463
|
+
const above = await fs.stat(ancestor);
|
|
464
|
+
if (above === null) {
|
|
465
|
+
if (!op.kwargs.parents)
|
|
466
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, virtual);
|
|
467
|
+
missing.push(ancestor);
|
|
468
|
+
}
|
|
469
|
+
else if (!above.is_dir)
|
|
470
|
+
throw osError("NotADirectoryError", ERRNO.ENOTDIR, virtual);
|
|
471
|
+
}
|
|
472
|
+
for (const directory of [...missing, path])
|
|
473
|
+
await fs.mkdir(directory);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
case "Path.unlink":
|
|
477
|
+
if ((await existing()).is_dir)
|
|
478
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, virtual);
|
|
479
|
+
await fs.delete(path);
|
|
480
|
+
return null;
|
|
481
|
+
case "Path.rmdir":
|
|
482
|
+
if (path === ROOT)
|
|
483
|
+
throw osError("PermissionError", ERRNO.EACCES, virtual);
|
|
484
|
+
if (!(await existing()).is_dir)
|
|
485
|
+
throw osError("NotADirectoryError", ERRNO.ENOTDIR, virtual);
|
|
486
|
+
if ((await fs.list(path)).length)
|
|
487
|
+
throw osError("OSError", ERRNO.ENOTEMPTY, virtual);
|
|
488
|
+
await fs.delete(path);
|
|
489
|
+
return null;
|
|
490
|
+
case "Path.rename":
|
|
491
|
+
await existing();
|
|
492
|
+
await fs.rename(path, target);
|
|
493
|
+
return null;
|
|
494
|
+
default:
|
|
495
|
+
return { __monty__: "not_handled" };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ---------------------------------------------------------------- the in-memory backend
|
|
499
|
+
const FileEntry = z.object({
|
|
500
|
+
content: z.string(),
|
|
501
|
+
encoding: z.enum(["utf-8", "base64"]).default("utf-8"),
|
|
502
|
+
size: z.number().int(),
|
|
503
|
+
});
|
|
504
|
+
/**
|
|
505
|
+
* An `InMemoryFileSystem`'s files as agent state: `files` keyed by mount-relative path
|
|
506
|
+
* (`"notes/todo.md"`), and `directories` lists every directory except the mount's root. Declare
|
|
507
|
+
* it with `states: { workspace: state(FileTree) }` and pass the ref as
|
|
508
|
+
* `new InMemoryFileSystem({ state })`: the files then live in the state, and every change to them
|
|
509
|
+
* is published as a state patch.
|
|
510
|
+
*/
|
|
511
|
+
export const FileTree = z
|
|
512
|
+
.object({
|
|
513
|
+
files: z.record(z.string(), FileEntry).default({}),
|
|
514
|
+
directories: z.array(z.string()).default([]),
|
|
515
|
+
})
|
|
516
|
+
.meta({ title: "FileTree" });
|
|
517
|
+
function entry(data) {
|
|
518
|
+
try {
|
|
519
|
+
return { content: fromUtf8.decode(data), encoding: "utf-8", size: data.length };
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return { content: encodeBase64(data), encoding: "base64", size: data.length };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const content = (e) => e.encoding === "base64" ? decodeBase64(e.content) : utf8.encode(e.content);
|
|
526
|
+
function memoryStore() {
|
|
527
|
+
const files = new Map();
|
|
528
|
+
const dirs = new Set();
|
|
529
|
+
return {
|
|
530
|
+
content: (path) => files.get(path) ?? null,
|
|
531
|
+
size: (path) => files.get(path)?.length ?? null,
|
|
532
|
+
isDir: (path) => path === ROOT || dirs.has(path),
|
|
533
|
+
paths: () => [...dirs, ...files.keys()],
|
|
534
|
+
total: () => [...files.values()].reduce((n, d) => n + d.length, 0),
|
|
535
|
+
apply({ drop = [], rmdirs = [], mkdirs = [], put = new Map() }) {
|
|
536
|
+
for (const path of drop)
|
|
537
|
+
files.delete(path);
|
|
538
|
+
for (const path of rmdirs)
|
|
539
|
+
dirs.delete(path);
|
|
540
|
+
for (const path of mkdirs)
|
|
541
|
+
dirs.add(path);
|
|
542
|
+
for (const [path, data] of put)
|
|
543
|
+
files.set(path, data);
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function stateStore(ref) {
|
|
548
|
+
return {
|
|
549
|
+
content: (path) => {
|
|
550
|
+
const e = ref.current.files[path];
|
|
551
|
+
return e ? content(e) : null;
|
|
552
|
+
},
|
|
553
|
+
size: (path) => ref.current.files[path]?.size ?? null,
|
|
554
|
+
isDir: (path) => path === ROOT || ref.current.directories.includes(path),
|
|
555
|
+
paths: () => [...ref.current.directories, ...Object.keys(ref.current.files)],
|
|
556
|
+
total: () => Object.values(ref.current.files).reduce((n, e) => n + e.size, 0),
|
|
557
|
+
apply({ drop = [], rmdirs = [], mkdirs = [], put = new Map() }) {
|
|
558
|
+
ref.mutate((draft) => {
|
|
559
|
+
for (const path of drop)
|
|
560
|
+
delete draft.files[path];
|
|
561
|
+
if (rmdirs.length)
|
|
562
|
+
draft.directories = draft.directories.filter((d) => !rmdirs.includes(d));
|
|
563
|
+
draft.directories.push(...mkdirs);
|
|
564
|
+
for (const [path, data] of put)
|
|
565
|
+
draft.files[path] = entry(data);
|
|
566
|
+
});
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A `FileSystem` held in workflow memory. Build it in `create`, so each workflow gets its own; its
|
|
572
|
+
* files then last across the run's scripts, and replay rebuilds them. A continue-as-new carries
|
|
573
|
+
* only declared states, so without `state` the next run starts empty and seeds again.
|
|
574
|
+
*
|
|
575
|
+
* `seed` runs in workflow code, once, before the first operation on the mount, and returns the
|
|
576
|
+
* initial files. Load files from disk inside an activity the seed awaits: the activity's result is
|
|
577
|
+
* recorded, so a running workflow keeps the files it seeded even after a deploy changes them.
|
|
578
|
+
* `maxBytes` caps the total size of the files, seeded ones included.
|
|
579
|
+
*
|
|
580
|
+
* `state` (opt-in) keeps the files in a declared `FileTree` state instead, so every change is
|
|
581
|
+
* published as a state patch the UI can show. A patch carries each written file whole, and anyone
|
|
582
|
+
* who can view the session sees the files, so track only filesystems whose files are worth
|
|
583
|
+
* streaming to every viewer.
|
|
584
|
+
*/
|
|
585
|
+
export class InMemoryFileSystem {
|
|
586
|
+
#seed;
|
|
587
|
+
#seeded;
|
|
588
|
+
#maxBytes;
|
|
589
|
+
#store;
|
|
590
|
+
constructor({ seed, maxBytes, state, } = {}) {
|
|
591
|
+
this.#seed = seed;
|
|
592
|
+
this.#seeded = seed === undefined;
|
|
593
|
+
this.#maxBytes = maxBytes;
|
|
594
|
+
this.#store = state ? stateStore(state) : memoryStore();
|
|
595
|
+
}
|
|
596
|
+
get needsSeed() {
|
|
597
|
+
return !this.#seeded;
|
|
598
|
+
}
|
|
599
|
+
/** Run the seed and load its files, as one change. Called by the fs_seed tool. */
|
|
600
|
+
async seedNow() {
|
|
601
|
+
const files = await this.#seed();
|
|
602
|
+
const mkdirs = [];
|
|
603
|
+
const put = new Map();
|
|
604
|
+
for (const [raw, data] of Object.entries(files)) {
|
|
605
|
+
const path = normalize(raw);
|
|
606
|
+
if (path === null || path.startsWith("/") || path === "")
|
|
607
|
+
throw new TypeError(`seeded path '${raw}' must name a file inside the mount`);
|
|
608
|
+
for (const ancestor of ancestors(path))
|
|
609
|
+
if (!this.#store.isDir(ancestor) && !mkdirs.includes(ancestor))
|
|
610
|
+
mkdirs.push(ancestor);
|
|
611
|
+
put.set(path, typeof data === "string" ? utf8.encode(data) : data);
|
|
612
|
+
}
|
|
613
|
+
this.#checkBudget(put);
|
|
614
|
+
this.#store.apply({ mkdirs, put });
|
|
615
|
+
this.#seeded = true;
|
|
616
|
+
return [put.size, [...put.values()].reduce((n, d) => n + d.length, 0)];
|
|
617
|
+
}
|
|
618
|
+
#checkBudget(put) {
|
|
619
|
+
if (this.#maxBytes === undefined)
|
|
620
|
+
return;
|
|
621
|
+
const replaced = [...put.keys()].reduce((n, p) => n + (this.#store.size(p) ?? 0), 0);
|
|
622
|
+
const total = this.#store.total() - replaced + [...put.values()].reduce((n, d) => n + d.length, 0);
|
|
623
|
+
if (total > this.#maxBytes) {
|
|
624
|
+
throw osError("OSError", ERRNO.ENOSPC, put.keys().next().value, `No space left on device: the filesystem is limited to ${this.#maxBytes} bytes`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async stat(path) {
|
|
628
|
+
if (this.#store.isDir(path))
|
|
629
|
+
return { is_dir: true };
|
|
630
|
+
const size = this.#store.size(path);
|
|
631
|
+
return size === null ? null : { is_dir: false, size };
|
|
632
|
+
}
|
|
633
|
+
async read(path) {
|
|
634
|
+
if (this.#store.isDir(path))
|
|
635
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, path);
|
|
636
|
+
const data = this.#store.content(path);
|
|
637
|
+
if (data === null)
|
|
638
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, path);
|
|
639
|
+
return data;
|
|
640
|
+
}
|
|
641
|
+
async list(path) {
|
|
642
|
+
if (!this.#store.isDir(path))
|
|
643
|
+
throw osError("NotADirectoryError", ERRNO.ENOTDIR, path);
|
|
644
|
+
return this.#store
|
|
645
|
+
.paths()
|
|
646
|
+
.filter((p) => p !== path && parentOf(p) === path)
|
|
647
|
+
.map(nameOf)
|
|
648
|
+
.sort();
|
|
649
|
+
}
|
|
650
|
+
async write(path, data) {
|
|
651
|
+
if (this.#store.isDir(path))
|
|
652
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, path);
|
|
653
|
+
if (!this.#store.isDir(parentOf(path)))
|
|
654
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, path);
|
|
655
|
+
const put = new Map([[path, data]]);
|
|
656
|
+
this.#checkBudget(put);
|
|
657
|
+
this.#store.apply({ put });
|
|
658
|
+
}
|
|
659
|
+
async mkdir(path) {
|
|
660
|
+
if (this.#store.isDir(path) || this.#store.size(path) !== null)
|
|
661
|
+
throw osError("FileExistsError", ERRNO.EEXIST, path);
|
|
662
|
+
if (!this.#store.isDir(parentOf(path)))
|
|
663
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, path);
|
|
664
|
+
this.#store.apply({ mkdirs: [path] });
|
|
665
|
+
}
|
|
666
|
+
async delete(path) {
|
|
667
|
+
if (this.#store.size(path) !== null)
|
|
668
|
+
return this.#store.apply({ drop: [path] });
|
|
669
|
+
if (this.#store.isDir(path) && path !== ROOT) {
|
|
670
|
+
if ((await this.list(path)).length)
|
|
671
|
+
throw osError("OSError", ERRNO.ENOTEMPTY, path);
|
|
672
|
+
return this.#store.apply({ rmdirs: [path] });
|
|
673
|
+
}
|
|
674
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, path);
|
|
675
|
+
}
|
|
676
|
+
async rename(path, target) {
|
|
677
|
+
const store = this.#store;
|
|
678
|
+
if (!store.isDir(parentOf(target)))
|
|
679
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, target);
|
|
680
|
+
const data = store.content(path);
|
|
681
|
+
if (data !== null) {
|
|
682
|
+
if (store.isDir(target))
|
|
683
|
+
throw osError("IsADirectoryError", ERRNO.EISDIR, target);
|
|
684
|
+
return store.apply({ drop: [path], put: new Map([[target, data]]) });
|
|
685
|
+
}
|
|
686
|
+
if (!store.isDir(path) || path === ROOT)
|
|
687
|
+
throw osError("FileNotFoundError", ERRNO.ENOENT, path);
|
|
688
|
+
if (target === path || target.startsWith(`${path}/`))
|
|
689
|
+
throw osError("OSError", ERRNO.EINVAL, target);
|
|
690
|
+
if (store.size(target) !== null || (store.isDir(target) && (await this.list(target)).length))
|
|
691
|
+
throw osError("OSError", ERRNO.ENOTEMPTY, target);
|
|
692
|
+
const change = {
|
|
693
|
+
drop: [],
|
|
694
|
+
rmdirs: store.isDir(target) ? [target] : [],
|
|
695
|
+
mkdirs: [],
|
|
696
|
+
put: new Map(),
|
|
697
|
+
};
|
|
698
|
+
for (const old of store.paths()) {
|
|
699
|
+
if (old !== path && !old.startsWith(`${path}/`))
|
|
700
|
+
continue;
|
|
701
|
+
const moved = target + old.slice(path.length);
|
|
702
|
+
const bytes = store.content(old);
|
|
703
|
+
if (bytes === null) {
|
|
704
|
+
change.rmdirs.push(old);
|
|
705
|
+
change.mkdirs.push(moved);
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
change.drop.push(old);
|
|
709
|
+
change.put.set(moved, bytes);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
store.apply(change);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// ---------------------------------------------------------------- the file index
|
|
716
|
+
// What a session has seen of an activity-backed mount. Contents stay in the store; the index
|
|
717
|
+
// only records paths and metadata, and where a viewer can read the files later.
|
|
718
|
+
export const IndexEntry = z.object({
|
|
719
|
+
is_dir: z.boolean().nullable().default(null),
|
|
720
|
+
size: z.number().nullable().default(null),
|
|
721
|
+
mtime: z.number().nullable().default(null),
|
|
722
|
+
writes: z.number().int().default(0),
|
|
723
|
+
});
|
|
724
|
+
export const IndexSource = z.object({
|
|
725
|
+
filesystem: z.string(),
|
|
726
|
+
config: z.record(z.string(), z.unknown()),
|
|
727
|
+
task_queue: z.string(),
|
|
728
|
+
});
|
|
729
|
+
export const FileIndex = z
|
|
730
|
+
.object({
|
|
731
|
+
kind: z.literal("file_index").default("file_index"),
|
|
732
|
+
mount: z.string().default(""),
|
|
733
|
+
description: z.string().default(""),
|
|
734
|
+
read_only: z.boolean().default(false),
|
|
735
|
+
okf_version: z.string().nullable().default(null),
|
|
736
|
+
source: IndexSource.nullable().default(null),
|
|
737
|
+
entries: z.record(z.string(), IndexEntry).default({}),
|
|
738
|
+
})
|
|
739
|
+
.meta({ title: "FileIndex" });
|
|
740
|
+
const blank = () => ({ is_dir: null, size: null, mtime: null, writes: 0 });
|
|
741
|
+
const same = (a, b) => a !== undefined &&
|
|
742
|
+
a.is_dir === b.is_dir &&
|
|
743
|
+
a.size === b.size &&
|
|
744
|
+
a.mtime === b.mtime &&
|
|
745
|
+
a.writes === b.writes;
|
|
746
|
+
function put(entries, key, entry) {
|
|
747
|
+
if (!same(entries[key], entry))
|
|
748
|
+
entries[key] = entry;
|
|
749
|
+
}
|
|
750
|
+
function putDir(entries, path) {
|
|
751
|
+
if (path === "." || path === "")
|
|
752
|
+
return;
|
|
753
|
+
const parts = path.split("/");
|
|
754
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
755
|
+
const directory = parts.slice(0, i).join("/");
|
|
756
|
+
const found = entries[directory];
|
|
757
|
+
if (found === undefined || found.is_dir !== true)
|
|
758
|
+
put(entries, directory, { ...blank(), is_dir: true });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function drop(entries, path) {
|
|
762
|
+
for (const key of Object.keys(entries)) {
|
|
763
|
+
if (key === path || key.startsWith(`${path}/`))
|
|
764
|
+
delete entries[key];
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function putFile(entries, path, size, mtime) {
|
|
768
|
+
const known = entries[path];
|
|
769
|
+
putDir(entries, parentOf(path));
|
|
770
|
+
put(entries, path, {
|
|
771
|
+
is_dir: false,
|
|
772
|
+
size,
|
|
773
|
+
mtime,
|
|
774
|
+
writes: known?.writes ?? 0,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function inWorkflow() {
|
|
778
|
+
try {
|
|
779
|
+
return inWorkflowContext();
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
export function indexTrack(ref, source) {
|
|
786
|
+
const change = (recipe) => {
|
|
787
|
+
ref.mutate((index) => {
|
|
788
|
+
if (index.source === null && inWorkflow()) {
|
|
789
|
+
index.source = {
|
|
790
|
+
filesystem: source.filesystem,
|
|
791
|
+
config: source.config,
|
|
792
|
+
task_queue: workflowInfo().taskQueue,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
recipe(index.entries);
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
return {
|
|
799
|
+
noteStat(path, found) {
|
|
800
|
+
if (path === ".")
|
|
801
|
+
return;
|
|
802
|
+
change((entries) => {
|
|
803
|
+
if (found === null)
|
|
804
|
+
drop(entries, path);
|
|
805
|
+
else if (found.is_dir)
|
|
806
|
+
putDir(entries, path);
|
|
807
|
+
else
|
|
808
|
+
putFile(entries, path, found.size ?? 0, found.mtime ? found.mtime : null);
|
|
809
|
+
});
|
|
810
|
+
},
|
|
811
|
+
noteRead(path, size) {
|
|
812
|
+
change((entries) => {
|
|
813
|
+
const known = entries[path];
|
|
814
|
+
const mtime = known !== undefined && known.size === size ? known.mtime : null;
|
|
815
|
+
putFile(entries, path, size, mtime);
|
|
816
|
+
});
|
|
817
|
+
},
|
|
818
|
+
noteList(path, names) {
|
|
819
|
+
change((entries) => {
|
|
820
|
+
putDir(entries, path);
|
|
821
|
+
const listed = new Set(names.map((name) => (path === "." ? name : `${path}/${name}`)));
|
|
822
|
+
for (const key of Object.keys(entries)) {
|
|
823
|
+
if (parentOf(key) === path && !listed.has(key))
|
|
824
|
+
drop(entries, key);
|
|
825
|
+
}
|
|
826
|
+
for (const key of [...listed].sort())
|
|
827
|
+
if (!(key in entries))
|
|
828
|
+
entries[key] = blank();
|
|
829
|
+
});
|
|
830
|
+
},
|
|
831
|
+
noteWrite(path, size) {
|
|
832
|
+
change((entries) => {
|
|
833
|
+
const known = entries[path];
|
|
834
|
+
putDir(entries, parentOf(path));
|
|
835
|
+
entries[path] = {
|
|
836
|
+
is_dir: false,
|
|
837
|
+
size,
|
|
838
|
+
mtime: null,
|
|
839
|
+
writes: (known?.writes ?? 0) + 1,
|
|
840
|
+
};
|
|
841
|
+
});
|
|
842
|
+
},
|
|
843
|
+
noteMkdir(path) {
|
|
844
|
+
change((entries) => putDir(entries, path));
|
|
845
|
+
},
|
|
846
|
+
noteRemove(path) {
|
|
847
|
+
change((entries) => drop(entries, path));
|
|
848
|
+
},
|
|
849
|
+
noteRename(path, target) {
|
|
850
|
+
change((entries) => {
|
|
851
|
+
const moving = Object.entries(entries).filter(([key]) => key === path || key.startsWith(`${path}/`));
|
|
852
|
+
drop(entries, target);
|
|
853
|
+
drop(entries, path);
|
|
854
|
+
putDir(entries, parentOf(target));
|
|
855
|
+
for (const [key, entry] of moving) {
|
|
856
|
+
const rest = key === path ? "" : key.slice(path.length + 1);
|
|
857
|
+
entries[rest ? `${target}/${rest}` : target] = { ...entry };
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
}
|