@deepseek-ai/dsh-api-workspace-files 0.1.6-alpha.2 → 0.1.7-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -4,14 +4,11 @@ import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typer
4
4
  import { Deque } from "@deepseek-ai/dsh-deque";
5
5
  //#region lib/types/changes.js
6
6
  /**
7
- * Producer of the `changes` stream: every `fs/observed` emission whose target
8
- * lies inside a generation's workspace root becomes one frame of that
9
- * generation. Instrumented filesystem operations emit these observations; the
10
- * operating system is not watched.
11
- * Each generation acknowledges its observation queue and resolved workspace
12
- * root with `ready` before emitting any queued or live changes.
7
+ * Target-scoped filesystem watches and `fs/observed` invalidations for `changes`.
8
+ * Each generation sends `ready` after watcher initialization, then reads current
9
+ * target metadata for matching queued and live invalidations.
13
10
  */
14
- /** Owns `fs/observed` observation and every open `changes` generation. */
11
+ /** Owns target watches, instrumented observations, and every open `changes` generation. */
15
12
  var WorkspaceChangeFeed = class {
16
13
  ctx;
17
14
  followers = /* @__PURE__ */ new Set();
@@ -21,37 +18,83 @@ var WorkspaceChangeFeed = class {
21
18
  ctx.on("fs/observed", (target, observation) => {
22
19
  for (const follower of this.followers) follower.push([target, observation]);
23
20
  });
24
- ctx.effect(() => () => {
25
- for (const follower of this.followers) follower.close();
21
+ ctx.effect(() => async () => {
22
+ const followers = [...this.followers];
23
+ await Promise.all(followers.map((follower) => follower.close()));
26
24
  this.followers.clear();
27
25
  }, "workspace-files.changes");
28
26
  }
29
27
  /**
30
- * Open one generation reporting observations inside `workspaceRoot`.
28
+ * Open one target watch; directory targets remain inside `workspaceRoot`.
31
29
  * @param workspaceRoot - the session's workspace root path.
30
+ * @param path - target path, resolved relative to the workspace root; Host metadata determines its type.
32
31
  * @param signal - generation cancellation.
33
- * @returns `ready` after observation is active and the root resolves, then
34
- * observations made after the generation was first pulled, in emission order.
32
+ * @returns `ready` after watching starts, then current metadata for target invalidations.
35
33
  */
36
- async *follow(workspaceRoot, signal) {
34
+ async *follow(workspaceRoot, path, signal) {
37
35
  signal.throwIfAborted();
38
- const follower = new ChangeFollower();
36
+ const follower = new ChangeFollower(() => {
37
+ signal.removeEventListener("abort", cancel);
38
+ this.followers.delete(follower);
39
+ });
40
+ signal = AbortSignal.any([signal, follower.controller.signal]);
41
+ const aborted = () => signal.aborted;
42
+ const cancel = () => {
43
+ follower.close().catch((error) => {
44
+ this.ctx.logger.error(error);
45
+ });
46
+ };
47
+ signal.addEventListener("abort", cancel, { once: true });
39
48
  this.followers.add(follower);
49
+ let unwatch;
40
50
  try {
41
51
  const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error) => {
42
- if (signal.aborted) return void 0;
52
+ if (aborted()) return void 0;
53
+ throw error;
54
+ });
55
+ if (root === void 0 || aborted() || follower.isClosed) return;
56
+ const target = await this.ctx.fs.resolve(path, {
57
+ cwd: workspaceRoot,
58
+ signal
59
+ }).catch((error) => {
60
+ if (aborted()) return void 0;
43
61
  throw error;
44
62
  });
45
- if (root === void 0 || signal.aborted || follower.isClosed) return;
63
+ if (target === void 0 || aborted()) return;
64
+ const stat = async () => {
65
+ const info = await this.ctx.fs.stat(target, signal).catch((error) => {
66
+ if (aborted()) return void 0;
67
+ throw error;
68
+ });
69
+ if (info?.type === "directory" && !this.ctx.fs.contains(root, target)) throw new RemoteError("workspace-file/outside-workspace", "Directory is outside the workspace", { path });
70
+ return info;
71
+ };
72
+ await stat();
73
+ if (aborted()) return;
74
+ try {
75
+ unwatch = await this.ctx.fs.watch(target, (error) => {
76
+ if (error !== void 0) follower.fail(error);
77
+ else if (!follower.isClosed) follower.push([target]);
78
+ }, signal);
79
+ if (follower.error !== void 0) throw follower.error;
80
+ } catch (error) {
81
+ if (aborted() && follower.error === void 0) return;
82
+ const failure = follower.error ?? error;
83
+ throw new RemoteError("workspace-file/watch-unsupported", failure instanceof Error ? failure.message : String(failure), { path });
84
+ }
85
+ follower.initialized.resolve(unwatch);
86
+ if (aborted()) return;
46
87
  yield { kind: "ready" };
47
- for await (const [target, observation] of follower.read(signal)) {
48
- if (!this.ctx.fs.contains(root, target)) continue;
88
+ for await (const [observed] of follower.read()) {
89
+ if (observed.targetKey !== target.targetKey) continue;
90
+ const info = await stat();
91
+ if (aborted()) return;
49
92
  const absolutePath = this.ctx.fs.processPath(target);
50
93
  yield {
51
94
  kind: "change",
52
- change: observation.kind === "present" ? {
95
+ change: info !== void 0 ? {
53
96
  absolutePath,
54
- version: observation.version
97
+ version: info.version
55
98
  } : {
56
99
  absolutePath,
57
100
  absent: true
@@ -59,50 +102,59 @@ var WorkspaceChangeFeed = class {
59
102
  };
60
103
  }
61
104
  } finally {
62
- this.followers.delete(follower);
63
- follower.close();
105
+ follower.initialized.resolve(unwatch);
106
+ await follower.close();
64
107
  }
65
108
  }
66
109
  };
67
110
  /** One generation's queue: observations wait here until its consumer pulls them. */
68
111
  var ChangeFollower = class {
112
+ release;
113
+ controller = new AbortController();
114
+ initialized = Promise.withResolvers();
115
+ error;
69
116
  queue = new Deque();
70
117
  wake;
71
118
  closed = false;
72
- /** Whether the generation was closed while its workspace root resolved. */
119
+ closing;
120
+ constructor(release) {
121
+ this.release = release;
122
+ }
123
+ /** Whether this generation has stopped accepting invalidations. */
73
124
  get isClosed() {
74
125
  return this.closed;
75
126
  }
76
127
  push(observed) {
128
+ if (this.closed) return;
77
129
  this.queue.pushBack(observed);
78
130
  this.wake?.();
79
131
  }
80
132
  close() {
133
+ if (this.closing !== void 0) return this.closing;
81
134
  this.closed = true;
135
+ this.closing = this.initialized.promise.then((unwatch) => unwatch?.()).finally(this.release);
136
+ this.controller.abort();
82
137
  this.wake?.();
138
+ return this.closing;
139
+ }
140
+ fail(error) {
141
+ this.error = error;
142
+ this.controller.abort();
83
143
  }
84
144
  /** Drain until closed or aborted; anything still queued then is dropped with the generation. */
85
- async *read(signal) {
86
- const abort = () => {
87
- this.close();
88
- };
89
- signal.addEventListener("abort", abort, { once: true });
90
- if (signal.aborted) abort();
91
- try {
92
- while (!this.closed) {
93
- const observed = this.queue.popFront();
94
- if (observed !== void 0) {
95
- yield observed;
96
- continue;
97
- }
98
- await new Promise((resolve) => {
99
- this.wake = resolve;
100
- });
101
- this.wake = void 0;
145
+ async *read() {
146
+ while (!this.closed) {
147
+ const observed = this.queue.popFront();
148
+ if (observed !== void 0) {
149
+ yield observed;
150
+ continue;
102
151
  }
103
- } finally {
104
- signal.removeEventListener("abort", abort);
152
+ await new Promise((resolve) => {
153
+ this.wake = resolve;
154
+ });
155
+ this.wake = void 0;
105
156
  }
157
+ if (this.error !== void 0) throw this.error;
106
158
  }
107
159
  };
108
160
  //#endregion
@@ -248,8 +300,6 @@ let WorkspaceFiles = (() => {
248
300
  let _instanceExtraInitializers = [];
249
301
  let _read_decorators;
250
302
  let _readBytes_decorators;
251
- let _readAll_decorators;
252
- let _readRelated_decorators;
253
303
  let _stat_decorators;
254
304
  let _list_decorators;
255
305
  let _changes_decorators;
@@ -258,8 +308,6 @@ let WorkspaceFiles = (() => {
258
308
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
259
309
  _read_decorators = [Remote];
260
310
  _readBytes_decorators = [Remote];
261
- _readAll_decorators = [Remote];
262
- _readRelated_decorators = [Remote];
263
311
  _stat_decorators = [Remote];
264
312
  _list_decorators = [Remote];
265
313
  _changes_decorators = [Remote({ mode: "stream" })];
@@ -285,28 +333,6 @@ let WorkspaceFiles = (() => {
285
333
  },
286
334
  metadata: _metadata
287
335
  }, null, _instanceExtraInitializers);
288
- __esDecorate(this, null, _readAll_decorators, {
289
- kind: "method",
290
- name: "readAll",
291
- static: false,
292
- private: false,
293
- access: {
294
- has: (obj) => "readAll" in obj,
295
- get: (obj) => obj.readAll
296
- },
297
- metadata: _metadata
298
- }, null, _instanceExtraInitializers);
299
- __esDecorate(this, null, _readRelated_decorators, {
300
- kind: "method",
301
- name: "readRelated",
302
- static: false,
303
- private: false,
304
- access: {
305
- has: (obj) => "readRelated" in obj,
306
- get: (obj) => obj.readRelated
307
- },
308
- metadata: _metadata
309
- }, null, _instanceExtraInitializers);
310
336
  __esDecorate(this, null, _stat_decorators, {
311
337
  kind: "method",
312
338
  name: "stat",
@@ -410,38 +436,28 @@ let WorkspaceFiles = (() => {
410
436
  };
411
437
  }
412
438
  /**
413
- * Read one byte window of a regular file readable by the filesystem backend: raw
414
- * bytes, no text decoding and no binary rejection.
439
+ * Read a complete regular file or one byte range without text decoding.
415
440
  * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
416
- * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
417
- * @param range - the byte window; omitted fields take the window defaults.
441
+ * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided.
442
+ * @param options - optional base file and range; without a range the complete-file cap applies.
418
443
  * @param signal - caller cancellation.
419
- * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
444
+ * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker.
420
445
  */
421
- async readBytes(workspaceFileScope, path, range, signal) {
422
- const { offset, length } = this.resolveWindow(range, path);
423
- const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
424
- const data = await this.ctx.fs.readByteRange(target, {
425
- offset,
426
- length
427
- }, signal);
428
- const eof = info.size === void 0 ? data.length < length : offset + data.length >= info.size;
429
- return {
430
- ...this.statOf(target, info),
431
- offset,
432
- data: Buffer.from(data).toString("base64"),
433
- eof
434
- };
435
- }
436
- /**
437
- * Read a complete regular file as bytes, subject to the configured full-file cap.
438
- * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
439
- * @param path - absolute or workspace-relative file path.
440
- * @param signal - caller cancellation.
441
- * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
442
- */
443
- async readAll(workspaceFileScope, path, signal) {
444
- const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
446
+ async readBytes(workspaceFileScope, path, options, signal) {
447
+ const window = options.range === void 0 ? void 0 : this.resolveWindow(options.range, path);
448
+ const resolved = options.baseFile === void 0 ? path : await this.relativePath(workspaceFileScope, options.baseFile, path, signal);
449
+ const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal);
450
+ if (window !== void 0) {
451
+ const { offset, length } = window;
452
+ const data = await this.ctx.fs.readByteRange(target, window, signal);
453
+ const eof = info.size === void 0 ? data.length < length : offset + data.length >= info.size;
454
+ return {
455
+ ...this.statOf(target, info),
456
+ offset,
457
+ data,
458
+ eof
459
+ };
460
+ }
445
461
  const limit = this.config.maxFileBytes;
446
462
  const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause) => {
447
463
  if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "FS_TOO_LARGE") throw new RemoteError("workspace-file/too-large", `"${path}" exceeds the ${limit} byte full-file cap`, {
@@ -453,27 +469,11 @@ let WorkspaceFiles = (() => {
453
469
  return {
454
470
  ...this.statOf(target, info),
455
471
  offset: 0,
456
- data: Buffer.from(data).toString("base64"),
472
+ data,
457
473
  eof: true
458
474
  };
459
475
  }
460
476
  /**
461
- * Read a complete file relative to another file's directory, including outside the workspace.
462
- * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
463
- * @param path - base file, absolute or workspace-relative.
464
- * @param relativePath - relative filesystem path, not a URL or absolute path.
465
- * @param signal - caller cancellation.
466
- * @returns the complete related file using the ordinary file-size and access checks.
467
- */
468
- async readRelated(workspaceFileScope, path, relativePath, signal) {
469
- const relative = relativePath.replace(/\\/g, "/");
470
- if (relative.length === 0 || relative.startsWith("/") || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) throw new RemoteError("gateway/bad-request", "relativePath must be a relative filesystem path", {});
471
- const { target } = await this.locateFile(workspaceFileScope, path, signal);
472
- const absolute = this.ctx.fs.processPath(target);
473
- const paths = absolute.startsWith("/") ? posix : win32;
474
- return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal);
475
- }
476
- /**
477
477
  * Report one regular file's identity, version, and size without its content.
478
478
  * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
479
479
  * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
@@ -506,16 +506,24 @@ let WorkspaceFiles = (() => {
506
506
  };
507
507
  }
508
508
  /**
509
- * Stream every `fs/observed` observation of a file inside the Session's
510
- * workspace. Only instrumented filesystem operations report here; the OS is
511
- * not watched.
509
+ * Watch one file or a directory's direct entries in the Session's filesystem.
510
+ * Files use the backend's read authority; directories remain workspace-scoped.
512
511
  * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
512
+ * @param path - target path; the Host determines its type and confines directories to the workspace.
513
513
  * @param signal - generation cancellation.
514
- * @returns `ready` once the Host observation queue is active and the workspace
515
- * root is resolved, then queued and live observations in emission order.
514
+ * @returns `ready` once the target watch is active, then current metadata for queued and live invalidations.
515
+ * @throws RemoteError when watching is unavailable or a directory is outside the workspace.
516
516
  */
517
- changes(workspaceFileScope, signal) {
518
- return this.feed.follow(workspaceFileScope.workspaceRoot, signal);
517
+ changes(workspaceFileScope, path, signal) {
518
+ return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal);
519
+ }
520
+ async relativePath(scope, baseFile, path, signal) {
521
+ const relative = path.replace(/\\/g, "/");
522
+ if (relative.length === 0 || relative.startsWith("/") || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) throw new RemoteError("gateway/bad-request", "path must be relative when baseFile is provided", {});
523
+ const { target } = await this.locateFile(scope, baseFile, signal);
524
+ const absolute = this.ctx.fs.processPath(target);
525
+ const paths = absolute.startsWith("/") ? posix : win32;
526
+ return paths.resolve(paths.dirname(absolute), relative);
519
527
  }
520
528
  /** Apply the page defaults and caps here, so the request never carries them implicitly. */
521
529
  resolvePage(range) {