@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,1045 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { hc } from 'hono/client';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { RUN_HISTORY_PAGE_ITEMS, apiRunSchema, changesPayloadSchema, githubChecksDataSchema, githubCommentsDataSchema, githubDataSchema, githubPrChangesDataSchema, githubPrMergeStateResponseSchema, githubRefStatusDataSchema, githubSearchDataSchema, repoCommitPayloadSchema, repoResponseSchema, runCommitsResponseSchema, runHistoryPageSchema, worktreeEntrySchema, } from '../../contract/index.js';
|
|
7
|
+
import { resolveTaskDiffBase } from '../../git-diff-base.js';
|
|
8
|
+
import { isSafeGitRef } from '../../git-refs.js';
|
|
9
|
+
import { imageMimeType } from '../../server/git-changes.js';
|
|
10
|
+
import { MCP_CURSOR_MAX_BYTES, openCursor, ownRun, ownWorkingDirectory, ownWorktreeFile, ownershipScope, sealCursor, } from '../resource-ownership.js';
|
|
11
|
+
import { McpServiceAdapter } from '../service-adapter.js';
|
|
12
|
+
import { defineTool, errorResult, textResult } from '../tool.js';
|
|
13
|
+
/**
|
|
14
|
+
* `read_results_evidence` (#95, F-10, M-08, M-10, M-11): the leader's reads of what a task
|
|
15
|
+
* produced — its summary, history, changes, files, commits and handoff journal, the project's own
|
|
16
|
+
* repository, and the GitHub issue, PR, comment, check and merge-state reads — each identified by
|
|
17
|
+
* the REVISION it describes (`docs/features/mcp-server/mcp-result-evidence-fields.md`).
|
|
18
|
+
*
|
|
19
|
+
* WHERE THE DATA COMES FROM. Every payload is the cockpit's own: the read is dispatched in-process
|
|
20
|
+
* into the bound project's chained routes (`/api/v1/p/<bound>/…`), the same way the shared service
|
|
21
|
+
* adapter (#89) reaches the services, and parsed with the route's contract schema. Nothing here
|
|
22
|
+
* re-implements a route, opens a store, or reads `.local/xezar`. What this module ADDS is only what
|
|
23
|
+
* the field record says no run route returns today — the revision — and it gets it from git in the
|
|
24
|
+
* run's own working directory, proved to be the bound project's by the ownership checks (#88):
|
|
25
|
+
*
|
|
26
|
+
* - `revision.headSha` is read before AND after the route read; a tree that moved in between is
|
|
27
|
+
* read once more, and then answered as unavailable rather than labelled with a sha it may not
|
|
28
|
+
* describe.
|
|
29
|
+
* - `revision.baseSha` for `changes` is `resolveTaskDiffBase` fed the run's `branch` AND
|
|
30
|
+
* `startedAt` — the one rule AGENTS.md names for "which ref anchors this task's diff", and the
|
|
31
|
+
* same inputs the Changes route passes. A review run that checked another branch out is
|
|
32
|
+
* therefore anchored at that branch as the run found it, never at the whole branch's history
|
|
33
|
+
* (#591, #751). The text `/runs/:id/diff` keeps its whole-branch anchor on purpose, which is why
|
|
34
|
+
* it is NOT offered here (inventory I-052: "never the whole-branch anchor").
|
|
35
|
+
* - `commits` keeps the route's own `merge-base` anchor (correction C-6) and reports the task-diff
|
|
36
|
+
* anchor beside it, so a disagreement between the two lists is visible instead of silent.
|
|
37
|
+
*
|
|
38
|
+
* STALE AND MISSING ARE ANSWERS, NOT EMPTINESS (§ 1, § 4). A gone worktree is `evidence:
|
|
39
|
+
* 'unavailable'` with the route's own 409 — never `{files: []}`. A caller that echoes the sha of an
|
|
40
|
+
* earlier read as `expectedHeadSha` gets `freshness: 'stale'` when the tree moved. A continuation
|
|
41
|
+
* cursor is bound to a digest of the page set it started, so continuing after the evidence changed
|
|
42
|
+
* answers `evidence: 'stale'` instead of splicing two revisions together. `diffStat` from the run
|
|
43
|
+
* record carries no revision, and says so (`measuredAtSha: null`). GitHub's `{available: false,
|
|
44
|
+
* reason}` unions pass through unflattened, with `truncated` and per-file reasons intact (N-06).
|
|
45
|
+
*
|
|
46
|
+
* BOUNDS (D-09). One result is at most `RESULT_BUDGET_BYTES` (B-01) and at most 100 items (B-02);
|
|
47
|
+
* an item larger than the budget is delivered in fragments with an explicit continuation (B-03);
|
|
48
|
+
* cursors are sealed to this project and this read (B-04, A-04). The cockpit's own ceilings stay
|
|
49
|
+
* as they are (B-06). The text block is the whole answer (D-05 § 6.8) — no `structuredContent`,
|
|
50
|
+
* because repeating the payload there would double the result past B-01.
|
|
51
|
+
*
|
|
52
|
+
* WITHHELD. A file git ignores is not returned: that is where `.env` and the seeded personal
|
|
53
|
+
* agent config live, and F-15 forbids a credential in a tool response. An `origin` remote URL
|
|
54
|
+
* loses any embedded userinfo for the same reason.
|
|
55
|
+
*/
|
|
56
|
+
/** D-09 B-01: the serialized result content of one tool call, in UTF-8 bytes. */
|
|
57
|
+
export const RESULT_BUDGET_BYTES = 40_000;
|
|
58
|
+
/** D-09 B-02: the one page size, shared with the run history. */
|
|
59
|
+
const PAGE_ITEMS = RUN_HISTORY_PAGE_ITEMS;
|
|
60
|
+
export const RESULTS_EVIDENCE_READS = [
|
|
61
|
+
'summary',
|
|
62
|
+
'history',
|
|
63
|
+
'changes',
|
|
64
|
+
'files',
|
|
65
|
+
'commits',
|
|
66
|
+
'commit',
|
|
67
|
+
'handoff',
|
|
68
|
+
'repo',
|
|
69
|
+
'repo_changes',
|
|
70
|
+
'repo_commit',
|
|
71
|
+
'github',
|
|
72
|
+
'github_comments',
|
|
73
|
+
'github_checks',
|
|
74
|
+
'github_search',
|
|
75
|
+
'github_ref_status',
|
|
76
|
+
'pr_merge_state',
|
|
77
|
+
'pr_changes',
|
|
78
|
+
];
|
|
79
|
+
const readSchema = z.enum(RESULTS_EVIDENCE_READS);
|
|
80
|
+
const shaInput = z.string().regex(/^[0-9a-fA-F]{4,40}$/, 'a commit sha: 4 to 40 hex characters');
|
|
81
|
+
const numberInput = z.number().int().positive().max(2 ** 31 - 1);
|
|
82
|
+
export const resultsEvidenceInputSchema = z
|
|
83
|
+
.object({
|
|
84
|
+
read: readSchema.describe('Which evidence to read.'),
|
|
85
|
+
runId: z.string().min(1).max(256).optional().describe('The task id. Required by every task read.'),
|
|
86
|
+
path: z.string().max(4096).optional().describe('files: a path relative to the task working directory; omit for its root.'),
|
|
87
|
+
sha: shaInput.optional().describe('commit / repo_commit: the commit to read.'),
|
|
88
|
+
number: numberInput.optional().describe('github_comments / pr_merge_state / pr_changes: the issue or PR number.'),
|
|
89
|
+
kind: z.enum(['issue', 'pr']).optional().describe('github_comments / github_search: issue or pull request.'),
|
|
90
|
+
prs: z.array(numberInput).min(1).max(100).optional().describe('github_checks / github_ref_status: PR numbers.'),
|
|
91
|
+
issues: z.array(numberInput).min(1).max(100).optional().describe('github_ref_status: issue numbers.'),
|
|
92
|
+
query: z.string().trim().min(1).max(256).optional().describe('github_search: the search text.'),
|
|
93
|
+
limit: z.number().int().positive().max(100).optional().describe('github / github_search: how many items to ask GitHub for.'),
|
|
94
|
+
refresh: z.boolean().optional().describe('GitHub reads: bypass the cockpit cache.'),
|
|
95
|
+
expectedHeadSha: z
|
|
96
|
+
.string()
|
|
97
|
+
.regex(/^[0-9a-fA-F]{7,40}$/, 'a commit sha: 7 to 40 hex characters')
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('The revision.headSha of an earlier read. The answer says whether that evidence is still current.'),
|
|
100
|
+
cursor: z.string().min(1).max(MCP_CURSOR_MAX_BYTES).optional().describe('The page.next value of the previous page of this same read.'),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
/** What each read needs beyond `read`. Checked before anything is dispatched. */
|
|
104
|
+
const REQUIRED = {
|
|
105
|
+
summary: ['runId'],
|
|
106
|
+
history: ['runId'],
|
|
107
|
+
changes: ['runId'],
|
|
108
|
+
files: ['runId'],
|
|
109
|
+
commits: ['runId'],
|
|
110
|
+
commit: ['runId', 'sha'],
|
|
111
|
+
handoff: ['runId'],
|
|
112
|
+
repo: [],
|
|
113
|
+
repo_changes: [],
|
|
114
|
+
repo_commit: ['sha'],
|
|
115
|
+
github: [],
|
|
116
|
+
github_comments: ['kind', 'number'],
|
|
117
|
+
github_checks: ['prs'],
|
|
118
|
+
github_search: ['kind', 'query'],
|
|
119
|
+
github_ref_status: [],
|
|
120
|
+
pr_merge_state: ['number'],
|
|
121
|
+
pr_changes: ['number'],
|
|
122
|
+
};
|
|
123
|
+
// ---- the answer's shape -------------------------------------------------------
|
|
124
|
+
const revisionSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
/** The commit the evidence was read at. */
|
|
127
|
+
headSha: z.string(),
|
|
128
|
+
/** The working tree differed from `headSha` when it was read — the sha alone does not reproduce it. */
|
|
129
|
+
uncommitted: z.boolean().optional(),
|
|
130
|
+
/** The resolved diff anchor, as a sha. */
|
|
131
|
+
baseSha: z.string().optional(),
|
|
132
|
+
/** Which rule produced `baseSha`. */
|
|
133
|
+
anchor: z.enum(['task-diff-base', 'merge-base', 'head']).optional(),
|
|
134
|
+
/** `commits` only: the anchor `changes` uses, for comparison (correction C-6). */
|
|
135
|
+
taskDiffBaseSha: z.string().optional(),
|
|
136
|
+
/** HEAD was not on the task's own branch — the reason the anchor is what it is. */
|
|
137
|
+
repointedHead: z.object({ headBranch: z.string(), taskBranch: z.string() }).optional(),
|
|
138
|
+
/** `files`: the working-tree copy of this path equals `headSha`'s. */
|
|
139
|
+
pathClean: z.boolean().optional(),
|
|
140
|
+
/** `commit` / `repo_commit`: the commit's own full sha and committer instant. */
|
|
141
|
+
commitSha: z.string().optional(),
|
|
142
|
+
committedAt: z.string().optional(),
|
|
143
|
+
/** `commit` / `repo_commit`: the commit is part of `headSha`'s history. */
|
|
144
|
+
reachableFromHead: z.boolean().optional(),
|
|
145
|
+
})
|
|
146
|
+
.strict();
|
|
147
|
+
const envelopeSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
read: readSchema,
|
|
150
|
+
runId: z.string().optional(),
|
|
151
|
+
/** `available` — here it is. `unavailable` — it cannot be read now (`status`, `reason`).
|
|
152
|
+
* `stale` — the evidence changed since the page this cursor continues. */
|
|
153
|
+
evidence: z.enum(['available', 'unavailable', 'stale']),
|
|
154
|
+
status: z.number().optional(),
|
|
155
|
+
reason: z.string().optional(),
|
|
156
|
+
/** Null when there is no working tree to read a revision from; absent on GitHub list reads. */
|
|
157
|
+
revision: revisionSchema.nullable().optional(),
|
|
158
|
+
/** Present only when `expectedHeadSha` was supplied. */
|
|
159
|
+
freshness: z.enum(['current', 'stale']).optional(),
|
|
160
|
+
/** `pr_merge_state` / `pr_changes` with a `runId`: whose head the forge evidence describes. */
|
|
161
|
+
forgeRevision: z
|
|
162
|
+
.object({
|
|
163
|
+
prHeadSha: z.string().nullable(),
|
|
164
|
+
runHeadSha: z.string().nullable(),
|
|
165
|
+
match: z.enum(['same', 'different', 'unknown']),
|
|
166
|
+
})
|
|
167
|
+
.strict()
|
|
168
|
+
.optional(),
|
|
169
|
+
notes: z.array(z.string()).optional(),
|
|
170
|
+
page: z
|
|
171
|
+
.object({
|
|
172
|
+
/** Items (or, for text, UTF-16 code units) `[from, to)` of `total`. */
|
|
173
|
+
from: z.number(),
|
|
174
|
+
to: z.number(),
|
|
175
|
+
total: z.number(),
|
|
176
|
+
unit: z.enum(['items', 'characters']),
|
|
177
|
+
/** This page's data is one serialized JSON document delivered in pieces: concatenate
|
|
178
|
+
* `fragment` from chunk 0 to `count - 1`, then parse. */
|
|
179
|
+
chunk: z.object({ index: z.number(), count: z.number() }).strict().optional(),
|
|
180
|
+
digest: z.string(),
|
|
181
|
+
next: z.string().optional(),
|
|
182
|
+
})
|
|
183
|
+
.strict()
|
|
184
|
+
.optional(),
|
|
185
|
+
data: z.unknown().optional(),
|
|
186
|
+
fragment: z.string().optional(),
|
|
187
|
+
})
|
|
188
|
+
.strict();
|
|
189
|
+
const NOTES = {
|
|
190
|
+
done: "status 'done' says the task's chain reached its end. It is not proof that checks passed: per-check outcomes are not recorded, so read them as unknown.",
|
|
191
|
+
diffStat: "diffStat carries no revision (measuredAtSha is null) and may describe an older head. Read 'changes' for numbers tied to revision.headSha.",
|
|
192
|
+
uncommitted: 'The working tree has uncommitted changes, so revision.headSha alone does not reproduce this evidence.',
|
|
193
|
+
commitsAnchor: "This list is anchored at the merge-base with the configured base branch (revision.baseSha), which is not the anchor 'changes' uses (revision.taskDiffBaseSha). The two can disagree.",
|
|
194
|
+
checks: 'Check rows carry no revision of their own, and GitHub may report skipped or neutral checks as passing. They describe prHeadSha at best — compare it with the task head before relying on them.',
|
|
195
|
+
checksAbsent: 'A PR number missing from checks means xezar got no answer for it or it has no checks: unknown, never passing.',
|
|
196
|
+
noWorkingTree: 'The task has no working tree to read a revision from, so nothing here is tied to a revision.',
|
|
197
|
+
withheld: 'git ignores this path. Ignored files (.env, personal agent config) can hold credentials, so their content is not returned over MCP.',
|
|
198
|
+
};
|
|
199
|
+
const NOT_FOUND = 'not found in this project';
|
|
200
|
+
const NOT_CONNECTED = 'read_results_evidence is not connected to the running cockpit (the tool context carries no service entry). Nothing was read.';
|
|
201
|
+
const MOVED = 'The working tree moved while it was being read, twice. Read it again.';
|
|
202
|
+
/**
|
|
203
|
+
* The context field this tool reads its service entry from. `McpToolContext` is widened
|
|
204
|
+
* additively by the tool infrastructure (`../tool.ts`); until the running service puts its app
|
|
205
|
+
* there, the tool answers `NOT_CONNECTED` and reads nothing.
|
|
206
|
+
*/
|
|
207
|
+
const serviceFromContext = (ctx) => ctx.service;
|
|
208
|
+
export function createResultsEvidenceTool(resolveService = serviceFromContext) {
|
|
209
|
+
return defineTool({
|
|
210
|
+
name: 'read_results_evidence',
|
|
211
|
+
title: 'Read task results and evidence',
|
|
212
|
+
description: [
|
|
213
|
+
"Read what a task produced and the project's GitHub state, each answer identified by the revision it describes.",
|
|
214
|
+
"Task reads (need runId): summary, history, changes (the task's own diff, anchored like the cockpit's Changes tab), files (path), commits, commit (sha), handoff.",
|
|
215
|
+
'Repository reads: repo, repo_changes, repo_commit (sha). GitHub reads: github, github_comments, github_checks, github_search, github_ref_status, pr_merge_state, pr_changes.',
|
|
216
|
+
"Every task read returns revision.headSha. Pass it back later as expectedHeadSha to learn whether that evidence is still current (freshness: 'stale' when the tree moved).",
|
|
217
|
+
"evidence: 'unavailable' means it cannot be read now (for example the worktree was reclaimed) — never that nothing changed. 'done' is not proof that checks passed.",
|
|
218
|
+
'Answers are paged (page.next); a page that is one large item comes as fragments to concatenate. This is a read for assessment, not a status poll.',
|
|
219
|
+
].join(' '),
|
|
220
|
+
inputSchema: resultsEvidenceInputSchema,
|
|
221
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
222
|
+
async call(args, ctx) {
|
|
223
|
+
const missing = REQUIRED[args.read].filter((key) => args[key] === undefined);
|
|
224
|
+
if (missing.length > 0)
|
|
225
|
+
return errorResult(`read '${args.read}' needs: ${missing.join(', ')}`);
|
|
226
|
+
if (args.read === 'github_ref_status' && !args.prs && !args.issues) {
|
|
227
|
+
return errorResult("read 'github_ref_status' needs prs, issues or both");
|
|
228
|
+
}
|
|
229
|
+
const service = resolveService(ctx);
|
|
230
|
+
if (!service)
|
|
231
|
+
return errorResult(NOT_CONNECTED);
|
|
232
|
+
return new EvidenceReader(ctx.project, service).read(args);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
export const resultsEvidenceTool = createResultsEvidenceTool();
|
|
237
|
+
// ---- the typed in-process client ------------------------------------------------
|
|
238
|
+
/** The same in-process dispatch the shared adapter uses: loopback `host`, no `Origin`. The
|
|
239
|
+
* operations below are additional READS the adapter's closed table does not carry yet. */
|
|
240
|
+
const buildClient = (service) => hc('http://127.0.0.1', {
|
|
241
|
+
fetch: async (input, init) => {
|
|
242
|
+
const headers = new Headers(init?.headers);
|
|
243
|
+
headers.set('host', '127.0.0.1');
|
|
244
|
+
headers.delete('origin');
|
|
245
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
246
|
+
return service.request(url, { ...init, headers });
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
async function answerOf(pending) {
|
|
250
|
+
const res = await pending;
|
|
251
|
+
return { status: res.status, body: await res.text() };
|
|
252
|
+
}
|
|
253
|
+
function jsonOf(answer) {
|
|
254
|
+
try {
|
|
255
|
+
return JSON.parse(answer.body);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function errorOf(answer) {
|
|
262
|
+
const body = jsonOf(answer);
|
|
263
|
+
if (body && typeof body === 'object' && typeof body.error === 'string') {
|
|
264
|
+
return body.error;
|
|
265
|
+
}
|
|
266
|
+
return `the service answered ${answer.status}`;
|
|
267
|
+
}
|
|
268
|
+
// ---- git, read-only ---------------------------------------------------------------
|
|
269
|
+
/**
|
|
270
|
+
* A `git` runner bound to one directory. `GIT_OPTIONAL_LOCKS=0` keeps every probe from
|
|
271
|
+
* rewriting the index it reads — `resolveTaskDiffBase` compares anchors with `git diff`, which
|
|
272
|
+
* would otherwise refresh the stat cache of a `worktree: false` run's REAL index. Never throws.
|
|
273
|
+
*/
|
|
274
|
+
function gitIn(dir) {
|
|
275
|
+
return (args) => new Promise((resolve) => {
|
|
276
|
+
execFile('git', args, {
|
|
277
|
+
cwd: dir,
|
|
278
|
+
encoding: 'utf8',
|
|
279
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
280
|
+
timeout: 15_000,
|
|
281
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
282
|
+
}, (err, stdout) => resolve({ ok: !err, stdout: stdout ?? '' }));
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
286
|
+
/** A ref as a full sha, or null. Refs come from a run record or a resolved anchor, never raw input. */
|
|
287
|
+
async function commitSha(git, ref) {
|
|
288
|
+
if (!isSafeGitRef(ref))
|
|
289
|
+
return null;
|
|
290
|
+
const res = await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
291
|
+
const sha = res.ok ? res.stdout.trim() : '';
|
|
292
|
+
return FULL_SHA.test(sha) ? sha : null;
|
|
293
|
+
}
|
|
294
|
+
async function isDirty(git, path) {
|
|
295
|
+
const res = await git(['status', '--porcelain=v1', '-z', '--untracked-files=normal', ...(path ? ['--', path] : [])]);
|
|
296
|
+
return !res.ok || res.stdout.length > 0;
|
|
297
|
+
}
|
|
298
|
+
/** The instant a commit was made, ISO-8601 — the comparable sibling of git's relative `when`. */
|
|
299
|
+
async function committedAt(git, sha) {
|
|
300
|
+
const res = await git(['show', '-s', '--format=%cI', sha]);
|
|
301
|
+
return res.ok && res.stdout.trim() ? res.stdout.trim() : undefined;
|
|
302
|
+
}
|
|
303
|
+
/** A remote URL without any `user:token@` it may carry (F-15). */
|
|
304
|
+
function withoutUserinfo(url) {
|
|
305
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, '$1');
|
|
306
|
+
}
|
|
307
|
+
// ---- paging -----------------------------------------------------------------------
|
|
308
|
+
/** Where a continuation resumes: item or character offset `o`, fragment `c`, the digest `d` of the
|
|
309
|
+
* page set it started, and — for history — the route's own cursor `r`. */
|
|
310
|
+
const positionSchema = z.object({ o: z.number().int().nonnegative(), c: z.number().int().nonnegative(), d: z.string(), r: z.string().optional() }).strict();
|
|
311
|
+
const START = { o: 0, c: 0, d: '' };
|
|
312
|
+
/** A result's size as the client receives it: the envelope is the text of one content block, so
|
|
313
|
+
* it is escaped a second time on the wire — that serialized content is what B-01 counts. */
|
|
314
|
+
function bytes(value) {
|
|
315
|
+
return Buffer.byteLength(JSON.stringify([{ type: 'text', text: JSON.stringify(value) }]), 'utf8');
|
|
316
|
+
}
|
|
317
|
+
/** One character's cost inside a fragment on the wire: escaped as JSON text, then again. */
|
|
318
|
+
function wireCost(ch) {
|
|
319
|
+
return Buffer.byteLength(JSON.stringify(JSON.stringify(ch).slice(1, -1)), 'utf8') - 2;
|
|
320
|
+
}
|
|
321
|
+
export function digestOf(value) {
|
|
322
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
323
|
+
}
|
|
324
|
+
/** A placeholder as long as the longest `next` a page can carry, so a measured page stays within
|
|
325
|
+
* budget whatever cursor ends up in it. */
|
|
326
|
+
const NEXT_PLACEHOLDER = 'x'.repeat(MCP_CURSOR_MAX_BYTES);
|
|
327
|
+
function listSlice(data, lists, from, to) {
|
|
328
|
+
const out = { ...data };
|
|
329
|
+
let offset = 0;
|
|
330
|
+
for (const key of lists) {
|
|
331
|
+
const items = Array.isArray(data[key]) ? data[key] : undefined;
|
|
332
|
+
if (!items)
|
|
333
|
+
continue;
|
|
334
|
+
out[key] = items.slice(Math.max(0, from - offset), Math.max(0, to - offset));
|
|
335
|
+
offset += items.length;
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
function listTotal(data, lists) {
|
|
340
|
+
return lists.reduce((n, key) => n + (Array.isArray(data[key]) ? data[key].length : 0), 0);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* One page of `data` from `at`, under B-01 and B-02. A page that is a single item (or a whole
|
|
344
|
+
* payload with no list) larger than the budget is serialized and cut into fragments whose
|
|
345
|
+
* ESCAPED size on the wire fits (B-03, and D-09 E3b: escaping can inflate a string six-fold).
|
|
346
|
+
*/
|
|
347
|
+
function pageOf(base, data, shape, at, digest) {
|
|
348
|
+
const fits = (candidate) => bytes(candidate) <= RESULT_BUDGET_BYTES;
|
|
349
|
+
const withPage = (page, extra) => ({
|
|
350
|
+
...base,
|
|
351
|
+
page: { ...page, digest, next: NEXT_PLACEHOLDER },
|
|
352
|
+
...extra,
|
|
353
|
+
});
|
|
354
|
+
if ('text' in shape) {
|
|
355
|
+
const text = typeof data[shape.text] === 'string' ? data[shape.text] : '';
|
|
356
|
+
const total = text.length;
|
|
357
|
+
const from = Math.min(at.o, total);
|
|
358
|
+
const envAt = (to) => withPage({ from, to, total, unit: 'characters' }, { data: { ...data, [shape.text]: text.slice(from, to) } });
|
|
359
|
+
// The largest `to` that fits, by bisection over code units, never splitting a surrogate pair.
|
|
360
|
+
let lo = from;
|
|
361
|
+
let hi = total;
|
|
362
|
+
while (lo < hi) {
|
|
363
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
364
|
+
if (fits(envAt(mid)))
|
|
365
|
+
lo = mid;
|
|
366
|
+
else
|
|
367
|
+
hi = mid - 1;
|
|
368
|
+
}
|
|
369
|
+
let to = lo;
|
|
370
|
+
if (to < total && to > from && /[\uD800-\uDBFF]/.test(text[to - 1] ?? ''))
|
|
371
|
+
to -= 1;
|
|
372
|
+
if (to === from && from < total)
|
|
373
|
+
to = Math.min(total, from + 1); // pathological: metadata alone fills the budget
|
|
374
|
+
return { envelope: envAt(to), next: to < total ? { o: to, c: 0, d: digest } : null };
|
|
375
|
+
}
|
|
376
|
+
const lists = 'lists' in shape ? shape.lists : [];
|
|
377
|
+
const total = listTotal(data, lists);
|
|
378
|
+
const from = Math.min(at.o, total);
|
|
379
|
+
const envAt = (to) => withPage({ from, to, total, unit: 'items' }, { data: lists.length ? listSlice(data, lists, from, to) : data });
|
|
380
|
+
if (lists.length > 0 && at.c === 0) {
|
|
381
|
+
let to = from;
|
|
382
|
+
while (to < total && to - from < PAGE_ITEMS && fits(envAt(to + 1)))
|
|
383
|
+
to += 1;
|
|
384
|
+
if (to > from || total === 0) {
|
|
385
|
+
return { envelope: envAt(to), next: to < total ? { o: to, c: 0, d: digest } : null };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else if (lists.length === 0 && fits(envAt(0))) {
|
|
389
|
+
return { envelope: envAt(0), next: null };
|
|
390
|
+
}
|
|
391
|
+
// One item (or the whole payload) is larger than a result: deliver it in fragments.
|
|
392
|
+
const to = lists.length ? Math.min(total, from + 1) : 0;
|
|
393
|
+
const pageData = lists.length ? listSlice(data, lists, from, to) : data;
|
|
394
|
+
const json = JSON.stringify(pageData);
|
|
395
|
+
const frame = withPage({ from, to, total, unit: 'items', chunk: { index: 999_999, count: 999_999 } }, { fragment: '' });
|
|
396
|
+
const allowance = RESULT_BUDGET_BYTES - bytes(frame);
|
|
397
|
+
const fragments = [];
|
|
398
|
+
let current = '';
|
|
399
|
+
let used = 0;
|
|
400
|
+
for (const ch of json) {
|
|
401
|
+
const cost = wireCost(ch);
|
|
402
|
+
if (used + cost > allowance && current) {
|
|
403
|
+
fragments.push(current);
|
|
404
|
+
current = '';
|
|
405
|
+
used = 0;
|
|
406
|
+
}
|
|
407
|
+
current += ch;
|
|
408
|
+
used += cost;
|
|
409
|
+
}
|
|
410
|
+
if (current || fragments.length === 0)
|
|
411
|
+
fragments.push(current);
|
|
412
|
+
const index = Math.min(at.c, fragments.length - 1);
|
|
413
|
+
const envelope = withPage({ from, to, total, unit: 'items', chunk: { index, count: fragments.length } }, { fragment: fragments[index] ?? '' });
|
|
414
|
+
const next = index + 1 < fragments.length
|
|
415
|
+
? { o: from, c: index + 1, d: digest }
|
|
416
|
+
: lists.length && to < total
|
|
417
|
+
? { o: to, c: 0, d: digest }
|
|
418
|
+
: null;
|
|
419
|
+
return { envelope, next };
|
|
420
|
+
}
|
|
421
|
+
class EvidenceReader {
|
|
422
|
+
project;
|
|
423
|
+
api;
|
|
424
|
+
adapter;
|
|
425
|
+
scopeParam;
|
|
426
|
+
constructor(project, service) {
|
|
427
|
+
this.project = project;
|
|
428
|
+
this.adapter = new McpServiceAdapter({ projectId: project.id, service });
|
|
429
|
+
this.api = buildClient(service).api.v1.p[':projectId'];
|
|
430
|
+
this.scopeParam = { projectId: project.id };
|
|
431
|
+
}
|
|
432
|
+
async read(args) {
|
|
433
|
+
// A cursor is sealed to this project AND this exact read: the same read of another task, path,
|
|
434
|
+
// sha or number cannot continue from it (A-04 — a foreign cursor never widens scope).
|
|
435
|
+
const resource = JSON.stringify([
|
|
436
|
+
args.read,
|
|
437
|
+
args.runId ?? null,
|
|
438
|
+
args.path ?? null,
|
|
439
|
+
args.sha?.toLowerCase() ?? null,
|
|
440
|
+
args.number ?? null,
|
|
441
|
+
args.kind ?? null,
|
|
442
|
+
args.prs ?? null,
|
|
443
|
+
args.issues ?? null,
|
|
444
|
+
args.query ?? null,
|
|
445
|
+
args.limit ?? null,
|
|
446
|
+
]);
|
|
447
|
+
const cursorScope = this.projectScope();
|
|
448
|
+
let at = START;
|
|
449
|
+
if (args.cursor !== undefined) {
|
|
450
|
+
const opened = openCursor(cursorScope, resource, args.cursor);
|
|
451
|
+
if (!opened.ok)
|
|
452
|
+
return errorResult(opened.message);
|
|
453
|
+
const parsed = positionSchema.safeParse(safeJson(opened.value));
|
|
454
|
+
if (!parsed.success)
|
|
455
|
+
return errorResult('invalid cursor — request the first page again');
|
|
456
|
+
at = parsed.data;
|
|
457
|
+
}
|
|
458
|
+
const produced = await this.produce(args, at.r);
|
|
459
|
+
if (!produced.ok)
|
|
460
|
+
return produced.result;
|
|
461
|
+
const { base, data, shape } = produced.evidence;
|
|
462
|
+
const digest = digestOf({ revision: base.revision ?? null, forge: base.forgeRevision ?? null, data });
|
|
463
|
+
if (at.d !== '' && at.d !== digest) {
|
|
464
|
+
return this.respond({
|
|
465
|
+
...base,
|
|
466
|
+
evidence: 'stale',
|
|
467
|
+
reason: 'This evidence changed since the page this cursor continues was read. Read it again from the first page.',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// A forge that could not answer says so in its own payload; the envelope says it once more at
|
|
471
|
+
// the top, and leaves the union itself exactly as the route sent it.
|
|
472
|
+
const forgeDown = data.available === false;
|
|
473
|
+
const outcome = pageOf({
|
|
474
|
+
...base,
|
|
475
|
+
evidence: forgeDown ? 'unavailable' : 'available',
|
|
476
|
+
...(forgeDown && typeof data.reason === 'string' ? { reason: data.reason } : {}),
|
|
477
|
+
}, data, shape, at, digest);
|
|
478
|
+
// Inside one route page of history the route cursor rides along; past its end, the next
|
|
479
|
+
// position is the route's own older page, whose digest is not known until it is read.
|
|
480
|
+
let next = outcome.next && at.r !== undefined ? { ...outcome.next, r: at.r } : outcome.next;
|
|
481
|
+
if (!next && produced.evidence.olderRouteCursor)
|
|
482
|
+
next = { o: 0, c: 0, d: '', r: produced.evidence.olderRouteCursor };
|
|
483
|
+
const { next: _placeholder, ...page } = outcome.envelope.page;
|
|
484
|
+
return this.respond({
|
|
485
|
+
...outcome.envelope,
|
|
486
|
+
page: { ...page, ...(next ? { next: sealCursor(cursorScope, resource, JSON.stringify(next)) } : {}) },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** Validate against the envelope schema (a key nobody declared becomes a failure, not a leak)
|
|
490
|
+
* and send the text block only. */
|
|
491
|
+
respond(envelope) {
|
|
492
|
+
return textResult(JSON.stringify(envelopeSchema.parse(envelope)));
|
|
493
|
+
}
|
|
494
|
+
async produce(args, routeCursor) {
|
|
495
|
+
switch (args.read) {
|
|
496
|
+
case 'summary':
|
|
497
|
+
return this.summary(args);
|
|
498
|
+
case 'history':
|
|
499
|
+
return this.history(args, routeCursor);
|
|
500
|
+
case 'changes':
|
|
501
|
+
return this.changes(args);
|
|
502
|
+
case 'files':
|
|
503
|
+
return this.files(args);
|
|
504
|
+
case 'commits':
|
|
505
|
+
return this.commits(args);
|
|
506
|
+
case 'commit':
|
|
507
|
+
return this.commit(args);
|
|
508
|
+
case 'handoff':
|
|
509
|
+
return this.handoff(args);
|
|
510
|
+
case 'repo':
|
|
511
|
+
return this.repo(args);
|
|
512
|
+
case 'repo_changes':
|
|
513
|
+
return this.repoChanges(args);
|
|
514
|
+
case 'repo_commit':
|
|
515
|
+
return this.repoCommit(args);
|
|
516
|
+
default:
|
|
517
|
+
return this.github(args);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ---- scopes and runs ----
|
|
521
|
+
/** The bound project as ownership sees it, with no run in reach — cursors and GitHub reads. */
|
|
522
|
+
projectScope(run) {
|
|
523
|
+
return ownershipScope({
|
|
524
|
+
root: this.project.root,
|
|
525
|
+
// The one record the SERVICE answered for this project — never a second store.
|
|
526
|
+
store: {
|
|
527
|
+
getRun: (id) => (run && run.id === id ? run : undefined),
|
|
528
|
+
listRuns: () => (run ? [run] : []),
|
|
529
|
+
},
|
|
530
|
+
automationStore: { get: () => undefined, latestReceipts: () => new Map() },
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** The run, from the bound project's own route, with its working directory proved to be ours. */
|
|
534
|
+
async openRun(runId) {
|
|
535
|
+
const got = await this.adapter.getRun(runId);
|
|
536
|
+
if (!got.ok) {
|
|
537
|
+
return { ok: false, result: errorResult(got.status === 404 || got.status === 400 ? NOT_FOUND : got.error) };
|
|
538
|
+
}
|
|
539
|
+
const parsed = apiRunSchema.safeParse(got.value);
|
|
540
|
+
if (!parsed.success)
|
|
541
|
+
return { ok: false, result: errorResult('the service answered an unexpected task shape') };
|
|
542
|
+
const run = parsed.data;
|
|
543
|
+
const scope = this.projectScope(run);
|
|
544
|
+
// Lexical containment first: a record naming another tree is refused before any path is touched.
|
|
545
|
+
const owned = ownRun(scope, run.id);
|
|
546
|
+
if (!owned.ok)
|
|
547
|
+
return { ok: false, result: errorResult(owned.message) };
|
|
548
|
+
const gone = run.worktree !== false && (run.worktreePath === undefined || !existsSync(run.worktreePath));
|
|
549
|
+
if (gone)
|
|
550
|
+
return { ok: true, view: { run, scope, dir: null } };
|
|
551
|
+
const dir = await ownWorkingDirectory(scope, run.id);
|
|
552
|
+
if (!dir.ok)
|
|
553
|
+
return { ok: false, result: errorResult(dir.message) };
|
|
554
|
+
return { ok: true, view: { run, scope, dir: dir.value.directory } };
|
|
555
|
+
}
|
|
556
|
+
runParam(run) {
|
|
557
|
+
return { ...this.scopeParam, id: run.id };
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Read `fetch` between two HEAD reads. Equal → the answer describes that head. A tree that
|
|
561
|
+
* moved is read once more, then refused rather than labelled with a sha it may not describe.
|
|
562
|
+
*/
|
|
563
|
+
async atHead(dir, fetch) {
|
|
564
|
+
const git = gitIn(dir);
|
|
565
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
566
|
+
const before = await commitSha(git, 'HEAD');
|
|
567
|
+
const value = await fetch();
|
|
568
|
+
const after = await commitSha(git, 'HEAD');
|
|
569
|
+
if (before === after)
|
|
570
|
+
return { ok: true, head: after, value };
|
|
571
|
+
}
|
|
572
|
+
return { ok: false };
|
|
573
|
+
}
|
|
574
|
+
unavailable(args, status, reason, extra = {}) {
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
result: this.respond({
|
|
578
|
+
read: args.read,
|
|
579
|
+
...(args.runId ? { runId: args.runId } : {}),
|
|
580
|
+
evidence: 'unavailable',
|
|
581
|
+
status,
|
|
582
|
+
reason,
|
|
583
|
+
...extra,
|
|
584
|
+
}),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** The route refused, or the worktree is gone: surface the service's own status and words (§ 4). */
|
|
588
|
+
refused(args, answer, run) {
|
|
589
|
+
if (answer.status === 404)
|
|
590
|
+
return { ok: false, result: errorResult(NOT_FOUND) };
|
|
591
|
+
if (answer.status === 400)
|
|
592
|
+
return { ok: false, result: errorResult(`read '${args.read}' was refused: ${errorOf(answer)}`) };
|
|
593
|
+
if (answer.status >= 500)
|
|
594
|
+
return { ok: false, result: errorResult(`read '${args.read}' failed inside xezar; the cockpit's log has the details.`) };
|
|
595
|
+
const reclaimed = run?.worktreeReclaimedAt
|
|
596
|
+
? ` The worktree was reclaimed at ${run.worktreeReclaimedAt}; its branch is kept.`
|
|
597
|
+
: '';
|
|
598
|
+
return this.unavailable(args, answer.status, `${errorOf(answer)}${reclaimed}`);
|
|
599
|
+
}
|
|
600
|
+
freshness(args, head) {
|
|
601
|
+
if (!args.expectedHeadSha)
|
|
602
|
+
return {};
|
|
603
|
+
const expected = args.expectedHeadSha.toLowerCase();
|
|
604
|
+
return { freshness: head !== null && head.startsWith(expected) ? 'current' : 'stale' };
|
|
605
|
+
}
|
|
606
|
+
baseFor(args, revision, notes = []) {
|
|
607
|
+
return {
|
|
608
|
+
read: args.read,
|
|
609
|
+
...(args.runId ? { runId: args.runId } : {}),
|
|
610
|
+
revision,
|
|
611
|
+
...this.freshness(args, revision?.headSha ?? null),
|
|
612
|
+
...(notes.length ? { notes } : {}),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/** Open the run and require its working directory, answering the route's own refusal when it
|
|
616
|
+
* is gone — the route is still asked, so the 409 and its words are the cockpit's. */
|
|
617
|
+
async runWithTree(args, probe) {
|
|
618
|
+
const opened = await this.openRun(args.runId);
|
|
619
|
+
if (!opened.ok)
|
|
620
|
+
return opened;
|
|
621
|
+
const { view } = opened;
|
|
622
|
+
if (view.dir === null) {
|
|
623
|
+
const answer = await probe(view.run);
|
|
624
|
+
const refusal = answer.status === 200
|
|
625
|
+
? this.unavailable(args, 409, "The task's working directory changed while it was being read. Read it again.")
|
|
626
|
+
: this.refused(args, answer, view.run);
|
|
627
|
+
return { ok: false, result: refusal.result };
|
|
628
|
+
}
|
|
629
|
+
return { ok: true, view: { ...view, dir: view.dir } };
|
|
630
|
+
}
|
|
631
|
+
// ---- task reads ----
|
|
632
|
+
async summary(args) {
|
|
633
|
+
const opened = await this.openRun(args.runId);
|
|
634
|
+
if (!opened.ok)
|
|
635
|
+
return opened;
|
|
636
|
+
const { run, dir } = opened.view;
|
|
637
|
+
const git = dir ? gitIn(dir) : null;
|
|
638
|
+
const head = git ? await commitSha(git, 'HEAD') : null;
|
|
639
|
+
const revision = git && head ? { headSha: head, uncommitted: await isDirty(git) } : null;
|
|
640
|
+
const notes = [NOTES.done];
|
|
641
|
+
if (run.diffStat)
|
|
642
|
+
notes.push(NOTES.diffStat);
|
|
643
|
+
if (!revision)
|
|
644
|
+
notes.push(NOTES.noWorkingTree);
|
|
645
|
+
else if (revision.uncommitted)
|
|
646
|
+
notes.push(NOTES.uncommitted);
|
|
647
|
+
// A slim projection (D-05 § 6.8): never the prompt, the system prompt, the workflow definition
|
|
648
|
+
// or the agent account.
|
|
649
|
+
const data = {
|
|
650
|
+
id: run.id,
|
|
651
|
+
title: run.titleSummary ?? run.title,
|
|
652
|
+
status: run.status,
|
|
653
|
+
workflow: run.workflow,
|
|
654
|
+
createdAt: run.createdAt,
|
|
655
|
+
...pick(run, ['startedAt', 'finishedAt', 'error', 'branch', 'baseBranch', 'worktreeReclaimedAt', 'model', 'runner']),
|
|
656
|
+
worktree: run.worktree === false ? 'in-place' : 'isolated',
|
|
657
|
+
workingTreeAvailable: dir !== null,
|
|
658
|
+
...(run.diffStat ? { diffStat: { ...run.diffStat, measuredAtSha: null } } : {}),
|
|
659
|
+
...pick(run, ['pullRequestUrl', 'referencedPullRequestUrl', 'prNumber', 'issueNumber', 'referencedIssueUrl']),
|
|
660
|
+
steps: run.steps.map((step) => ({
|
|
661
|
+
id: step.id,
|
|
662
|
+
name: step.name,
|
|
663
|
+
kind: step.kind,
|
|
664
|
+
status: step.status,
|
|
665
|
+
iterations: step.iterations,
|
|
666
|
+
...pick(step, ['startedAt', 'finishedAt', 'error']),
|
|
667
|
+
})),
|
|
668
|
+
};
|
|
669
|
+
return { ok: true, evidence: { base: this.baseFor(args, revision, notes), data, shape: { lists: ['steps'] } } };
|
|
670
|
+
}
|
|
671
|
+
async history(args, routeCursor) {
|
|
672
|
+
const opened = await this.openRun(args.runId);
|
|
673
|
+
if (!opened.ok)
|
|
674
|
+
return opened;
|
|
675
|
+
const { run, dir } = opened.view;
|
|
676
|
+
const answer = await answerOf(this.api.runs[':id'].history.$get({ param: this.runParam(run), query: routeCursor ? { cursor: routeCursor } : {} }));
|
|
677
|
+
if (answer.status !== 200)
|
|
678
|
+
return this.refused(args, answer, run);
|
|
679
|
+
const page = runHistoryPageSchema.safeParse(jsonOf(answer));
|
|
680
|
+
if (!page.success)
|
|
681
|
+
return { ok: false, result: errorResult('the service answered an unexpected history shape') };
|
|
682
|
+
const head = dir ? await commitSha(gitIn(dir), 'HEAD') : null;
|
|
683
|
+
const revision = head ? { headSha: head } : null;
|
|
684
|
+
const { olderCursor, newerCursor: _newer, liveCursor: _live, ...data } = page.data;
|
|
685
|
+
return {
|
|
686
|
+
ok: true,
|
|
687
|
+
evidence: {
|
|
688
|
+
base: this.baseFor(args, revision),
|
|
689
|
+
data,
|
|
690
|
+
shape: { lists: ['events'] },
|
|
691
|
+
...(page.data.hasOlder && olderCursor ? { olderRouteCursor: olderCursor } : {}),
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async changes(args) {
|
|
696
|
+
const probe = (run) => answerOf(this.api.runs[':id'].changes.$get({ param: this.runParam(run) }));
|
|
697
|
+
const target = await this.runWithTree(args, probe);
|
|
698
|
+
if (!target.ok)
|
|
699
|
+
return target;
|
|
700
|
+
const { run, dir } = target.view;
|
|
701
|
+
const git = gitIn(dir);
|
|
702
|
+
const read = await this.atHead(dir, async () => {
|
|
703
|
+
const answer = await probe(run);
|
|
704
|
+
// The anchor this diff was measured from: the ONE rule, fed the run's branch AND its start,
|
|
705
|
+
// exactly as the route feeds `collectChanges` (AGENTS.md § Git/worktree; #591, #751).
|
|
706
|
+
const baseBranch = run.baseBranch ?? 'HEAD';
|
|
707
|
+
const anchor = isSafeGitRef(baseBranch)
|
|
708
|
+
? await resolveTaskDiffBase(git, baseBranch, { taskBranch: run.branch, runStartedAt: run.startedAt })
|
|
709
|
+
: null;
|
|
710
|
+
return { answer, anchor };
|
|
711
|
+
});
|
|
712
|
+
if (!read.ok)
|
|
713
|
+
return this.unavailable(args, 409, MOVED);
|
|
714
|
+
const { answer, anchor } = read.value;
|
|
715
|
+
if (answer.status !== 200)
|
|
716
|
+
return this.refused(args, answer, run);
|
|
717
|
+
const payload = changesPayloadSchema.safeParse(jsonOf(answer));
|
|
718
|
+
if (!payload.success || !read.head)
|
|
719
|
+
return { ok: false, result: errorResult('the service answered an unexpected changes shape') };
|
|
720
|
+
const baseSha = anchor ? await commitSha(git, anchor.base) : null;
|
|
721
|
+
const revision = {
|
|
722
|
+
headSha: read.head,
|
|
723
|
+
uncommitted: await isDirty(git),
|
|
724
|
+
...(baseSha ? { baseSha, anchor: 'task-diff-base' } : {}),
|
|
725
|
+
...(anchor?.repointedHead ? { repointedHead: anchor.repointedHead } : {}),
|
|
726
|
+
};
|
|
727
|
+
return {
|
|
728
|
+
ok: true,
|
|
729
|
+
evidence: {
|
|
730
|
+
base: this.baseFor(args, revision, revision.uncommitted ? [NOTES.uncommitted] : []),
|
|
731
|
+
data: payload.data,
|
|
732
|
+
shape: { lists: ['files'] },
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
async files(args) {
|
|
737
|
+
const probeFor = (run) => (path) => answerOf(this.api.runs[':id'].files.$get({ param: this.runParam(run), query: { path } }));
|
|
738
|
+
const target = await this.runWithTree(args, (run) => probeFor(run)(''));
|
|
739
|
+
if (!target.ok)
|
|
740
|
+
return target;
|
|
741
|
+
const { run, scope, dir } = target.view;
|
|
742
|
+
const probe = probeFor(run);
|
|
743
|
+
// A-04: absolute, `..`, `.git`, `.local` and any symlinked component are refused BEFORE any
|
|
744
|
+
// content is read. The route keeps its own traversal, symlink and size checks behind this.
|
|
745
|
+
const owned = await ownWorktreeFile(scope, run.id, args.path ?? '');
|
|
746
|
+
if (!owned.ok)
|
|
747
|
+
return { ok: false, result: errorResult(owned.message) };
|
|
748
|
+
const path = owned.value.path;
|
|
749
|
+
const git = gitIn(dir);
|
|
750
|
+
const read = await this.atHead(dir, () => probe(path));
|
|
751
|
+
if (!read.ok)
|
|
752
|
+
return this.unavailable(args, 409, MOVED);
|
|
753
|
+
const answer = read.value;
|
|
754
|
+
if (answer.status !== 200)
|
|
755
|
+
return this.refused(args, answer, run);
|
|
756
|
+
const entry = worktreeEntrySchema.safeParse(jsonOf(answer));
|
|
757
|
+
if (!entry.success || !read.head)
|
|
758
|
+
return { ok: false, result: errorResult('the service answered an unexpected file shape') };
|
|
759
|
+
const revision = { headSha: read.head };
|
|
760
|
+
if (entry.data.type === 'dir') {
|
|
761
|
+
return {
|
|
762
|
+
ok: true,
|
|
763
|
+
evidence: { base: this.baseFor(args, { ...revision, uncommitted: await isDirty(git, path || '.') }), data: entry.data, shape: { lists: ['entries'] } },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const file = entry.data;
|
|
767
|
+
const pathRevision = { ...revision, pathClean: !(await isDirty(git, path)) };
|
|
768
|
+
const ignored = (await git(['check-ignore', '-q', '--', path])).ok;
|
|
769
|
+
const common = { type: 'file', path: file.path, size: file.size };
|
|
770
|
+
// The cockpit's own order (`previewKind`): too large, then image, then binary, then text.
|
|
771
|
+
let data;
|
|
772
|
+
let shape = { whole: true };
|
|
773
|
+
const notes = [];
|
|
774
|
+
if (ignored) {
|
|
775
|
+
data = { ...common, state: 'withheld' };
|
|
776
|
+
notes.push(NOTES.withheld);
|
|
777
|
+
}
|
|
778
|
+
else if (file.tooLarge) {
|
|
779
|
+
data = { ...common, state: 'too-large' };
|
|
780
|
+
}
|
|
781
|
+
else if (imageMimeType(file.path) !== null) {
|
|
782
|
+
data = { ...common, state: 'image', mimeType: imageMimeType(file.path) };
|
|
783
|
+
}
|
|
784
|
+
else if (file.binary) {
|
|
785
|
+
data = { ...common, state: 'binary' };
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
data = { ...common, state: 'text', content: file.content ?? '' };
|
|
789
|
+
shape = { text: 'content' };
|
|
790
|
+
}
|
|
791
|
+
return { ok: true, evidence: { base: this.baseFor(args, pathRevision, notes), data, shape } };
|
|
792
|
+
}
|
|
793
|
+
async commits(args) {
|
|
794
|
+
const probe = (run) => answerOf(this.api.runs[':id'].commits.$get({ param: this.runParam(run) }));
|
|
795
|
+
const target = await this.runWithTree(args, probe);
|
|
796
|
+
if (!target.ok)
|
|
797
|
+
return target;
|
|
798
|
+
const { run, dir } = target.view;
|
|
799
|
+
const git = gitIn(dir);
|
|
800
|
+
const baseBranch = run.baseBranch ?? 'HEAD';
|
|
801
|
+
const read = await this.atHead(dir, async () => {
|
|
802
|
+
const answer = await probe(run);
|
|
803
|
+
// The route's own anchor (C-6): `merge-base <baseBranch> HEAD`, falling back to the name.
|
|
804
|
+
const mergeBase = isSafeGitRef(baseBranch) ? await git(['merge-base', baseBranch, 'HEAD']) : { ok: false, stdout: '' };
|
|
805
|
+
const listBase = mergeBase.ok && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : baseBranch;
|
|
806
|
+
const anchor = isSafeGitRef(baseBranch)
|
|
807
|
+
? await resolveTaskDiffBase(git, baseBranch, { taskBranch: run.branch, runStartedAt: run.startedAt })
|
|
808
|
+
: null;
|
|
809
|
+
const log = isSafeGitRef(listBase) ? await git(['log', '--format=%H%x1f%cI', `${listBase}..HEAD`]) : { ok: false, stdout: '' };
|
|
810
|
+
return { answer, listBase, anchor, log };
|
|
811
|
+
});
|
|
812
|
+
if (!read.ok)
|
|
813
|
+
return this.unavailable(args, 409, MOVED);
|
|
814
|
+
const { answer, listBase, anchor, log } = read.value;
|
|
815
|
+
if (answer.status !== 200)
|
|
816
|
+
return this.refused(args, answer, run);
|
|
817
|
+
const payload = runCommitsResponseSchema.safeParse(jsonOf(answer));
|
|
818
|
+
if (!payload.success || !read.head)
|
|
819
|
+
return { ok: false, result: errorResult('the service answered an unexpected commits shape') };
|
|
820
|
+
const when = new Map(log.stdout
|
|
821
|
+
.split('\n')
|
|
822
|
+
.filter(Boolean)
|
|
823
|
+
.map((line) => line.split('\x1f')));
|
|
824
|
+
const baseSha = await commitSha(git, listBase);
|
|
825
|
+
const taskDiffBaseSha = anchor ? await commitSha(git, anchor.base) : null;
|
|
826
|
+
const revision = {
|
|
827
|
+
headSha: read.head,
|
|
828
|
+
...(baseSha ? { baseSha, anchor: 'merge-base' } : {}),
|
|
829
|
+
...(taskDiffBaseSha ? { taskDiffBaseSha } : {}),
|
|
830
|
+
...(anchor?.repointedHead ? { repointedHead: anchor.repointedHead } : {}),
|
|
831
|
+
};
|
|
832
|
+
const notes = baseSha && taskDiffBaseSha && baseSha !== taskDiffBaseSha ? [NOTES.commitsAnchor] : [];
|
|
833
|
+
const data = {
|
|
834
|
+
commits: payload.data.commits.map((commit) => {
|
|
835
|
+
const at = when.get(commit.sha);
|
|
836
|
+
return at ? { ...commit, committedAt: at } : commit;
|
|
837
|
+
}),
|
|
838
|
+
};
|
|
839
|
+
return { ok: true, evidence: { base: this.baseFor(args, revision, notes), data, shape: { lists: ['commits'] } } };
|
|
840
|
+
}
|
|
841
|
+
async commit(args) {
|
|
842
|
+
const sha = args.sha.toLowerCase();
|
|
843
|
+
const probe = (run) => answerOf(this.api.runs[':id'].commit[':sha'].$get({ param: { ...this.runParam(run), sha } }));
|
|
844
|
+
const target = await this.runWithTree(args, probe);
|
|
845
|
+
if (!target.ok)
|
|
846
|
+
return target;
|
|
847
|
+
const { run, dir } = target.view;
|
|
848
|
+
return this.commitEvidence(args, dir, () => probe(run), run);
|
|
849
|
+
}
|
|
850
|
+
async commitEvidence(args, dir, probe, run) {
|
|
851
|
+
const git = gitIn(dir);
|
|
852
|
+
const read = await this.atHead(dir, probe);
|
|
853
|
+
if (!read.ok)
|
|
854
|
+
return this.unavailable(args, 409, MOVED);
|
|
855
|
+
const answer = read.value;
|
|
856
|
+
if (answer.status !== 200)
|
|
857
|
+
return this.refused(args, answer, run);
|
|
858
|
+
const payload = repoCommitPayloadSchema.safeParse(jsonOf(answer));
|
|
859
|
+
if (!payload.success || !read.head)
|
|
860
|
+
return { ok: false, result: errorResult('the service answered an unexpected commit shape') };
|
|
861
|
+
const full = payload.data.sha;
|
|
862
|
+
const at = FULL_SHA.test(full) ? await committedAt(git, full) : undefined;
|
|
863
|
+
const reachable = FULL_SHA.test(full) ? (await git(['merge-base', '--is-ancestor', full, 'HEAD'])).ok : false;
|
|
864
|
+
const revision = {
|
|
865
|
+
headSha: read.head,
|
|
866
|
+
commitSha: full,
|
|
867
|
+
...(at ? { committedAt: at } : {}),
|
|
868
|
+
reachableFromHead: reachable,
|
|
869
|
+
};
|
|
870
|
+
return { ok: true, evidence: { base: this.baseFor(args, revision), data: payload.data, shape: { lists: ['files'] } } };
|
|
871
|
+
}
|
|
872
|
+
async handoff(args) {
|
|
873
|
+
const opened = await this.openRun(args.runId);
|
|
874
|
+
if (!opened.ok)
|
|
875
|
+
return opened;
|
|
876
|
+
const { run, dir } = opened.view;
|
|
877
|
+
const answer = await answerOf(this.api.runs[':id'].handoff.$get({ param: this.runParam(run) }));
|
|
878
|
+
if (answer.status !== 200)
|
|
879
|
+
return this.refused(args, answer, run);
|
|
880
|
+
// The journal is not versioned by git; the head it sits beside is the context it describes.
|
|
881
|
+
const head = dir ? await commitSha(gitIn(dir), 'HEAD') : null;
|
|
882
|
+
return {
|
|
883
|
+
ok: true,
|
|
884
|
+
evidence: {
|
|
885
|
+
base: this.baseFor(args, head ? { headSha: head } : null, head ? [] : [NOTES.noWorkingTree]),
|
|
886
|
+
data: { markdown: answer.body },
|
|
887
|
+
shape: { text: 'markdown' },
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
// ---- repository reads (the project's main checkout, M-10) ----
|
|
892
|
+
async repo(args) {
|
|
893
|
+
const root = this.project.root;
|
|
894
|
+
const read = await this.atHead(root, () => answerOf(this.api.repo.$get({ param: this.scopeParam })));
|
|
895
|
+
if (!read.ok)
|
|
896
|
+
return this.unavailable(args, 409, MOVED);
|
|
897
|
+
if (read.value.status !== 200)
|
|
898
|
+
return this.refused(args, read.value);
|
|
899
|
+
const payload = repoResponseSchema.safeParse(jsonOf(read.value));
|
|
900
|
+
if (!payload.success)
|
|
901
|
+
return { ok: false, result: errorResult('the service answered an unexpected repository shape') };
|
|
902
|
+
const data = { ...payload.data };
|
|
903
|
+
if (payload.data.info?.remote)
|
|
904
|
+
data.info = { ...payload.data.info, remote: withoutUserinfo(payload.data.info.remote) };
|
|
905
|
+
const revision = read.head ? { headSha: read.head, uncommitted: await isDirty(gitIn(root)) } : null;
|
|
906
|
+
return { ok: true, evidence: { base: this.baseFor(args, revision), data, shape: { lists: ['status', 'log', 'branches'] } } };
|
|
907
|
+
}
|
|
908
|
+
async repoChanges(args) {
|
|
909
|
+
const root = this.project.root;
|
|
910
|
+
const read = await this.atHead(root, () => answerOf(this.api.repo.changes.$get({ param: this.scopeParam })));
|
|
911
|
+
if (!read.ok)
|
|
912
|
+
return this.unavailable(args, 409, MOVED);
|
|
913
|
+
if (read.value.status !== 200)
|
|
914
|
+
return this.refused(args, read.value);
|
|
915
|
+
const payload = changesPayloadSchema.safeParse(jsonOf(read.value));
|
|
916
|
+
if (!payload.success || !read.head)
|
|
917
|
+
return { ok: false, result: errorResult('the service answered an unexpected changes shape') };
|
|
918
|
+
// The main checkout's working diff is measured against HEAD itself.
|
|
919
|
+
const revision = { headSha: read.head, uncommitted: await isDirty(gitIn(root)), baseSha: read.head, anchor: 'head' };
|
|
920
|
+
return { ok: true, evidence: { base: this.baseFor(args, revision), data: payload.data, shape: { lists: ['files'] } } };
|
|
921
|
+
}
|
|
922
|
+
async repoCommit(args) {
|
|
923
|
+
const sha = args.sha.toLowerCase();
|
|
924
|
+
return this.commitEvidence(args, this.project.root, () => answerOf(this.api.repo.commit[':sha'].$get({ param: { ...this.scopeParam, sha }, query: { structured: '1' } })));
|
|
925
|
+
}
|
|
926
|
+
// ---- GitHub reads (M-11: the repository is the bound project's — no argument names one) ----
|
|
927
|
+
async github(args) {
|
|
928
|
+
const refresh = args.refresh ? '1' : undefined;
|
|
929
|
+
const q = (query) => Object.fromEntries(Object.entries(query).filter((entry) => entry[1] !== undefined));
|
|
930
|
+
const github = this.api.github;
|
|
931
|
+
let answer;
|
|
932
|
+
let schema;
|
|
933
|
+
let shape = { whole: true };
|
|
934
|
+
const notes = [];
|
|
935
|
+
switch (args.read) {
|
|
936
|
+
case 'github':
|
|
937
|
+
answer = await answerOf(github.$get({ param: this.scopeParam, query: q({ limit: args.limit ? String(args.limit) : undefined, refresh }) }));
|
|
938
|
+
schema = githubDataSchema;
|
|
939
|
+
shape = { lists: ['issues', 'prs'] };
|
|
940
|
+
break;
|
|
941
|
+
case 'github_comments':
|
|
942
|
+
answer = await answerOf(github.comments[':kind'][':number'].$get({
|
|
943
|
+
param: { ...this.scopeParam, kind: args.kind, number: String(args.number) },
|
|
944
|
+
query: q({ refresh }),
|
|
945
|
+
}));
|
|
946
|
+
schema = githubCommentsDataSchema;
|
|
947
|
+
shape = { lists: ['comments', 'events'] };
|
|
948
|
+
break;
|
|
949
|
+
case 'github_checks':
|
|
950
|
+
answer = await answerOf(github.checks.$get({ param: this.scopeParam, query: { prs: args.prs.join(',') } }));
|
|
951
|
+
schema = githubChecksDataSchema;
|
|
952
|
+
notes.push(NOTES.checksAbsent);
|
|
953
|
+
break;
|
|
954
|
+
case 'github_search':
|
|
955
|
+
answer = await answerOf(github.search.$get({
|
|
956
|
+
param: this.scopeParam,
|
|
957
|
+
query: { kind: args.kind, q: args.query, ...(args.limit ? { limit: args.limit } : {}) },
|
|
958
|
+
}));
|
|
959
|
+
schema = githubSearchDataSchema;
|
|
960
|
+
shape = { lists: ['items'] };
|
|
961
|
+
break;
|
|
962
|
+
case 'github_ref_status':
|
|
963
|
+
answer = await answerOf(github['ref-status'].$get({
|
|
964
|
+
param: this.scopeParam,
|
|
965
|
+
query: q({ prs: args.prs?.join(','), issues: args.issues?.join(',') }),
|
|
966
|
+
}));
|
|
967
|
+
schema = githubRefStatusDataSchema;
|
|
968
|
+
break;
|
|
969
|
+
case 'pr_merge_state':
|
|
970
|
+
answer = await answerOf(github.prs[':number']['merge-state'].$get({ param: { ...this.scopeParam, number: String(args.number) }, query: q({ refresh }) }));
|
|
971
|
+
schema = githubPrMergeStateResponseSchema;
|
|
972
|
+
notes.push(NOTES.checks);
|
|
973
|
+
break;
|
|
974
|
+
case 'pr_changes':
|
|
975
|
+
answer = await answerOf(github.prs[':number'].changes.$get({ param: { ...this.scopeParam, number: String(args.number) }, query: q({ refresh }) }));
|
|
976
|
+
schema = githubPrChangesDataSchema;
|
|
977
|
+
shape = { lists: ['files'] };
|
|
978
|
+
break;
|
|
979
|
+
default:
|
|
980
|
+
return { ok: false, result: errorResult(`unknown read: ${String(args.read)}`) };
|
|
981
|
+
}
|
|
982
|
+
if (answer.status !== 200)
|
|
983
|
+
return this.refused(args, answer);
|
|
984
|
+
// The union passes through as the route sent it: `{available: false, reason}` stays that, and
|
|
985
|
+
// `truncated` / `patchUnavailableReason` stay on the payload (N-06, F-10).
|
|
986
|
+
const payload = schema.safeParse(jsonOf(answer));
|
|
987
|
+
if (!payload.success)
|
|
988
|
+
return { ok: false, result: errorResult('the service answered an unexpected GitHub shape') };
|
|
989
|
+
const data = payload.data;
|
|
990
|
+
if (data.available === false)
|
|
991
|
+
shape = { whole: true };
|
|
992
|
+
let forgeRevision;
|
|
993
|
+
let revision;
|
|
994
|
+
if ((args.read === 'pr_merge_state' || args.read === 'pr_changes') && args.runId !== undefined) {
|
|
995
|
+
const opened = await this.openRun(args.runId);
|
|
996
|
+
if (!opened.ok)
|
|
997
|
+
return opened;
|
|
998
|
+
const runHead = opened.view.dir ? await commitSha(gitIn(opened.view.dir), 'HEAD') : null;
|
|
999
|
+
const prHead = prHeadOf(data);
|
|
1000
|
+
forgeRevision = {
|
|
1001
|
+
prHeadSha: prHead,
|
|
1002
|
+
runHeadSha: runHead,
|
|
1003
|
+
match: prHead && runHead ? (prHead === runHead ? 'same' : 'different') : 'unknown',
|
|
1004
|
+
};
|
|
1005
|
+
revision = runHead ? { headSha: runHead } : null;
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
ok: true,
|
|
1009
|
+
evidence: {
|
|
1010
|
+
base: {
|
|
1011
|
+
read: args.read,
|
|
1012
|
+
...(args.runId ? { runId: args.runId } : {}),
|
|
1013
|
+
...(revision !== undefined ? { revision } : {}),
|
|
1014
|
+
...(revision !== undefined ? this.freshness(args, revision?.headSha ?? null) : {}),
|
|
1015
|
+
...(forgeRevision ? { forgeRevision } : {}),
|
|
1016
|
+
...(notes.length ? { notes } : {}),
|
|
1017
|
+
},
|
|
1018
|
+
data,
|
|
1019
|
+
shape,
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function prHeadOf(data) {
|
|
1025
|
+
const direct = typeof data.headSha === 'string' ? data.headSha : null;
|
|
1026
|
+
const merge = data.mergeState;
|
|
1027
|
+
const nested = merge && typeof merge.headSha === 'string' ? merge.headSha : null;
|
|
1028
|
+
return direct ?? nested;
|
|
1029
|
+
}
|
|
1030
|
+
function pick(value, keys) {
|
|
1031
|
+
const out = {};
|
|
1032
|
+
for (const key of keys)
|
|
1033
|
+
if (value[key] !== undefined)
|
|
1034
|
+
out[key] = value[key];
|
|
1035
|
+
return out;
|
|
1036
|
+
}
|
|
1037
|
+
function safeJson(text) {
|
|
1038
|
+
try {
|
|
1039
|
+
return JSON.parse(text);
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
return undefined;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
//# sourceMappingURL=results-evidence.js.map
|