@qodeca/xezar 0.13.1 → 0.14.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/README.md +45 -15
- package/dist/agent-config/account-identity.d.ts +2 -8
- package/dist/agent-config/account-identity.js +54 -10
- package/dist/agent-config/account-identity.js.map +1 -1
- package/dist/agent-config/catalog.d.ts +49 -7
- package/dist/agent-config/catalog.js +107 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/model-settings/pi.d.ts +12 -6
- package/dist/agent-config/model-settings/pi.js +12 -6
- package/dist/agent-config/model-settings/pi.js.map +1 -1
- package/dist/agent-config/seed.js +10 -0
- package/dist/agent-config/seed.js.map +1 -1
- package/dist/contract/github.d.ts +15 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +1527 -895
- package/dist/contract/mcp-api-reference.d.ts +174 -0
- package/dist/contract/mcp-audit.d.ts +103 -0
- package/dist/contract/mcp-discovery.d.ts +257 -0
- package/dist/contract/mcp-event-catalog.d.ts +121 -0
- package/dist/contract/mcp-event-catalog.test.d.ts +1 -0
- package/dist/contract/mcp-idempotency.d.ts +200 -0
- package/dist/contract/mcp-journal.d.ts +268 -0
- package/dist/contract/mcp-leader.d.ts +88 -0
- package/dist/contract/mcp-ownership.d.ts +78 -0
- package/dist/contract/mcp-versioning.d.ts +139 -0
- package/dist/contract/runs.d.ts +81 -0
- package/dist/core/agent-profiles.d.ts +15 -4
- package/dist/core/agent-profiles.js +22 -8
- package/dist/core/agent-profiles.js.map +1 -1
- package/dist/core/opencode-server-runner.js +71 -11
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/secret-redaction.d.ts +7 -1
- package/dist/core/secret-redaction.js +44 -10
- package/dist/core/secret-redaction.js.map +1 -1
- package/dist/index.js +138 -27
- package/dist/index.js.map +1 -1
- package/dist/machine-identity.d.ts +66 -0
- package/dist/machine-identity.js +96 -0
- package/dist/machine-identity.js.map +1 -0
- package/dist/mcp/adapters/claude-code.d.ts +41 -0
- package/dist/mcp/adapters/claude-code.js +40 -0
- package/dist/mcp/adapters/claude-code.js.map +1 -0
- package/dist/mcp/adapters/codex.d.ts +140 -0
- package/dist/mcp/adapters/codex.js +323 -0
- package/dist/mcp/adapters/codex.js.map +1 -0
- package/dist/mcp/adapters/opencode.d.ts +148 -0
- package/dist/mcp/adapters/opencode.js +528 -0
- package/dist/mcp/adapters/opencode.js.map +1 -0
- package/dist/mcp/adapters/pi-link.d.ts +102 -0
- package/dist/mcp/adapters/pi-link.js +235 -0
- package/dist/mcp/adapters/pi-link.js.map +1 -0
- package/dist/mcp/adapters/pi.d.ts +188 -0
- package/dist/mcp/adapters/pi.js +418 -0
- package/dist/mcp/adapters/pi.js.map +1 -0
- package/dist/mcp/api-reference.d.ts +26 -0
- package/dist/mcp/api-reference.js +133 -0
- package/dist/mcp/api-reference.js.map +1 -0
- package/dist/mcp/audit-trail.d.ts +162 -0
- package/dist/mcp/audit-trail.js +306 -0
- package/dist/mcp/audit-trail.js.map +1 -0
- package/dist/mcp/bridge.d.ts +81 -0
- package/dist/mcp/bridge.js +407 -0
- package/dist/mcp/bridge.js.map +1 -0
- package/dist/mcp/connection-file.d.ts +49 -0
- package/dist/mcp/connection-file.js +63 -0
- package/dist/mcp/connection-file.js.map +1 -0
- package/dist/mcp/echo-guard.d.ts +94 -0
- package/dist/mcp/echo-guard.js +160 -0
- package/dist/mcp/echo-guard.js.map +1 -0
- package/dist/mcp/event-catalog.d.ts +120 -0
- package/dist/mcp/event-catalog.js +306 -0
- package/dist/mcp/event-catalog.js.map +1 -0
- package/dist/mcp/event-controller.d.ts +230 -0
- package/dist/mcp/event-controller.js +596 -0
- package/dist/mcp/event-controller.js.map +1 -0
- package/dist/mcp/event-journal.d.ts +57 -0
- package/dist/mcp/event-journal.js +423 -0
- package/dist/mcp/event-journal.js.map +1 -0
- package/dist/mcp/index.d.ts +63 -0
- package/dist/mcp/index.js +386 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/ipc.d.ts +202 -0
- package/dist/mcp/ipc.js +206 -0
- package/dist/mcp/ipc.js.map +1 -0
- package/dist/mcp/leader-delivery.d.ts +119 -0
- package/dist/mcp/leader-delivery.js +450 -0
- package/dist/mcp/leader-delivery.js.map +1 -0
- package/dist/mcp/operation-receipts.d.ts +210 -0
- package/dist/mcp/operation-receipts.js +723 -0
- package/dist/mcp/operation-receipts.js.map +1 -0
- package/dist/mcp/project-catalogs.d.ts +23 -0
- package/dist/mcp/project-catalogs.js +25 -0
- package/dist/mcp/project-catalogs.js.map +1 -0
- package/dist/mcp/project-leaders.d.ts +26 -0
- package/dist/mcp/project-leaders.js +17 -0
- package/dist/mcp/project-leaders.js.map +1 -0
- package/dist/mcp/protocol.d.ts +50 -0
- package/dist/mcp/protocol.js +47 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/reconnect.d.ts +297 -0
- package/dist/mcp/reconnect.js +465 -0
- package/dist/mcp/reconnect.js.map +1 -0
- package/dist/mcp/resource-ownership.d.ts +228 -0
- package/dist/mcp/resource-ownership.js +390 -0
- package/dist/mcp/resource-ownership.js.map +1 -0
- package/dist/mcp/service-adapter.d.ts +16488 -0
- package/dist/mcp/service-adapter.js +148 -0
- package/dist/mcp/service-adapter.js.map +1 -0
- package/dist/mcp/service.d.ts +83 -0
- package/dist/mcp/service.js +233 -0
- package/dist/mcp/service.js.map +1 -0
- package/dist/mcp/session-binding.d.ts +105 -0
- package/dist/mcp/session-binding.js +163 -0
- package/dist/mcp/session-binding.js.map +1 -0
- package/dist/mcp/stale-write.d.ts +145 -0
- package/dist/mcp/stale-write.js +219 -0
- package/dist/mcp/stale-write.js.map +1 -0
- package/dist/mcp/tool.d.ts +48 -0
- package/dist/mcp/tool.js +24 -0
- package/dist/mcp/tool.js.map +1 -0
- package/dist/mcp/tools/discovery.d.ts +40 -0
- package/dist/mcp/tools/discovery.js +232 -0
- package/dist/mcp/tools/discovery.js.map +1 -0
- package/dist/mcp/tools/execution-control.d.ts +171 -0
- package/dist/mcp/tools/execution-control.js +513 -0
- package/dist/mcp/tools/execution-control.js.map +1 -0
- package/dist/mcp/tools/handoff-git.d.ts +26 -0
- package/dist/mcp/tools/handoff-git.js +544 -0
- package/dist/mcp/tools/handoff-git.js.map +1 -0
- package/dist/mcp/tools/index.d.ts +2 -0
- package/dist/mcp/tools/index.js +23 -0
- package/dist/mcp/tools/index.js.map +1 -0
- package/dist/mcp/tools/leader-events.d.ts +46 -0
- package/dist/mcp/tools/leader-events.js +126 -0
- package/dist/mcp/tools/leader-events.js.map +1 -0
- package/dist/mcp/tools/local-handoff.d.ts +102 -0
- package/dist/mcp/tools/local-handoff.js +306 -0
- package/dist/mcp/tools/local-handoff.js.map +1 -0
- package/dist/mcp/tools/project-config.d.ts +340 -0
- package/dist/mcp/tools/project-config.js +1075 -0
- package/dist/mcp/tools/project-config.js.map +1 -0
- package/dist/mcp/tools/results-evidence.d.ts +112 -0
- package/dist/mcp/tools/results-evidence.js +1045 -0
- package/dist/mcp/tools/results-evidence.js.map +1 -0
- package/dist/mcp/tools/task-create.d.ts +70 -0
- package/dist/mcp/tools/task-create.js +473 -0
- package/dist/mcp/tools/task-create.js.map +1 -0
- package/dist/mcp/tools/task-reads.d.ts +102 -0
- package/dist/mcp/tools/task-reads.js +689 -0
- package/dist/mcp/tools/task-reads.js.map +1 -0
- package/dist/mcp/tools/work-organisation.d.ts +2 -0
- package/dist/mcp/tools/work-organisation.js +502 -0
- package/dist/mcp/tools/work-organisation.js.map +1 -0
- package/dist/paths.d.ts +17 -7
- package/dist/paths.js +18 -8
- package/dist/paths.js.map +1 -1
- package/dist/runs/project-writer.js +76 -11
- package/dist/runs/project-writer.js.map +1 -1
- package/dist/runs/retention.d.ts +24 -1
- package/dist/runs/retention.js +62 -1
- package/dist/runs/retention.js.map +1 -1
- package/dist/server/forge/github.js +44 -0
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/types.d.ts +16 -0
- package/dist/server/project-context.d.ts +81 -8
- package/dist/server/project-context.js +143 -35
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/server.d.ts +1050 -53
- package/dist/server/server.js +326 -120
- package/dist/server/server.js.map +1 -1
- package/dist/server/validators.d.ts +26 -6
- package/dist/server/validators.js +17 -0
- package/dist/server/validators.js.map +1 -1
- package/dist/skills-remote.js +64 -6
- package/dist/skills-remote.js.map +1 -1
- package/dist/workflows/run.d.ts +277 -12
- package/dist/workflows/run.js +554 -59
- package/dist/workflows/run.js.map +1 -1
- package/dist/workspace/agent-profiles.d.ts +30 -3
- package/dist/workspace/agent-profiles.js +39 -4
- package/dist/workspace/agent-profiles.js.map +1 -1
- package/dist/workspace/config.js +25 -4
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/project-owner.d.ts +210 -0
- package/dist/workspace/project-owner.js +518 -0
- package/dist/workspace/project-owner.js.map +1 -0
- package/package.json +5 -3
- package/scripts/pi-leader-extension.ts +420 -0
- package/scripts/sync-readme.mjs +83 -2
- package/web/dist/assets/{alert-dialog-BVsVcbFy.js → alert-dialog-D6rP6d39.js} +1 -1
- package/web/dist/assets/arrow-down-DMOfFIzW.js +1 -0
- package/web/dist/assets/arrow-left-C4Ec2BUg.js +1 -0
- package/web/dist/assets/{centered-state-Djgly6Ni.js → centered-state-B0ZqrPaa.js} +12 -12
- package/web/dist/assets/chevron-right-mHJ-crvn.js +1 -0
- package/web/dist/assets/{collapsible-DZ4D-DHG.js → collapsible-DchEHgUM.js} +1 -1
- package/web/dist/assets/{commit-list-B-z1JL9G.js → commit-list-BpMim1Rv.js} +1 -1
- package/web/dist/assets/{compare-variants-D62m4j3B.js → compare-variants-D5ItQbAq.js} +1 -1
- package/web/dist/assets/{diff-BKew9rQX.js → diff-HqqZ_WDR.js} +2 -2
- package/web/dist/assets/{diff-stat-D0mc5V6Z.js → diff-stat-NsU-3ecr.js} +1 -1
- package/web/dist/assets/{diff-view-xpIcuLIC.js → diff-view-B7c11Xux.js} +1 -1
- package/web/dist/assets/{dropdown-menu-CEQziH7X.js → dropdown-menu-CtTpnyod.js} +1 -1
- package/web/dist/assets/{editable-title-BCX3T2BY.js → editable-title-BXtuGQry.js} +1 -1
- package/web/dist/assets/{ellipsis-vertical-ByVdDc4z.js → ellipsis-vertical-MHtGZSH6.js} +1 -1
- package/web/dist/assets/{file-xBSB5hXO.js → file-DKLw5SOb.js} +1 -1
- package/web/dist/assets/{folder-CldgbZ5A.js → folder-Drw6-ufH.js} +1 -1
- package/web/dist/assets/{git-toolbar-DJANqg7Y.js → git-toolbar-BQH8bORV.js} +1 -1
- package/web/dist/assets/github-ynnpKFCJ.js +1 -0
- package/web/dist/assets/{image-preview-Cv5mZr0n.js → image-preview-DLhMN_R9.js} +1 -1
- package/web/dist/assets/index-BEsrNdXt.js +29 -0
- package/web/dist/assets/index-Da8DOBau.css +2 -0
- package/web/dist/assets/{markdown-CgEEE2ND.js → markdown-q365eN6Y.js} +1 -1
- package/web/dist/assets/{new-task-form-1r2bgsdD.js → new-task-form-CW-hNIWQ.js} +1 -1
- package/web/dist/assets/{pill-q-x22_ej.js → pill-B_TFulXa.js} +1 -1
- package/web/dist/assets/{project-router-VDN4jQM8.js → project-router-CLPc-ImZ.js} +1 -1
- package/web/dist/assets/{prompt-templates-BmZtCe-L.js → prompt-templates-CLc4RxyO.js} +1 -1
- package/web/dist/assets/{repo-git-btG0sdbO.js → repo-git-Is7R6-Tj.js} +1 -1
- package/web/dist/assets/{run-diff-vguEMJzh.js → run-diff-DHMvDlVo.js} +2 -2
- package/web/dist/assets/run-header-DvZJbQ8I.js +1 -0
- package/web/dist/assets/{search-x-QmhaAbg5.js → search-x-Ce2MMetu.js} +1 -1
- package/web/dist/assets/{skill-empty-hint-BrzUwByZ.js → skill-empty-hint-C_xCSvzB.js} +1 -1
- package/web/dist/assets/skills-CivqD4si.js +1 -0
- package/web/dist/assets/{sparkles-CP8QDOgX.js → sparkles-BCqPoj5g.js} +1 -1
- package/web/dist/assets/{square-terminal-CGypshSD.js → square-terminal-ikj5g-oK.js} +1 -1
- package/web/dist/assets/{tab-link-D4lFzzI1.js → tab-link-VsEjFV1Y.js} +1 -1
- package/web/dist/assets/{task-changes-BtZId14z.js → task-changes-CH-E81-_.js} +1 -1
- package/web/dist/assets/{task-commits-BoeyJXrv.js → task-commits-DqfkMFxp.js} +1 -1
- package/web/dist/assets/{task-files-DIy2oUnj.js → task-files-D_nkHZ_a.js} +2 -2
- package/web/dist/assets/task-thread-bxQompxJ.js +9 -0
- package/web/dist/assets/{textarea-CyBCyKIk.js → textarea-xajuW1L_.js} +1 -1
- package/web/dist/assets/{thread-loading-Cuq2jchC.js → thread-loading-C3JQZbIE.js} +1 -1
- package/web/dist/assets/{trash-2-B6Dp3ZNs.js → trash-2-hJrBhpUD.js} +1 -1
- package/web/dist/assets/{triangle-alert-xBya8FUp.js → triangle-alert-D1u82y1a.js} +1 -1
- package/web/dist/assets/{upload-D6MXEB9p.js → upload-Bn6FBZ59.js} +1 -1
- package/web/dist/assets/{use-desktop-CPBwzQ7M.js → use-desktop-DOIMfHfY.js} +1 -1
- package/web/dist/assets/{use-submit-shortcut-JTpNzU-u.js → use-submit-shortcut-Dj4DHTdE.js} +1 -1
- package/web/dist/assets/utils-YwBpOoRN.js +64 -0
- package/web/dist/assets/{workflows-DG115scU.js → workflows-CTaAKz2m.js} +3 -3
- package/web/dist/assets/{zoomable-image-GTYwLtkS.js → zoomable-image-C1-6P3tg.js} +1 -1
- package/web/dist/index.html +23 -23
- package/web/dist/assets/arrow-down-CYgAlZtW.js +0 -1
- package/web/dist/assets/arrow-left-BOqE0a3k.js +0 -1
- package/web/dist/assets/chevron-right-Cc9BWnbN.js +0 -1
- package/web/dist/assets/github-D6vK0jFn.js +0 -1
- package/web/dist/assets/index-CkmR6h7s.css +0 -2
- package/web/dist/assets/index-DVDqF4bM.js +0 -9
- package/web/dist/assets/refresh-cw-DcNCj2nu.js +0 -1
- package/web/dist/assets/run-header-DM7MiKj9.js +0 -1
- package/web/dist/assets/skills-DMfxQtrD.js +0 -1
- package/web/dist/assets/task-thread-CbMPmrWq.js +0 -9
- package/web/dist/assets/utils--z9hRtl3.js +0 -64
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { RUN_HISTORY_PAGE_ITEMS, apiRunSchema, capabilitiesSchema, groupResponseSchema, runHistoryContextSchema, runHistoryCursorSchema, runHistoryPageSchema, runIdParamSchema, runStatusSchema, runVersionResponseSchema, todoItemSchema, } from '../../contract/index.js';
|
|
4
|
+
import { collectSecretValues, redactDeep } from '../../core/secret-redaction.js';
|
|
5
|
+
import { decodeLiveCursor, decodePageCursor } from '../../runs/event-history.js';
|
|
6
|
+
import { openCursor, ownGroup, ownRun, ownershipScope, sealCursor, } from '../resource-ownership.js';
|
|
7
|
+
import { defineTool, errorResult, textResult } from '../tool.js';
|
|
8
|
+
/**
|
|
9
|
+
* `task_read` — the project leader's READ side of tasks (#91, epic #67): list and filter the
|
|
10
|
+
* bound project's tasks, read one task's record, its paginated history and context, its handoff
|
|
11
|
+
* journal, the project's Inbox and a variant group.
|
|
12
|
+
*
|
|
13
|
+
* WHERE THE DATA COMES FROM. Every read is dispatched IN-PROCESS into the service's own
|
|
14
|
+
* project-scoped routes — `runsRoutes`, `todosRoutes`, `groupsRoutes` — under
|
|
15
|
+
* `/api/v1/p/<bound project>/…`, through the same entry #89's service adapter takes. So the
|
|
16
|
+
* payload is the cockpit's payload by construction (N-02, A-08): the same `withUsage`, the same
|
|
17
|
+
* variant diff stats, the same Inbox capability ceiling. Nothing here opens a store or a file.
|
|
18
|
+
*
|
|
19
|
+
* ISOLATION (F-01, F-02, N-01, M-01, M-07, A-04). The project is `ctx.project` — the project
|
|
20
|
+
* whose socket this call arrived on — and no argument can name another: the input schema is
|
|
21
|
+
* strict, so a `project` key is refused rather than silently ignored. The only non-project path
|
|
22
|
+
* this module requests is `/api/v1/health`, and only for the one Inbox capability boolean. There
|
|
23
|
+
* is no path to the workspace runs index (`GET /api/v1/workspace/runs-index`, which spans every
|
|
24
|
+
* project). A task, every row of the task list and every member of a variant group are proved to
|
|
25
|
+
* be the bound project's by #88's `ownRun` / `ownGroup` before anything about them is read, and
|
|
26
|
+
* every cursor is sealed with #88's `sealCursor` to the project and the resource it pages. A
|
|
27
|
+
* refusal names nothing.
|
|
28
|
+
*
|
|
29
|
+
* BOUNDS (N-06, D-09). One result is at most B-01's 40 000 serialized bytes and B-02's 100
|
|
30
|
+
* items; a caller may ask for fewer items, never more, and a history read without a page size
|
|
31
|
+
* is bounded all the same. The task list, which the cockpit does not paginate, is keyset-paged
|
|
32
|
+
* over a summary projection (B-05). One item larger than the budget is split into parts with an
|
|
33
|
+
* explicit continuation, never dropped and never cut (B-03). Cursors stay within B-04's 2 048
|
|
34
|
+
* bytes.
|
|
35
|
+
*
|
|
36
|
+
* RESULTS (D-05 § 6.8). The text block is a compact JSON object and is authoritative; no
|
|
37
|
+
* `structuredContent` is added, because a second copy would double the bytes B-01 budgets.
|
|
38
|
+
* Every payload passes the transcript's own secret scrub before it leaves (F-15).
|
|
39
|
+
*
|
|
40
|
+
* VERSIONS (#250, N-03). The first answer of a read of ONE task — task, history, context, handoff —
|
|
41
|
+
* carries that task's `version`, the token a mutating tool requires back as `expectedVersion`. It
|
|
42
|
+
* is read from the task's own `GET /runs/:id/version` BEFORE the value it describes, so it can only
|
|
43
|
+
* be older than what the leader saw: a change in between costs a spurious rejection, never a stale
|
|
44
|
+
* write that passes. A continuation (a call with a cursor) carries none: a later history page is
|
|
45
|
+
* pinned to the walk's first view of the file, and a version read now could describe events it
|
|
46
|
+
* does not show. The list and group views carry none — a version per row would scan every task's
|
|
47
|
+
* event file — so a leader reads the one task it means to change.
|
|
48
|
+
*/
|
|
49
|
+
// ---- bounds (D-09) -------------------------------------------------------------------------
|
|
50
|
+
/** D-09 B-01: serialized bytes of one tool result's text. */
|
|
51
|
+
export const TASK_READ_RESULT_BUDGET_BYTES = 40_000;
|
|
52
|
+
/** D-09 B-02: the cockpit's own history page size, reused for every MCP list and history page. */
|
|
53
|
+
export const TASK_READ_PAGE_ITEMS = RUN_HISTORY_PAGE_ITEMS;
|
|
54
|
+
// ---- input ---------------------------------------------------------------------------------
|
|
55
|
+
const VIEWS = ['list', 'task', 'history', 'context', 'handoff', 'inbox', 'group'];
|
|
56
|
+
const idSchema = runIdParamSchema.shape.id;
|
|
57
|
+
const archivedModeSchema = z.enum(['exclude', 'include', 'only']);
|
|
58
|
+
const inputSchema = z.strictObject({
|
|
59
|
+
view: z
|
|
60
|
+
.enum(VIEWS)
|
|
61
|
+
.describe('list = the project’s tasks (summaries, newest first); task = one task’s full record; history = one task’s event history, newest page first; context = the plan and agent episode that frame a task’s history; handoff = a task’s handoff journal (markdown); inbox = the project’s Inbox items; group = one variant group, its tasks side by side.'),
|
|
62
|
+
taskId: idSchema.optional().describe('Task id. Required for task, history, context and handoff.'),
|
|
63
|
+
groupId: idSchema
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Variant group id. Required for group; with list, keeps only that group’s tasks.'),
|
|
66
|
+
cursor: runHistoryCursorSchema
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('The nextCursor of a previous task_read answer, to read the next page or part. Send it with the same view, task and filters as that call.'),
|
|
69
|
+
limit: z
|
|
70
|
+
.number()
|
|
71
|
+
.int()
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(TASK_READ_PAGE_ITEMS)
|
|
74
|
+
.optional()
|
|
75
|
+
.describe(`list and history: at most this many tasks or events per page (default and ceiling ${TASK_READ_PAGE_ITEMS}).`),
|
|
76
|
+
status: z.array(runStatusSchema).min(1).max(runStatusSchema.options.length).optional().describe('list: keep only these statuses.'),
|
|
77
|
+
archived: archivedModeSchema.optional().describe('list: exclude archived tasks (default), include them, or show only them.'),
|
|
78
|
+
query: z.string().min(1).max(200).optional().describe('list: case-insensitive text to find in a task’s title, prompt or branch.'),
|
|
79
|
+
});
|
|
80
|
+
/** Which arguments each view reads. Anything else is refused, never silently ignored. */
|
|
81
|
+
const VIEW_ARGS = {
|
|
82
|
+
list: { required: [], allowed: ['cursor', 'limit', 'status', 'archived', 'query', 'groupId'] },
|
|
83
|
+
task: { required: ['taskId'], allowed: ['cursor'] },
|
|
84
|
+
history: { required: ['taskId'], allowed: ['cursor', 'limit'] },
|
|
85
|
+
context: { required: ['taskId'], allowed: ['cursor'] },
|
|
86
|
+
handoff: { required: ['taskId'], allowed: ['cursor'] },
|
|
87
|
+
inbox: { required: [], allowed: ['cursor'] },
|
|
88
|
+
group: { required: ['groupId'], allowed: ['cursor'] },
|
|
89
|
+
};
|
|
90
|
+
// ---- output projections (derived from the contract, never re-declared) ---------------------
|
|
91
|
+
/**
|
|
92
|
+
* The task-list row (D-09 B-05): what a leader needs to pick a task to read — without the
|
|
93
|
+
* record's `steps[]`, `workflowDef`, prompt text, system prompt and worktree path, which are what
|
|
94
|
+
* make a full record ~6 KB. `task` view returns the whole record.
|
|
95
|
+
*/
|
|
96
|
+
export const taskSummarySchema = apiRunSchema.pick({
|
|
97
|
+
id: true,
|
|
98
|
+
title: true,
|
|
99
|
+
titleSummary: true,
|
|
100
|
+
status: true,
|
|
101
|
+
activity: true,
|
|
102
|
+
workflow: true,
|
|
103
|
+
runner: true,
|
|
104
|
+
model: true,
|
|
105
|
+
autonomous: true,
|
|
106
|
+
createdAt: true,
|
|
107
|
+
startedAt: true,
|
|
108
|
+
finishedAt: true,
|
|
109
|
+
currentStepId: true,
|
|
110
|
+
error: true,
|
|
111
|
+
archived: true,
|
|
112
|
+
pinned: true,
|
|
113
|
+
groupId: true,
|
|
114
|
+
variant: true,
|
|
115
|
+
branch: true,
|
|
116
|
+
baseBranch: true,
|
|
117
|
+
diffStat: true,
|
|
118
|
+
pullRequestUrl: true,
|
|
119
|
+
prNumber: true,
|
|
120
|
+
issueNumber: true,
|
|
121
|
+
autoResumeAt: true,
|
|
122
|
+
tokensUsed: true,
|
|
123
|
+
costUsd: true,
|
|
124
|
+
});
|
|
125
|
+
// ---- cursors -------------------------------------------------------------------------------
|
|
126
|
+
/** A part of one oversized item: which item, how far in, and a digest of what part 1 read. */
|
|
127
|
+
const partSchema = z.object({ id: z.string(), o: z.number().int().nonnegative(), h: z.string().regex(/^[a-f0-9]{16}$/) });
|
|
128
|
+
const pageSizeSchema = z.number().int().min(1).max(TASK_READ_PAGE_ITEMS).optional();
|
|
129
|
+
/**
|
|
130
|
+
* What a cursor carries INSIDE #88's seal. The seal binds it to the project and the resource
|
|
131
|
+
* (`tasks`, `run:<id>:history`, …), so nothing here names either.
|
|
132
|
+
*/
|
|
133
|
+
const cursorPayloadSchema = z.discriminatedUnion('k', [
|
|
134
|
+
z.object({
|
|
135
|
+
v: z.literal(1),
|
|
136
|
+
k: z.literal('list'),
|
|
137
|
+
/** A digest of the filters, not the filters: a 200-character query must not cost B-04. */
|
|
138
|
+
f: z.string().regex(/^[a-f0-9]{16}$/),
|
|
139
|
+
/** The page size the walk started with, so a continuation need not repeat it. */
|
|
140
|
+
n: pageSizeSchema,
|
|
141
|
+
/** Keyset: the last row already delivered. */
|
|
142
|
+
after: z.object({ createdAt: z.string(), id: z.string() }).optional(),
|
|
143
|
+
x: partSchema.optional(),
|
|
144
|
+
}),
|
|
145
|
+
z.object({
|
|
146
|
+
v: z.literal(1),
|
|
147
|
+
k: z.literal('history'),
|
|
148
|
+
n: pageSizeSchema,
|
|
149
|
+
/** The cockpit's history view this walk started from: its file size and correction. */
|
|
150
|
+
fs: z.number().int().nonnegative(),
|
|
151
|
+
co: z.string().optional(),
|
|
152
|
+
/** Only events with a `seq` below this are still to be read. */
|
|
153
|
+
before: z.number().int().nonnegative(),
|
|
154
|
+
x: partSchema.optional(),
|
|
155
|
+
}),
|
|
156
|
+
z.object({ v: z.literal(1), k: z.enum(['task', 'context', 'handoff', 'inbox', 'group']), x: partSchema }),
|
|
157
|
+
]);
|
|
158
|
+
/** The resource a view's cursors page — the key #88's seal binds them to. */
|
|
159
|
+
function resourceOf(view, key) {
|
|
160
|
+
if (view === 'list')
|
|
161
|
+
return 'tasks';
|
|
162
|
+
if (view === 'inbox')
|
|
163
|
+
return 'inbox';
|
|
164
|
+
if (view === 'group')
|
|
165
|
+
return `group:${key}`;
|
|
166
|
+
return `run:${key}:${view}`;
|
|
167
|
+
}
|
|
168
|
+
const INVALID_CURSOR = 'Invalid cursor: it was not issued for this read. Read again without a cursor to start from the first page.';
|
|
169
|
+
function encodeCursor(scope, view, key, payload) {
|
|
170
|
+
return sealCursor(scope, resourceOf(view, key), JSON.stringify(payload));
|
|
171
|
+
}
|
|
172
|
+
/** Open a cursor sealed for THIS project and resource, or say it was not. */
|
|
173
|
+
function decodeCursor(scope, view, key, cursor) {
|
|
174
|
+
const opened = openCursor(scope, resourceOf(view, key), cursor);
|
|
175
|
+
if (!opened.ok)
|
|
176
|
+
return undefined;
|
|
177
|
+
let json;
|
|
178
|
+
try {
|
|
179
|
+
json = JSON.parse(opened.value);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const parsed = cursorPayloadSchema.safeParse(json);
|
|
185
|
+
return parsed.success && parsed.data.k === view ? parsed.data : undefined;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The #88 ownership scope for the bound project, over the records THIS call read from the
|
|
189
|
+
* project's own routes. It holds no automation records: task reads never touch automations.
|
|
190
|
+
*/
|
|
191
|
+
function scopeOver(root, runs = []) {
|
|
192
|
+
const byId = new Map(runs.map((run) => [run.id, run]));
|
|
193
|
+
return ownershipScope({
|
|
194
|
+
root,
|
|
195
|
+
store: { getRun: (id) => byId.get(id), listRuns: () => [...byId.values()] },
|
|
196
|
+
automationStore: { get: () => undefined, latestReceipts: () => new Map() },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// ---- the budget ----------------------------------------------------------------------------
|
|
200
|
+
const bytes = (text) => Buffer.byteLength(text, 'utf8');
|
|
201
|
+
/** A stand-in for the longest cursor, so a page measured with it can never grow past B-01. */
|
|
202
|
+
const CURSOR_PLACEHOLDER = 'x'.repeat(2_048);
|
|
203
|
+
/** A stand-in for the longest version (`mcpVersionTokenSchema`'s bound): room reserved for one. */
|
|
204
|
+
const VERSION_ROOM = 'v'.repeat(512);
|
|
205
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
206
|
+
/** Bytes one code point costs inside a JSON string, exactly as `JSON.stringify` escapes it. */
|
|
207
|
+
function escapedBytes(cp) {
|
|
208
|
+
if (cp === 0x22 || cp === 0x5c)
|
|
209
|
+
return 2;
|
|
210
|
+
if (cp === 0x08 || cp === 0x09 || cp === 0x0a || cp === 0x0c || cp === 0x0d)
|
|
211
|
+
return 2;
|
|
212
|
+
if (cp < 0x20)
|
|
213
|
+
return 6;
|
|
214
|
+
if (cp < 0x80)
|
|
215
|
+
return 1;
|
|
216
|
+
if (cp < 0x800)
|
|
217
|
+
return 2;
|
|
218
|
+
if (cp >= 0xd800 && cp <= 0xdfff)
|
|
219
|
+
return 6; // a lone surrogate is escaped as \uXXXX
|
|
220
|
+
if (cp < 0x10000)
|
|
221
|
+
return 3;
|
|
222
|
+
return 4;
|
|
223
|
+
}
|
|
224
|
+
/** The end index of the longest slice of `text` from `start` whose JSON-escaped size fits. */
|
|
225
|
+
function sliceEnd(text, start, room) {
|
|
226
|
+
let used = 0;
|
|
227
|
+
let index = start;
|
|
228
|
+
while (index < text.length) {
|
|
229
|
+
const cp = text.codePointAt(index);
|
|
230
|
+
const cost = escapedBytes(cp);
|
|
231
|
+
if (used + cost > room)
|
|
232
|
+
break;
|
|
233
|
+
used += cost;
|
|
234
|
+
index += cp > 0xffff ? 2 : 1; // never split a surrogate pair
|
|
235
|
+
}
|
|
236
|
+
return index;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* B-03: one item too large for a result is sent in parts. Every part is sized against the same
|
|
240
|
+
* worst-case envelope, so the part count is fixed before part 1 is sent. Answers undefined when
|
|
241
|
+
* an offset does not sit on a part boundary this function produced — a stale cursor.
|
|
242
|
+
*/
|
|
243
|
+
function partOf(itemJson, offset, envelope) {
|
|
244
|
+
const room = TASK_READ_RESULT_BUDGET_BYTES -
|
|
245
|
+
bytes(envelope({ part: 999_999, parts: 999_999, text: '', nextCursor: CURSOR_PLACEHOLDER }));
|
|
246
|
+
if (room < 64)
|
|
247
|
+
throw new Error('task_read envelope leaves no room for a part');
|
|
248
|
+
let part = 0;
|
|
249
|
+
let found;
|
|
250
|
+
for (let start = 0; start < itemJson.length;) {
|
|
251
|
+
const end = sliceEnd(itemJson, start, room);
|
|
252
|
+
part += 1;
|
|
253
|
+
if (start === offset)
|
|
254
|
+
found = { text: itemJson.slice(start, end), part, next: end < itemJson.length ? end : undefined };
|
|
255
|
+
start = end;
|
|
256
|
+
}
|
|
257
|
+
return found ? { ...found, parts: part } : undefined;
|
|
258
|
+
}
|
|
259
|
+
const STALE_PART = 'This item changed while it was being read in parts. Read it again without a cursor to start from part 1.';
|
|
260
|
+
// ---- dispatch ------------------------------------------------------------------------------
|
|
261
|
+
/** Any absolute origin works: the request never leaves the process. The `host` is what the
|
|
262
|
+
* request-origin guard (#426) checks, and an in-process call genuinely is this machine. */
|
|
263
|
+
const IN_PROCESS_BASE = 'http://127.0.0.1';
|
|
264
|
+
class TaskReader {
|
|
265
|
+
service;
|
|
266
|
+
project;
|
|
267
|
+
scopePath;
|
|
268
|
+
/** A scope for sealing cursors only; ownership checks build theirs over the records read. */
|
|
269
|
+
scope;
|
|
270
|
+
constructor(service, project) {
|
|
271
|
+
this.service = service;
|
|
272
|
+
this.project = project;
|
|
273
|
+
this.scopePath = `/api/v1/p/${encodeURIComponent(project.id)}`;
|
|
274
|
+
this.scope = scopeOver(project.root);
|
|
275
|
+
}
|
|
276
|
+
/** A project-scoped route. The path is built here from validated ids only. */
|
|
277
|
+
get(path, as = 'json') {
|
|
278
|
+
return this.dispatch(`${this.scopePath}${path}`, as);
|
|
279
|
+
}
|
|
280
|
+
/** Every task of the project, as the cockpit's `GET /runs` answers them. */
|
|
281
|
+
async runs() {
|
|
282
|
+
const answer = await this.get('/runs');
|
|
283
|
+
return answer.status === 200 ? { ok: true, value: z.array(apiRunSchema).parse(answer.body) } : { ok: false, answer };
|
|
284
|
+
}
|
|
285
|
+
/** One task, read from the project's own route AND proved the project's by #88's `ownRun`. */
|
|
286
|
+
async ownedRun(taskId) {
|
|
287
|
+
const answer = await this.get(`/runs/${encodeURIComponent(taskId)}`);
|
|
288
|
+
if (answer.status !== 200)
|
|
289
|
+
return { ok: false, answer };
|
|
290
|
+
const run = apiRunSchema.parse(answer.body);
|
|
291
|
+
if (!ownRun(scopeOver(this.project.root, [run]), taskId).ok)
|
|
292
|
+
return { ok: false, answer: { status: 404, body: undefined } };
|
|
293
|
+
return { ok: true, value: run };
|
|
294
|
+
}
|
|
295
|
+
/** The task's stale-write token (#250). Callers read it BEFORE the value it goes out with. */
|
|
296
|
+
async version(taskId) {
|
|
297
|
+
const answer = await this.get(`/runs/${encodeURIComponent(taskId)}/version`);
|
|
298
|
+
return answer.status === 200 ? { ok: true, value: runVersionResponseSchema.parse(answer.body).version } : { ok: false, answer };
|
|
299
|
+
}
|
|
300
|
+
/** The one capability this module needs outside the project scope: is the Inbox on. */
|
|
301
|
+
async inboxEnabled() {
|
|
302
|
+
const answer = await this.dispatch('/api/v1/health', 'json');
|
|
303
|
+
if (answer.status !== 200)
|
|
304
|
+
throw new Error(`health answered ${answer.status}`);
|
|
305
|
+
return z.object({ capabilities: capabilitiesSchema.pick({ followups: true }) }).parse(answer.body).capabilities.followups;
|
|
306
|
+
}
|
|
307
|
+
async dispatch(path, as) {
|
|
308
|
+
const res = await this.service.request(`${IN_PROCESS_BASE}${path}`, { headers: { host: '127.0.0.1' } });
|
|
309
|
+
if (as === 'text' && res.status === 200)
|
|
310
|
+
return { status: res.status, body: await res.text() };
|
|
311
|
+
return { status: res.status, body: await res.json().catch(() => undefined) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const NO_TASK = 'No such task in this project.';
|
|
315
|
+
const NO_GROUP = 'No such variant group in this project.';
|
|
316
|
+
/**
|
|
317
|
+
* A refusal the service answered with, in words a model can act on. Fixed texts only for "not
|
|
318
|
+
* found": a message that echoed an id or a path could name another project's resource (N-01).
|
|
319
|
+
*/
|
|
320
|
+
function refusal(answer, notFound) {
|
|
321
|
+
if (answer.status === 404)
|
|
322
|
+
return errorResult(notFound);
|
|
323
|
+
const message = answer.body && typeof answer.body === 'object' && typeof answer.body.error === 'string'
|
|
324
|
+
? answer.body.error
|
|
325
|
+
: `xezar answered ${answer.status}`;
|
|
326
|
+
if (answer.status === 400 || answer.status === 409)
|
|
327
|
+
return errorResult(`${message}. Read again without a cursor.`);
|
|
328
|
+
throw new Error(`unexpected ${answer.status} for a task read`);
|
|
329
|
+
}
|
|
330
|
+
// ---- the tool ------------------------------------------------------------------------------
|
|
331
|
+
export const taskReadsTool = defineTool({
|
|
332
|
+
name: 'task_read',
|
|
333
|
+
title: 'Read tasks',
|
|
334
|
+
description: [
|
|
335
|
+
'Read this project’s tasks. Choose one view:',
|
|
336
|
+
'- list: task summaries, newest first, filterable by status, archived, groupId and query.',
|
|
337
|
+
'- task: one task’s full record. history: its events, newest page first; follow nextCursor for older events.',
|
|
338
|
+
'- context: the plan and agent episode that frame the history. handoff: the task’s handoff journal.',
|
|
339
|
+
'- inbox: the project’s Inbox items. group: one variant group, its tasks side by side.',
|
|
340
|
+
`Pages are bounded: at most ${TASK_READ_PAGE_ITEMS} items and ${TASK_READ_RESULT_BUDGET_BYTES} bytes. When an answer has a nextCursor, call again with the same view, task and filters plus that cursor.`,
|
|
341
|
+
'An item too large for one answer comes in parts ("part" of "parts"): join the "text" of every part in order, then parse it as JSON.',
|
|
342
|
+
'The first answer of a task, history, context or handoff read (no cursor) carries the task’s "version": send it as expectedVersion when you then change that task (organise_work, execution_control, handoff_git, project_config). A change is refused if the task moved after this read.',
|
|
343
|
+
'This reads only the project this connection is bound to. Use it to assess state or recover after a lost answer, not to poll: task events are pushed.',
|
|
344
|
+
].join('\n'),
|
|
345
|
+
inputSchema,
|
|
346
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
347
|
+
async call(args, ctx) {
|
|
348
|
+
const service = ctx.service;
|
|
349
|
+
if (!service) {
|
|
350
|
+
return errorResult('task_read is not connected in this xezar yet: the running service did not hand MCP its task reads. Use the cockpit to read tasks.');
|
|
351
|
+
}
|
|
352
|
+
const argIssue = checkArgs(args);
|
|
353
|
+
if (argIssue)
|
|
354
|
+
return errorResult(argIssue);
|
|
355
|
+
const reader = new TaskReader(service, ctx.project);
|
|
356
|
+
switch (args.view) {
|
|
357
|
+
case 'list':
|
|
358
|
+
return readList(reader, args);
|
|
359
|
+
case 'history':
|
|
360
|
+
return readHistory(reader, args.taskId, args);
|
|
361
|
+
case 'inbox':
|
|
362
|
+
return readInbox(reader, args.cursor);
|
|
363
|
+
case 'group':
|
|
364
|
+
return readGroup(reader, args.groupId, args.cursor);
|
|
365
|
+
default:
|
|
366
|
+
return readTaskView(reader, args.view, args.taskId, args.cursor);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
function checkArgs(args) {
|
|
371
|
+
const rule = VIEW_ARGS[args.view];
|
|
372
|
+
for (const key of rule.required) {
|
|
373
|
+
if (args[key] === undefined)
|
|
374
|
+
return `${args.view} needs ${key}.`;
|
|
375
|
+
}
|
|
376
|
+
for (const key of Object.keys(args)) {
|
|
377
|
+
if (key === 'view' || args[key] === undefined)
|
|
378
|
+
continue;
|
|
379
|
+
if (!rule.required.includes(key) && !rule.allowed.includes(key))
|
|
380
|
+
return `${key} does not apply to the ${args.view} view.`;
|
|
381
|
+
}
|
|
382
|
+
// A dot segment passes the id pattern, and the URL parser would resolve it onto another route.
|
|
383
|
+
for (const id of [args.taskId, args.groupId]) {
|
|
384
|
+
if (id === '.' || id === '..')
|
|
385
|
+
return `not an id: ${JSON.stringify(id)}`;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
/** One pass of the transcript's own secret scrub over everything that leaves (F-15). */
|
|
390
|
+
const scrub = (value) => redactDeep(value, collectSecretValues());
|
|
391
|
+
// ---- list ----------------------------------------------------------------------------------
|
|
392
|
+
/** Newest first, as the cockpit's `listRuns`; the id breaks a tie so the keyset is total. */
|
|
393
|
+
function newestFirst(a, b) {
|
|
394
|
+
return b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id);
|
|
395
|
+
}
|
|
396
|
+
function matches(run, filter) {
|
|
397
|
+
if (filter.archived === 'exclude' && run.archived)
|
|
398
|
+
return false;
|
|
399
|
+
if (filter.archived === 'only' && !run.archived)
|
|
400
|
+
return false;
|
|
401
|
+
if (filter.status && !filter.status.includes(run.status))
|
|
402
|
+
return false;
|
|
403
|
+
if (filter.groupId !== undefined && run.groupId !== filter.groupId)
|
|
404
|
+
return false;
|
|
405
|
+
if (filter.query !== undefined) {
|
|
406
|
+
const needle = filter.query.toLowerCase();
|
|
407
|
+
const haystack = [run.title, run.titleSummary, run.task, run.branch];
|
|
408
|
+
if (!haystack.some((text) => text?.toLowerCase().includes(needle)))
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
function filterFrom(args) {
|
|
414
|
+
return {
|
|
415
|
+
...(args.status ? { status: [...new Set(args.status)].sort() } : {}),
|
|
416
|
+
archived: args.archived ?? 'exclude',
|
|
417
|
+
...(args.groupId !== undefined ? { groupId: args.groupId } : {}),
|
|
418
|
+
...(args.query !== undefined ? { query: args.query } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
async function readList(reader, args) {
|
|
422
|
+
const filter = filterFrom(args);
|
|
423
|
+
const filterDigest = digest(JSON.stringify(filter));
|
|
424
|
+
let position = {};
|
|
425
|
+
let pageSize = args.limit;
|
|
426
|
+
if (args.cursor !== undefined) {
|
|
427
|
+
const decoded = decodeCursor(reader.scope, 'list', '', args.cursor);
|
|
428
|
+
if (!decoded)
|
|
429
|
+
return errorResult(INVALID_CURSOR);
|
|
430
|
+
if (decoded.f !== filterDigest) {
|
|
431
|
+
return errorResult('This cursor belongs to a list with other filters. Send it with the same filters as the call that returned it.');
|
|
432
|
+
}
|
|
433
|
+
position = { ...(decoded.after ? { after: decoded.after } : {}), ...(decoded.x ? { x: decoded.x } : {}) };
|
|
434
|
+
pageSize ??= decoded.n;
|
|
435
|
+
}
|
|
436
|
+
const listed = await reader.runs();
|
|
437
|
+
if (!listed.ok)
|
|
438
|
+
return refusal(listed.answer, 'This project’s tasks could not be read.');
|
|
439
|
+
// A row is held to the single read's rule (#240, F-01): a record #88's `ownRun` refuses — its
|
|
440
|
+
// recorded worktree is not this project's — is simply not in the list, its total or its search.
|
|
441
|
+
// Filtered before the keyset, so a page is never short because a row was left out.
|
|
442
|
+
const owned = scopeOver(reader.project.root, listed.value);
|
|
443
|
+
const rows = listed.value.filter((run) => ownRun(owned, run.id).ok && matches(run, filter)).sort(newestFirst);
|
|
444
|
+
const after = position.after;
|
|
445
|
+
const remaining = after ? rows.filter((run) => newestFirst(after, run) < 0) : rows;
|
|
446
|
+
const summaries = remaining.map((run) => scrub(taskSummarySchema.parse(run)));
|
|
447
|
+
const limit = pageSize ?? TASK_READ_PAGE_ITEMS;
|
|
448
|
+
const cursor = (fields) => encodeCursor(reader.scope, 'list', '', {
|
|
449
|
+
v: 1,
|
|
450
|
+
k: 'list',
|
|
451
|
+
f: filterDigest,
|
|
452
|
+
...(pageSize !== undefined ? { n: pageSize } : {}),
|
|
453
|
+
...fields,
|
|
454
|
+
});
|
|
455
|
+
const cursorAfter = (row) => cursor({ after: { createdAt: row.createdAt, id: row.id } });
|
|
456
|
+
// While an oversized row is read in parts the keyset stays BEFORE it.
|
|
457
|
+
const partCursor = (x) => cursor({ ...(after ? { after } : {}), x });
|
|
458
|
+
const head = summaries[0];
|
|
459
|
+
if (position.x) {
|
|
460
|
+
// Continuing one oversized row: it must still be the next row, unchanged.
|
|
461
|
+
if (!head || head.id !== position.x.id || digest(JSON.stringify(head)) !== position.x.h)
|
|
462
|
+
return errorResult(STALE_PART);
|
|
463
|
+
return listPart(head, position.x.o, summaries.length > 1, partCursor, cursorAfter);
|
|
464
|
+
}
|
|
465
|
+
const page = [];
|
|
466
|
+
const envelope = (tasks, nextCursor) => JSON.stringify({ view: 'list', total: rows.length, tasks, ...(nextCursor ? { nextCursor } : {}) });
|
|
467
|
+
for (const row of summaries) {
|
|
468
|
+
if (page.length === limit)
|
|
469
|
+
break;
|
|
470
|
+
if (bytes(envelope([...page, row], CURSOR_PLACEHOLDER)) > TASK_READ_RESULT_BUDGET_BYTES)
|
|
471
|
+
break;
|
|
472
|
+
page.push(row);
|
|
473
|
+
}
|
|
474
|
+
if (page.length === 0 && head)
|
|
475
|
+
return listPart(head, 0, summaries.length > 1, partCursor, cursorAfter);
|
|
476
|
+
const last = page.at(-1);
|
|
477
|
+
const more = page.length < summaries.length;
|
|
478
|
+
return textResult(envelope(page, more && last ? cursorAfter(last) : undefined));
|
|
479
|
+
}
|
|
480
|
+
/** One list row too large for a result; after its last part the keyset moves past it. */
|
|
481
|
+
function listPart(row, offset, moreRows, partCursor, cursorAfter) {
|
|
482
|
+
const json = JSON.stringify(row);
|
|
483
|
+
const envelope = (chunk) => JSON.stringify({ view: 'list', taskId: row.id, ...chunk });
|
|
484
|
+
const part = partOf(json, offset, envelope);
|
|
485
|
+
if (!part)
|
|
486
|
+
return errorResult(STALE_PART);
|
|
487
|
+
const nextCursor = part.next !== undefined
|
|
488
|
+
? partCursor({ id: row.id, o: part.next, h: digest(json) })
|
|
489
|
+
: moreRows
|
|
490
|
+
? cursorAfter(row)
|
|
491
|
+
: undefined;
|
|
492
|
+
return textResult(envelope({ part: part.part, parts: part.parts, text: part.text, ...(nextCursor ? { nextCursor } : {}) }));
|
|
493
|
+
}
|
|
494
|
+
// ---- history -------------------------------------------------------------------------------
|
|
495
|
+
async function readHistory(reader, taskId, args) {
|
|
496
|
+
let walk;
|
|
497
|
+
let pageSize = args.limit;
|
|
498
|
+
if (args.cursor !== undefined) {
|
|
499
|
+
const decoded = decodeCursor(reader.scope, 'history', taskId, args.cursor);
|
|
500
|
+
if (!decoded)
|
|
501
|
+
return errorResult(INVALID_CURSOR);
|
|
502
|
+
walk = { fs: decoded.fs, before: decoded.before, ...(decoded.co ? { co: decoded.co } : {}), ...(decoded.x ? { x: decoded.x } : {}) };
|
|
503
|
+
pageSize ??= decoded.n;
|
|
504
|
+
}
|
|
505
|
+
// #250: only the FIRST page of a walk carries the version, read before the page. A later page is
|
|
506
|
+
// pinned to the walk's first view of the file, so a version read now could describe events that
|
|
507
|
+
// page does not show — a stale write would then pass on it.
|
|
508
|
+
const version = args.cursor === undefined ? await reader.version(taskId) : undefined;
|
|
509
|
+
const owned = await reader.ownedRun(taskId);
|
|
510
|
+
if (!owned.ok)
|
|
511
|
+
return refusal(owned.answer, NO_TASK);
|
|
512
|
+
if (version && !version.ok)
|
|
513
|
+
return refusal(version.answer, NO_TASK);
|
|
514
|
+
const stamp = version?.ok ? { version: version.value } : {};
|
|
515
|
+
// The cockpit's own page, either the newest or the one that ends just before `before`. The
|
|
516
|
+
// older-page cursor is the cockpit's own shape, rebuilt from the view the walk started on.
|
|
517
|
+
const query = walk
|
|
518
|
+
? `?cursor=${Buffer.from(JSON.stringify({
|
|
519
|
+
v: 1,
|
|
520
|
+
kind: 'page',
|
|
521
|
+
...(walk.co ? { correction: walk.co } : {}),
|
|
522
|
+
direction: 'older',
|
|
523
|
+
fileSize: walk.fs,
|
|
524
|
+
boundarySeq: walk.before,
|
|
525
|
+
}), 'utf8').toString('base64url')}`
|
|
526
|
+
: '';
|
|
527
|
+
const read = await reader.get(`/runs/${encodeURIComponent(taskId)}/history${query}`);
|
|
528
|
+
if (read.status !== 200)
|
|
529
|
+
return refusal(read, NO_TASK);
|
|
530
|
+
const page = runHistoryPageSchema.parse(read.body);
|
|
531
|
+
// The walk stays on the view it started from: its file size and correction ride every cursor.
|
|
532
|
+
const live = decodeLiveCursor(page.liveCursor);
|
|
533
|
+
const correction = walk ? walk.co : live.correction;
|
|
534
|
+
const view = { fs: walk ? walk.fs : live.offset, ...(correction !== undefined ? { co: correction } : {}) };
|
|
535
|
+
const events = scrub(page.events);
|
|
536
|
+
const limit = pageSize ?? TASK_READ_PAGE_ITEMS;
|
|
537
|
+
const cursorBefore = (before, x) => encodeCursor(reader.scope, 'history', taskId, {
|
|
538
|
+
v: 1,
|
|
539
|
+
k: 'history',
|
|
540
|
+
...(pageSize !== undefined ? { n: pageSize } : {}),
|
|
541
|
+
...view,
|
|
542
|
+
before,
|
|
543
|
+
...(x ? { x } : {}),
|
|
544
|
+
});
|
|
545
|
+
/** Where the cockpit page itself continues, if anywhere. */
|
|
546
|
+
const olderThanPage = page.olderCursor ? decodePageCursor(page.olderCursor).boundarySeq : undefined;
|
|
547
|
+
const envelope = (payload) => JSON.stringify({ view: 'history', taskId, ...stamp, asOfSeq: page.asOfSeq, ...payload });
|
|
548
|
+
// Every size is measured as if a version were present, so what fits never depends on whether
|
|
549
|
+
// this answer carries one.
|
|
550
|
+
const sizing = (payload) => JSON.stringify({ view: 'history', taskId, version: VERSION_ROOM, asOfSeq: page.asOfSeq, ...payload });
|
|
551
|
+
if (walk?.x) {
|
|
552
|
+
const target = events.at(-1);
|
|
553
|
+
if (!target || String(target.seq) !== walk.x.id || digest(JSON.stringify(target)) !== walk.x.h)
|
|
554
|
+
return errorResult(STALE_PART);
|
|
555
|
+
return historyPart(target, walk.x.o, events.length > 1 || olderThanPage !== undefined, envelope, sizing, cursorBefore);
|
|
556
|
+
}
|
|
557
|
+
// Newest first: take the longest suffix of the cockpit page that fits the item and byte bounds.
|
|
558
|
+
let start = events.length;
|
|
559
|
+
while (start > 0 && events.length - start < limit) {
|
|
560
|
+
const candidate = events.slice(start - 1);
|
|
561
|
+
if (bytes(sizing({ events: candidate, hasOlder: true, nextCursor: CURSOR_PLACEHOLDER })) > TASK_READ_RESULT_BUDGET_BYTES)
|
|
562
|
+
break;
|
|
563
|
+
start -= 1;
|
|
564
|
+
}
|
|
565
|
+
if (start === events.length && events.length > 0) {
|
|
566
|
+
return historyPart(events.at(-1), 0, events.length > 1 || olderThanPage !== undefined, envelope, sizing, cursorBefore);
|
|
567
|
+
}
|
|
568
|
+
// Everything before `events[start]` is still to read: the next page is the cockpit's page that
|
|
569
|
+
// ends just below it. Taking the whole page, the cockpit's own older boundary applies.
|
|
570
|
+
const nextBefore = start > 0 ? events[start].seq : olderThanPage;
|
|
571
|
+
const nextCursor = nextBefore !== undefined ? cursorBefore(nextBefore) : undefined;
|
|
572
|
+
return textResult(envelope({ events: events.slice(start), hasOlder: nextCursor !== undefined, ...(nextCursor ? { nextCursor } : {}) }));
|
|
573
|
+
}
|
|
574
|
+
function historyPart(event, offset, olderExists, envelope, sizing, cursorBefore) {
|
|
575
|
+
const json = JSON.stringify(event);
|
|
576
|
+
const h = digest(json);
|
|
577
|
+
const wrap = (chunk) => sizing({ seq: event.seq, hasOlder: true, ...chunk });
|
|
578
|
+
const part = partOf(json, offset, wrap);
|
|
579
|
+
if (!part)
|
|
580
|
+
return errorResult(STALE_PART);
|
|
581
|
+
// While parts remain, the walk stays on this event (`before` just above it); after the last
|
|
582
|
+
// part it moves below it.
|
|
583
|
+
const nextCursor = part.next !== undefined
|
|
584
|
+
? cursorBefore(event.seq + 1, { id: String(event.seq), o: part.next, h })
|
|
585
|
+
: olderExists
|
|
586
|
+
? cursorBefore(event.seq)
|
|
587
|
+
: undefined;
|
|
588
|
+
return textResult(envelope({
|
|
589
|
+
seq: event.seq,
|
|
590
|
+
hasOlder: olderExists || part.next !== undefined,
|
|
591
|
+
part: part.part,
|
|
592
|
+
parts: part.parts,
|
|
593
|
+
text: part.text,
|
|
594
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
// ---- inbox ---------------------------------------------------------------------------------
|
|
598
|
+
async function readInbox(reader, cursor) {
|
|
599
|
+
const read = await reader.get('/todos');
|
|
600
|
+
if (read.status !== 200)
|
|
601
|
+
return refusal(read, 'This project’s Inbox could not be read.');
|
|
602
|
+
const items = z.array(todoItemSchema).parse(read.body);
|
|
603
|
+
// The route answers `[]` both when the Inbox is empty and when it is switched off. Those must
|
|
604
|
+
// not read the same to a leader (F-03), so an empty answer asks which one it was.
|
|
605
|
+
if (items.length === 0 && !(await reader.inboxEnabled())) {
|
|
606
|
+
if (cursor !== undefined)
|
|
607
|
+
return errorResult(INVALID_CURSOR);
|
|
608
|
+
return textResult(JSON.stringify({
|
|
609
|
+
view: 'inbox',
|
|
610
|
+
available: false,
|
|
611
|
+
reason: 'The Inbox is off for this xezar, so tasks do not file follow-ups. A human can turn it on in the cockpit (Settings) or start xezar with XEZ_FOLLOWUPS=1.',
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
return whole(reader.scope, 'inbox', '', { available: true }, 'items', scrub(items), cursor);
|
|
615
|
+
}
|
|
616
|
+
// ---- task, context, handoff, group ---------------------------------------------------------
|
|
617
|
+
async function readTaskView(reader, view, taskId, cursor) {
|
|
618
|
+
// #250: the first answer of a read carries the version, read BEFORE the value it goes out with,
|
|
619
|
+
// so it can only be older than what the leader saw. A continuation carries none (see `whole`).
|
|
620
|
+
const version = cursor === undefined ? await reader.version(taskId) : undefined;
|
|
621
|
+
const run = await reader.ownedRun(taskId);
|
|
622
|
+
if (!run.ok)
|
|
623
|
+
return refusal(run.answer, NO_TASK);
|
|
624
|
+
if (version && !version.ok)
|
|
625
|
+
return refusal(version.answer, NO_TASK);
|
|
626
|
+
const stamp = version?.ok ? { version: version.value } : {};
|
|
627
|
+
if (view === 'task')
|
|
628
|
+
return whole(reader.scope, 'task', taskId, {}, 'task', scrub(run.value), cursor, stamp);
|
|
629
|
+
const id = encodeURIComponent(taskId);
|
|
630
|
+
if (view === 'context') {
|
|
631
|
+
const read = await reader.get(`/runs/${id}/history-context`);
|
|
632
|
+
if (read.status !== 200)
|
|
633
|
+
return refusal(read, NO_TASK);
|
|
634
|
+
return whole(reader.scope, 'context', taskId, { taskId }, 'context', scrub(runHistoryContextSchema.parse(read.body)), cursor, stamp);
|
|
635
|
+
}
|
|
636
|
+
const read = await reader.get(`/runs/${id}/handoff`, 'text');
|
|
637
|
+
if (read.status !== 200)
|
|
638
|
+
return refusal(read, NO_TASK);
|
|
639
|
+
return whole(reader.scope, 'handoff', taskId, { taskId }, 'markdown', scrub(z.string().parse(read.body)), cursor, stamp);
|
|
640
|
+
}
|
|
641
|
+
async function readGroup(reader, groupId, cursor) {
|
|
642
|
+
// M-07: a group is ONE resource. Every member is proved the project's — including where its
|
|
643
|
+
// worktree lives, because the group read computes each member's diff there — or none is read.
|
|
644
|
+
const runs = await reader.runs();
|
|
645
|
+
if (!runs.ok)
|
|
646
|
+
return refusal(runs.answer, NO_GROUP);
|
|
647
|
+
if (!(await ownGroup(scopeOver(reader.project.root, runs.value), groupId)).ok)
|
|
648
|
+
return errorResult(NO_GROUP);
|
|
649
|
+
const read = await reader.get(`/groups/${encodeURIComponent(groupId)}`);
|
|
650
|
+
if (read.status !== 200)
|
|
651
|
+
return refusal(read, NO_GROUP);
|
|
652
|
+
return whole(reader.scope, 'group', groupId, {}, 'group', scrub(groupResponseSchema.parse(read.body)), cursor);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* A view whose answer is ONE value: sent whole when it fits, in parts when it does not (B-03).
|
|
656
|
+
* The parts are bound to a digest of the value part 1 read, so a value that changes mid-read is
|
|
657
|
+
* refused instead of being spliced from two versions.
|
|
658
|
+
*
|
|
659
|
+
* `stamp` marks a view of ONE task (#250): its `version` rides the first answer only, and every
|
|
660
|
+
* part is sized as if a version of the longest possible length were present, so part boundaries
|
|
661
|
+
* are the same whether or not this answer carries one — and whatever the version's length.
|
|
662
|
+
*/
|
|
663
|
+
function whole(scope, view, key, head, field, value, cursor, stamp) {
|
|
664
|
+
const json = JSON.stringify(value);
|
|
665
|
+
const h = digest(json);
|
|
666
|
+
const out = { ...head, ...(stamp?.version !== undefined ? { version: stamp.version } : {}) };
|
|
667
|
+
const room = stamp ? { ...head, version: VERSION_ROOM } : head;
|
|
668
|
+
let offset = 0;
|
|
669
|
+
if (cursor !== undefined) {
|
|
670
|
+
const decoded = decodeCursor(scope, view, key, cursor);
|
|
671
|
+
if (!decoded)
|
|
672
|
+
return errorResult(INVALID_CURSOR);
|
|
673
|
+
if (decoded.x.h !== h)
|
|
674
|
+
return errorResult(STALE_PART);
|
|
675
|
+
offset = decoded.x.o;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const full = JSON.stringify({ view, ...out, [field]: value });
|
|
679
|
+
if (bytes(full) <= TASK_READ_RESULT_BUDGET_BYTES)
|
|
680
|
+
return textResult(full);
|
|
681
|
+
}
|
|
682
|
+
const envelope = (chunk) => JSON.stringify({ view, ...out, field, ...chunk });
|
|
683
|
+
const part = partOf(json, offset, (chunk) => JSON.stringify({ view, ...room, field, ...chunk }));
|
|
684
|
+
if (!part)
|
|
685
|
+
return errorResult(STALE_PART);
|
|
686
|
+
const nextCursor = part.next !== undefined ? encodeCursor(scope, view, key, { v: 1, k: view, x: { id: key, o: part.next, h } }) : undefined;
|
|
687
|
+
return textResult(envelope({ part: part.part, parts: part.parts, text: part.text, ...(nextCursor ? { nextCursor } : {}) }));
|
|
688
|
+
}
|
|
689
|
+
//# sourceMappingURL=task-reads.js.map
|