Просмотр исходного кода

refactor(workspace-files): reuse bounded reads and return raw Host bytes

yudshj 2 недель назад
Родитель
Сommit
b911efef39

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
-2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53
-2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8
+2026-09-15-bounded-office-conversion.md: af24cee4bc73c40135a15f70cff9201e59280da7
+2026-09-15-bounded-office-conversion.zh.md: 29411e117653755e2fd7ddce8e2fe516ffdf226f

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md

@@ -30,8 +30,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b
 
 **Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
 
+Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy.
+
 ## Consequences
 
-The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
+The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
 
 Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md

@@ -30,8 +30,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
 
 **为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
 
+Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。
+
 ## 后果
 
-缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
+缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
 
 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。

+ 0 - 6
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml

@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md
-2026-09-15-bounded-office-rendering.md: ae6ac3ec78d826cea69ff9adf9399a555206d022
-2026-09-15-bounded-office-rendering.zh.md: 5dd1c69df16e87819dece7bd00ebc367861177bf

+ 0 - 37
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md

@@ -1,37 +0,0 @@
-# Agent Note: Bounded shared Office conversion
-
-Status: implemented
-
-English | [中文](2026-09-15-bounded-office-rendering.zh.md)
-
-## Problem
-
-Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion.
-
-## Decision
-
-The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
-
-The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
-
-A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles.
-
-Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller.
-
-## Alternatives considered
-
-**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion.
-
-**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content.
-
-**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work.
-
-**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work.
-
-**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
-
-## Consequences
-
-The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
-
-Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.

+ 0 - 37
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md

@@ -1,37 +0,0 @@
-# Agent Note: 有界的共享 Office 转换
-
-Status: implemented
-
-[English](2026-09-15-bounded-office-rendering.md) | 中文
-
-## 问题
-
-Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。
-
-## 决策
-
-`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
-
-[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
-
-有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。
-
-前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。
-
-## 考虑过的替代方案
-
-**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。
-
-**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。
-
-**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。
-
-**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。
-
-**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
-
-## 后果
-
-缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
-
-受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。

+ 2 - 2
docs/subsystems/workspace.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e
-workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8
+workspace.md: 9633bd74b6997554e0d25824746117b4760fac29
+workspace.zh.md: fd749cec14383ea746efb096437f841f57291962

+ 2 - 2
docs/subsystems/workspace.md

@@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi
  * @param path - absolute or workspace-relative file path.
  * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
  * @param signal - caller cancellation.
- * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+ * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
  */
-async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
+async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.

+ 2 - 2
docs/subsystems/workspace.zh.md

@@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi
  * @param path - absolute or workspace-relative file path.
  * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
  * @param signal - caller cancellation.
- * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+ * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
  */
-async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
+async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>
 
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.

+ 2 - 2
packages/api/workspace-files/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md
-README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91
-README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4
+README.md: a3ccbc24ca37b5b933c34390773d10a223e99d36
+README.zh.md: a7e59c07d5d99216a2e8f6659ed3dc3201846a62

+ 1 - 1
packages/api/workspace-files/README.md

@@ -82,7 +82,7 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues
 
 One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
 
-Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte.
+Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. It returns raw `Uint8Array` data with file metadata. The filesystem enforces the smaller of that reservation and `maxFileBytes`; `readAll()` encodes the result as base64 for Remote callers.
 
 -----
 

+ 1 - 1
packages/api/workspace-files/README.zh.md

@@ -82,7 +82,7 @@ kind: "package-reference"
 
 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
 
-Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。
+Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。它返回原始 `Uint8Array` 数据及文件元数据。文件系统执行预留值与 `maxFileBytes` 中较小的上限;`readAll()` 为 Remote 调用方将结果编码为 base64。
 
 -----
 

+ 11 - 11
packages/api/workspace-files/src/index.ts

@@ -276,7 +276,8 @@ export class WorkspaceFiles extends TypertRemoteService {
    */
   @Remote
   async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
-    return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal)
+    const { data, ...stat } = await this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal)
+    return { ...stat, offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
   }
 
   /**
@@ -285,21 +286,20 @@ export class WorkspaceFiles extends TypertRemoteService {
    * @param path - absolute or workspace-relative file path.
    * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
    * @param signal - caller cancellation.
-   * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+   * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
    */
   async readAllBounded(
     workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal,
-  ): Promise<WorkspaceFileBytes> {
+  ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }> {
     const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
     const limit = Math.min(this.config.maxFileBytes, maxBytes)
-    if (info.size !== undefined && info.size > limit) {
-      throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
-    }
-    const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal)
-    if (data.length > limit) {
-      throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
-    }
-    return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
+    const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => {
+      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`, { path, limit }, { cause })
+      }
+      throw cause
+    })
+    return { ...this.statOf(target, info), data }
   }
 
   /**

+ 34 - 10
packages/api/workspace-files/tests/read-all.spec.ts

@@ -24,13 +24,11 @@ describe('workspaceFiles.readAll', () => {
     expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
   })
 
-  it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => {
+  it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => {
     const path = join(harness[location], 'large')
     await writeFile(path, 'abcde')
-    const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
     expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal())))
       .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } })
-    expect(read).not.toHaveBeenCalled()
   })
 
   it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => {
@@ -51,15 +49,41 @@ describe('workspaceFiles.readAll', () => {
   })
 })
 
-it('applies the smaller Host reservation before reading a complete file', async () => {
+it.each([[8, 3], [3, 8]])('applies the smaller cap from deployment %s and Host reservation %s', async (maxFileBytes, reservation) => {
   await writeFile(join(harness.workspace, 'reserved'), '1234')
-  const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
-  const files = harness.endpoint({ maxFileBytes: 8 })
-  expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal())))
+  const read = vi.spyOn(harness.ctx.fs, 'readBytes')
+  const files = harness.endpoint({ maxFileBytes })
+  const caller = signal()
+  expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', reservation, caller)))
     .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } })
-  expect(read).not.toHaveBeenCalled()
-  expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4)
-  expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 })
+  expect(read).toHaveBeenCalledExactlyOnceWith(expect.anything(), caller, 3)
+})
+
+it('returns the filesystem byte array directly to Host consumers at the exact cap', async () => {
+  await writeFile(join(harness.workspace, 'reserved'), '1234')
+  const read = vi.spyOn(harness.ctx.fs, 'readBytes')
+  const result = await harness.endpoint({ maxFileBytes: 8 }).readAllBounded(harness.scope, 'reserved', 4, signal())
+  expect(result.data).toBe(await read.mock.results[0]!.value)
+  expect(result.data).toEqual(Buffer.from('1234'))
+  expect(result.bytes).toBe(4)
+  expect(result).not.toHaveProperty('offset')
+  expect(result).not.toHaveProperty('eof')
+})
+
+it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])(
+  'preserves non-size filesystem failures: %s', async (failure) => {
+    await writeFile(join(harness.workspace, 'file'), '1234')
+    vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure)
+    await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())).rejects.toBe(failure)
+  },
+)
+
+it('maps a size refusal by code without requiring a shared error class', async () => {
+  await writeFile(join(harness.workspace, 'file'), '1234')
+  const failure = { code: 'FS_TOO_LARGE' }
+  vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure)
+  await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal()))
+    .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure })
 })
 
 describe('workspaceFiles.readRelated', () => {

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3247,10 +3247,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.',
       },
       {
-        signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
+        signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>',
         description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.',
         parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }],
-        returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.',
+        returns: 'complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.',
       },
       {
         signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',

+ 5 - 5
pnpm-lock.yaml

@@ -8106,7 +8106,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@earendil-works/pi-ai':
         specifier: ^0.85.1
-        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
+        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -20561,14 +20561,14 @@ snapshots:
     transitivePeerDependencies:
       - '@algolia/client-search'
 
-  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
+  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)':
     dependencies:
       '@anthropic-ai/sdk': 0.123.0(zod@4.4.3)
       '@aws-sdk/client-bedrock-runtime': 3.1048.0
       '@earendil-works/pi-telemetry': 0.85.1
       '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
       '@smithy/node-http-handler': 4.7.3
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       openai: 6.40.0(ws@8.21.0)(zod@4.4.3)
       partial-json: 0.1.7
@@ -23196,7 +23196,7 @@ snapshots:
       cross-spawn: 7.0.6
       debug: 4.4.3(supports-color@9.4.0)
       fs-extra: 10.1.0
-      http-proxy-agent: 7.0.2
+      http-proxy-agent: 7.0.2(supports-color@9.4.0)
       https-proxy-agent: 7.0.6
       js-yaml: 4.3.1
       sanitize-filename: 1.6.4
@@ -24511,7 +24511,7 @@ snapshots:
       statuses: 2.0.2
       toidentifier: 1.0.1
 
-  http-proxy-agent@7.0.2:
+  http-proxy-agent@7.0.2(supports-color@9.4.0):
     dependencies:
       agent-base: 7.1.4
       debug: 4.4.3(supports-color@9.4.0)