@qodeca/xezar 0.13.0 → 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 +72 -39
- 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 +25 -2
- package/dist/runs/retention.js +63 -2
- 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 +599 -79
- 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-CdS8r8E2.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-BYlnf4qf.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-C2On8SlI.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-6aopqAmV.js → task-changes-CH-E81-_.js} +1 -1
- package/web/dist/assets/{task-commits-u-dwaCyw.js → task-commits-DqfkMFxp.js} +1 -1
- package/web/dist/assets/{task-files-IMOZnIUK.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-fLDKEJoB.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-BY3KTkFw.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-CqGK62Lw.js +0 -1
- package/web/dist/assets/index-3nxVmdn5.css +0 -2
- package/web/dist/assets/index-qX7n11FX.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-Bj-uG_Hz.js +0 -1
- package/web/dist/assets/task-thread-CbMPmrWq.js +0 -9
- package/web/dist/assets/utils--z9hRtl3.js +0 -64
package/dist/server/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import { Hono } from 'hono';
|
|
|
17
17
|
import { serve } from '@hono/node-server';
|
|
18
18
|
import { bodyLimit } from 'hono/body-limit';
|
|
19
19
|
import { streamSSE } from 'hono/streaming';
|
|
20
|
-
import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
|
|
20
|
+
import { jsonZodValidator, optionalJsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
|
|
21
21
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import { setWorkspaceUiStateInputSchema, } from '../contract/index.js';
|
|
@@ -36,6 +36,8 @@ import { applyProviderEnablement } from '../core/provider-availability.js';
|
|
|
36
36
|
import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
|
|
37
37
|
import { currentUsage, onUsage } from '../core/process-usage.js';
|
|
38
38
|
import { projectWorkflowsDir, loadWorkflows } from '../workflows/load.js';
|
|
39
|
+
import { reportProjectChange } from '../mcp/project-catalogs.js';
|
|
40
|
+
import { projectLeader } from '../mcp/project-leaders.js';
|
|
39
41
|
import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
|
|
40
42
|
import { planChain, slugify } from '../planner.js';
|
|
41
43
|
import { discoverSkills } from '../skills.js';
|
|
@@ -46,10 +48,15 @@ import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from
|
|
|
46
48
|
import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
|
|
47
49
|
import { readRunIndexFromDisk } from '../runs/run-index.js';
|
|
48
50
|
import { isV2WireEventType } from '../runs/ui-event-sink.js';
|
|
49
|
-
import {
|
|
51
|
+
import { mcpLeaderActionInputSchema } from '../contract/index.js';
|
|
52
|
+
import { githubPrReadyInputSchema, runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
|
|
53
|
+
// The run-mutation request shapes, each carrying the optional stale-write guard (#250).
|
|
54
|
+
import { archiveRunInputSchema, continueRunInputSchema, gitCommitInputSchema, messageInputSchema, patchRunInputSchema, pickVariantInputSchema, pinRunInputSchema, queuedMessagePatchInputSchema, runVersionGuardInputSchema, } from '../contract/index.js';
|
|
55
|
+
import { ownGroup, ownershipScope } from '../mcp/resource-ownership.js';
|
|
56
|
+
import { runVersion, staleRunWrite } from '../mcp/stale-write.js';
|
|
50
57
|
import { toPastedContent } from '../workflows/run.js';
|
|
51
58
|
import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
|
|
52
|
-
import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
|
|
59
|
+
import { isReclaimable, ownRunAndWorktree, reclaimWorktrees } from '../runs/retention.js';
|
|
53
60
|
import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
|
|
54
61
|
import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
|
|
55
62
|
import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
|
|
@@ -61,7 +68,7 @@ import { listConfigFiles } from '../agent-config/catalog.js';
|
|
|
61
68
|
import { readAccountIdentity } from '../agent-config/account-identity.js';
|
|
62
69
|
import { DEFAULT_MEMORY_LIMIT_MB, PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
|
|
63
70
|
import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
|
|
64
|
-
import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
|
|
71
|
+
import { accountHomePatch, defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
|
|
65
72
|
import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
|
|
66
73
|
import { withEnvPrefix } from '../core/shell-env.js';
|
|
67
74
|
import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
|
|
@@ -218,6 +225,46 @@ export function projectRouteManifest(app) {
|
|
|
218
225
|
const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set XEZ_FOLLOWUPS=1 to enable it';
|
|
219
226
|
/** 409 body for every automations route while GitHub automations are off (#801). */
|
|
220
227
|
const AUTOMATIONS_OFF = 'GitHub automations are disabled — set XEZ_AUTOMATIONS=1 to enable them';
|
|
228
|
+
/**
|
|
229
|
+
* How long `DELETE /projects/:id` waits on either of its project-lifecycle promises — a context
|
|
230
|
+
* still opening, then the removed context's last background writes — before answering anyway
|
|
231
|
+
* (#200). One bound per wait, applied through `withinTeardownBound`.
|
|
232
|
+
*
|
|
233
|
+
* `RunManager.dispose()` deliberately carries NO deadline — an invented one there would only
|
|
234
|
+
* move an ENOTEMPTY a few hundred milliseconds later, which is the right call for a teardown
|
|
235
|
+
* helper whose caller is about to delete the directory. An HTTP request is the opposite case:
|
|
236
|
+
* it must answer. The realistic tail is short (the 409 above already refused the removal if any
|
|
237
|
+
* run is active, and a retention sweep stops after its current iteration), but that iteration
|
|
238
|
+
* is `git worktree remove --force` + `rm -rf` + `git worktree prune`, and `git()` in
|
|
239
|
+
* `git-worktree.ts` passes no `timeout` to `execFile` — so a git blocked on `index.lock` or a
|
|
240
|
+
* stalled network mount would hang the request with no response and no recovery.
|
|
241
|
+
*
|
|
242
|
+
* Deliberately bounded HERE and not by giving `git()` a timeout: the same helper runs
|
|
243
|
+
* `git worktree add` and `git fetch`, which are legitimately slow on a large repository, so a
|
|
244
|
+
* blanket timeout would turn a slow clone into a failed run. What the bound gives up is only
|
|
245
|
+
* the tail of the guarantee: the registry entry is already gone and the context is already out
|
|
246
|
+
* of the map, so the answer stays true — a sweep that outlives it can still stamp a record, the
|
|
247
|
+
* pre-#200 behaviour, and only for a project no route can reach.
|
|
248
|
+
*/
|
|
249
|
+
const PROJECT_TEARDOWN_WAIT_MS = 5_000;
|
|
250
|
+
/**
|
|
251
|
+
* Wait for one of `DELETE /projects/:id`'s two project-lifecycle promises — the in-flight build
|
|
252
|
+
* before the running-tasks guard, the teardown after the removal — and answer anyway once
|
|
253
|
+
* `PROJECT_TEARDOWN_WAIT_MS` is up.
|
|
254
|
+
*
|
|
255
|
+
* One helper for both because the reason is the same one, and because two hand-rolled races in
|
|
256
|
+
* one handler drift: neither wait may turn a request into something that cannot answer. Both
|
|
257
|
+
* outcomes are swallowed deliberately — the route's answer does not depend on how either promise
|
|
258
|
+
* settled, and a build that lost its project rejects by design.
|
|
259
|
+
*/
|
|
260
|
+
async function withinTeardownBound(work) {
|
|
261
|
+
await Promise.race([
|
|
262
|
+
work.then(() => undefined, () => undefined),
|
|
263
|
+
new Promise((resolve) => {
|
|
264
|
+
setTimeout(resolve, PROJECT_TEARDOWN_WAIT_MS).unref?.();
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
221
268
|
/**
|
|
222
269
|
* The in-process bus for workspace-level SSE events. The registry-mutating
|
|
223
270
|
* routes (`POST /api/projects` — step 4.2, emits `project-added` for a
|
|
@@ -307,9 +354,6 @@ const startRunSchema = z
|
|
|
307
354
|
.refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
|
|
308
355
|
message: 'provide either "workflow" or "steps", not both',
|
|
309
356
|
});
|
|
310
|
-
const pickSchema = z.object({
|
|
311
|
-
runId: z.string().min(1),
|
|
312
|
-
});
|
|
313
357
|
const planSchema = z.object({
|
|
314
358
|
// Same bound as `startRunSchema.task` — this flows into `planChain` (#429).
|
|
315
359
|
task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
|
|
@@ -439,16 +483,6 @@ const uiStateSchema = z
|
|
|
439
483
|
dismissedSkillsBanner: z.boolean().optional(),
|
|
440
484
|
})
|
|
441
485
|
.passthrough();
|
|
442
|
-
// Editable titles (#389), and the initial prompt while the run is still queued
|
|
443
|
-
// (#472 — rejected with 409 on any other status by the handler).
|
|
444
|
-
const patchRunSchema = z.object({
|
|
445
|
-
title: z.string().trim().min(1).max(300).optional(),
|
|
446
|
-
task: z.string().trim().min(1).max(100_000).optional(),
|
|
447
|
-
});
|
|
448
|
-
// Session commit (redesign R5 — §"Git/session API additions").
|
|
449
|
-
const gitCommitSchema = z.object({
|
|
450
|
-
message: z.string().trim().min(1, 'must not be empty').max(5_000),
|
|
451
|
-
});
|
|
452
486
|
// "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
|
|
453
487
|
// narrows the target's own worktree/repo-root default to one file — used by the diff pane's
|
|
454
488
|
// "open in default app" action for images. Containment is re-checked server-side via
|
|
@@ -458,27 +492,6 @@ const openInSchema = z.object({
|
|
|
458
492
|
target: z.string().trim().min(1, 'target required').max(200),
|
|
459
493
|
path: z.string().max(1_000).optional(),
|
|
460
494
|
});
|
|
461
|
-
// Attachment-carrying bodies validate with the CONTRACT's `attachmentInputSchema` (#950) —
|
|
462
|
-
// images plus the short PDF/TXT/MD allowlist, ~5 MB each once base64-decoded. Imported rather
|
|
463
|
-
// than mirrored here, so the wire cannot drift from what the cockpit compiles against.
|
|
464
|
-
const messageSchema = z
|
|
465
|
-
.object({
|
|
466
|
-
text: z.string().max(100_000).default(''),
|
|
467
|
-
images: z.array(attachmentInputSchema).max(4).default([]),
|
|
468
|
-
})
|
|
469
|
-
.refine((m) => m.text.trim().length > 0 || m.images.length > 0, {
|
|
470
|
-
message: 'message needs text or at least one attachment',
|
|
471
|
-
});
|
|
472
|
-
// PATCH semantics are load-bearing here: an omitted field keeps its current value.
|
|
473
|
-
// In particular, the cockpit edits text without re-uploading existing attachments.
|
|
474
|
-
const queuedMessagePatchSchema = z
|
|
475
|
-
.object({
|
|
476
|
-
text: z.string().max(100_000).optional(),
|
|
477
|
-
images: z.array(attachmentInputSchema).max(4).optional(),
|
|
478
|
-
})
|
|
479
|
-
.refine((m) => m.text !== undefined || m.images !== undefined, {
|
|
480
|
-
message: 'message edit needs text or attachments',
|
|
481
|
-
});
|
|
482
495
|
// Queued prompt stack bounds (#472). The per-message bounds mirror `messageSchema`
|
|
483
496
|
// above; the one that actually matters is the FOLDED total, because 20 messages of
|
|
484
497
|
// 100 000 chars each would otherwise compose a ~2 M-character {{task}}.
|
|
@@ -495,21 +508,6 @@ function foldedLength(task, stack) {
|
|
|
495
508
|
.filter((part) => part.length > 0)
|
|
496
509
|
.join('\n\n').length;
|
|
497
510
|
}
|
|
498
|
-
// "Continue"/"Send back" body (spec 003 / #401): every field optional, so an empty POST reopens
|
|
499
|
-
// the last session on the run's current backend (backward compat). A runner/model/account override
|
|
500
|
-
// lets the follow-up composer choose which engine handles the continuation. `text` stays bounded
|
|
501
|
-
// like the live-session message `text` (#429), and `images` like a live-session message's — the
|
|
502
|
-
// follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
|
|
503
|
-
// session rather than being silently dropped.
|
|
504
|
-
const continueSchema = z.object({
|
|
505
|
-
text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
|
|
506
|
-
images: z.array(attachmentInputSchema).max(4).optional(),
|
|
507
|
-
runner: z.enum(RUNNER_IDS).optional(),
|
|
508
|
-
model: z.string().max(200).optional(),
|
|
509
|
-
/** Agent account for the reopened session (spec 2026-07-29-agent-profiles). Bound mirrors
|
|
510
|
-
* `POST /runs`' own `agentProfile`. Omitted = keep the account the run is already on. */
|
|
511
|
-
agentProfile: z.string().max(64).optional(),
|
|
512
|
-
});
|
|
513
511
|
// Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
|
|
514
512
|
// optional too, so an empty POST — every client before the pills and the composer — starts on
|
|
515
513
|
// the host's `defaultRunner` with no extra instructions, exactly as before. This is a START
|
|
@@ -529,17 +527,6 @@ const startTodoSchema = z
|
|
|
529
527
|
.transform((s) => (s ? s : undefined)),
|
|
530
528
|
})
|
|
531
529
|
.optional();
|
|
532
|
-
// `POST /api/runs/:id/archive` (#429) — no body archives; `{archived:false}`
|
|
533
|
-
// un-archives. A tiny schema so the route follows the safeParse convention.
|
|
534
|
-
const archiveSchema = z.object({
|
|
535
|
-
archived: z.boolean().optional(),
|
|
536
|
-
});
|
|
537
|
-
// `POST /api/v1/runs/:id/pin` (#935) — no body pins; `{pinned:false}` unpins. The archive
|
|
538
|
-
// route's shape, deliberately: it is the same kind of per-task flag, and a second spelling for
|
|
539
|
-
// "absent means do the thing" would be one more rule for a client to remember.
|
|
540
|
-
const pinSchema = z.object({
|
|
541
|
-
pinned: z.boolean().optional(),
|
|
542
|
-
});
|
|
543
530
|
// Request-body size guards (#429). A generous global cap keeps a single
|
|
544
531
|
// localhost request from being unbounded (the largest legit body is 4 pasted
|
|
545
532
|
// images at ~7 MB base64 each); the ui-state PUT gets a much tighter cap since
|
|
@@ -1453,14 +1440,14 @@ export function createApp(deps) {
|
|
|
1453
1440
|
* `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
|
|
1454
1441
|
* login's `settings.json` the file you open rather than the default account's, and it keeps the
|
|
1455
1442
|
* ids opaque and stable so the open route below never takes a path from the client.
|
|
1443
|
+
*
|
|
1444
|
+
* WHICH slot comes from `accountHomePatch`, shared with `defaultAgentProfile`'s read of the same
|
|
1445
|
+
* table. This was a run of conditional spreads that named three providers and silently did
|
|
1446
|
+
* nothing for the fourth, so a pi account would have resolved its files in the default pi home
|
|
1447
|
+
* (#329). It never surfaced only because `CONFIG_FILES` carries no pi entry yet.
|
|
1456
1448
|
*/
|
|
1457
1449
|
const accountFiles = async (profile) => {
|
|
1458
|
-
const home = {
|
|
1459
|
-
...agentHomePaths(),
|
|
1460
|
-
...(profile.provider === 'claude' ? { claude: profile.path } : {}),
|
|
1461
|
-
...(profile.provider === 'codex' ? { codex: profile.path } : {}),
|
|
1462
|
-
...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
|
|
1463
|
-
};
|
|
1450
|
+
const home = { ...agentHomePaths(), ...accountHomePatch(profile.provider, profile.path) };
|
|
1464
1451
|
const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
|
|
1465
1452
|
return Promise.all(defs.map(async (def) => {
|
|
1466
1453
|
const path = def.resolve(bootRoot, home);
|
|
@@ -1985,6 +1972,16 @@ export function createApp(deps) {
|
|
|
1985
1972
|
error: `xezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
|
|
1986
1973
|
}, 409);
|
|
1987
1974
|
}
|
|
1975
|
+
// A project whose context is still BUILDING is not a project with no runs: its store opens
|
|
1976
|
+
// with `keepLive` and `manager.recover()` re-queues or resumes every live-looking row, so
|
|
1977
|
+
// the guard below — which reads the already-built context only — would count zero for a
|
|
1978
|
+
// project that is at this moment bringing agents back, and remove it out from under them.
|
|
1979
|
+
// Let the build finish, then count. Bounded for the same reason the teardown below is: a
|
|
1980
|
+
// build that outlives the bound leaves the pre-existing answer (nothing built, zero runs)
|
|
1981
|
+
// rather than a project that can never be removed.
|
|
1982
|
+
const opening = contexts.pending(id);
|
|
1983
|
+
if (opening)
|
|
1984
|
+
await withinTeardownBound(opening);
|
|
1988
1985
|
const active = activeRunCount(id);
|
|
1989
1986
|
if (active > 0) {
|
|
1990
1987
|
return c.json({
|
|
@@ -2006,7 +2003,27 @@ export function createApp(deps) {
|
|
|
2006
2003
|
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
2007
2004
|
// In-process handles for a project no route can reach any more: store
|
|
2008
2005
|
// closed (index flushed), manager's timers and usage subscription dropped.
|
|
2009
|
-
|
|
2006
|
+
//
|
|
2007
|
+
// AWAITED (#200): the manager's promise is what settles the background writes it could not
|
|
2008
|
+
// stop synchronously — a worktree-retention sweep spawning git and stamping records. Not
|
|
2009
|
+
// awaiting it let the response go out while a sweep was still writing into the removed
|
|
2010
|
+
// project's `.local/xezar`, and a re-add inside that window could see the stale in-memory
|
|
2011
|
+
// index overwrite the fresh one. There is no run to wait for: the 409 above already refused
|
|
2012
|
+
// the removal if this process owns any.
|
|
2013
|
+
//
|
|
2014
|
+
// BOUNDED, because this is a request and not a teardown helper: `dispose()` has no deadline
|
|
2015
|
+
// by design and the git it waits on has no `execFile` timeout, so an unbounded await is a
|
|
2016
|
+
// request that can never answer. `dispose()` removed the context from the map
|
|
2017
|
+
// synchronously, so the floated remainder is invisible to every route; see
|
|
2018
|
+
// `PROJECT_TEARDOWN_WAIT_MS`. The answer is the same however it settles — the removal has
|
|
2019
|
+
// already happened, and this is only the wait for the last writes.
|
|
2020
|
+
//
|
|
2021
|
+
// Covers a context that was still BUILDING when the removal landed, too: `dispose()` ends
|
|
2022
|
+
// the registration first, so that build tears itself down instead of publishing, and the
|
|
2023
|
+
// promise awaited here is the one that settles its store and manager. The answer stays
|
|
2024
|
+
// `{ removed: true }` — the registry entry is gone, which is what the caller asked for, and
|
|
2025
|
+
// the racing build was never a context anyone could reach.
|
|
2026
|
+
await withinTeardownBound(contexts.dispose(id));
|
|
2010
2027
|
workspaceEvents.emit('project-removed', { id });
|
|
2011
2028
|
const body = { removed: true, id };
|
|
2012
2029
|
return c.json(body);
|
|
@@ -2279,6 +2296,12 @@ export function createApp(deps) {
|
|
|
2279
2296
|
* rejected for the same reason as the `rm` above: `RunStore.open` creates
|
|
2280
2297
|
* directories, and a stale `running` row left by a crashed process would
|
|
2281
2298
|
* become a 409 the user could never clear.
|
|
2299
|
+
*
|
|
2300
|
+
* "No context, no agent to strand" holds for a project nobody has touched. It does NOT hold for
|
|
2301
|
+
* a project whose context is being built RIGHT NOW — that build opened its store with
|
|
2302
|
+
* `keepLive` and its `manager.recover()` re-queues or resumes exactly the runs this guard
|
|
2303
|
+
* exists to protect — so the caller waits for an in-flight build (`contexts.pending`) before
|
|
2304
|
+
* asking, and only then is zero an answer about the project rather than about the map.
|
|
2282
2305
|
*/
|
|
2283
2306
|
const activeRunCount = (projectId) => {
|
|
2284
2307
|
const ctx = contexts.peek(projectId);
|
|
@@ -2726,6 +2749,8 @@ export function createApp(deps) {
|
|
|
2726
2749
|
const message = err instanceof Error ? err.message : String(err);
|
|
2727
2750
|
return c.json({ error: message }, 500);
|
|
2728
2751
|
}
|
|
2752
|
+
// E-05 (#252): the project leader hears of it. No catalog for this project → no row.
|
|
2753
|
+
reportProjectChange(c.get('project').id, (catalog) => catalog.workflowChanged({ name: parsed.data.name, change: 'saved' }));
|
|
2729
2754
|
return c.json({ path, name: parsed.data.name }, 201);
|
|
2730
2755
|
})
|
|
2731
2756
|
// Delete a saved workflow (spec 012 follow-up): file workflows only —
|
|
@@ -2751,6 +2776,7 @@ export function createApp(deps) {
|
|
|
2751
2776
|
catch (err) {
|
|
2752
2777
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2753
2778
|
}
|
|
2779
|
+
reportProjectChange(c.get('project').id, (catalog) => catalog.workflowChanged({ name, change: 'deleted' }));
|
|
2754
2780
|
return c.json({ ok: true, path: target });
|
|
2755
2781
|
})
|
|
2756
2782
|
// Import support for the builder (spec 012): parse + validate a pasted
|
|
@@ -3072,6 +3098,17 @@ export function createApp(deps) {
|
|
|
3072
3098
|
console.warn(`[xezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
|
|
3073
3099
|
}
|
|
3074
3100
|
};
|
|
3101
|
+
// A run whose recorded worktree is this project's own (#316). `worktreePath` is data: a copied or
|
|
3102
|
+
// hand-edited `.local/xezar` can name another project's worktree, and the delete, remove-worktree,
|
|
3103
|
+
// commit, push, PR, diff and changes routes would `rm -rf`, commit, push or run `git add -N .`
|
|
3104
|
+
// there. They look the run up through the same rule the variant pick and reclaim use (#288). An
|
|
3105
|
+
// id this project does not hold is still the plain 404. A record it DOES hold whose worktree is
|
|
3106
|
+
// elsewhere gets `FOREIGN_WORKTREE` instead: the cockpit shows the server's words as they are,
|
|
3107
|
+
// and a bare "not found" on a row the user can see would leave them with no way forward. The
|
|
3108
|
+
// sentence names no path, id or project, so it says nothing of the folder it refuses to touch,
|
|
3109
|
+
// and it is short because a toast is on screen for five seconds (`TOAST_MS` in the cockpit).
|
|
3110
|
+
const ownedRun = (project, id) => ownRunAndWorktree(ownershipScope(project), id);
|
|
3111
|
+
const FOREIGN_WORKTREE = "This task's worktree is outside this project, so xezar will not touch it from here. Archive the task to hide it from your list.";
|
|
3075
3112
|
// ---- chained family: runs lifecycle + artifacts (project-scoped) ----
|
|
3076
3113
|
const runsRoutes = new Hono()
|
|
3077
3114
|
.get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
|
|
@@ -3081,7 +3118,7 @@ export function createApp(deps) {
|
|
|
3081
3118
|
// The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
|
|
3082
3119
|
// sweep above, and under the same registration-order guard.
|
|
3083
3120
|
.post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
|
|
3084
|
-
.post('/runs/:id/archive', jsonZodValidator(
|
|
3121
|
+
.post('/runs/:id/archive', jsonZodValidator(archiveRunInputSchema, { absent: ({}) }), async (c) => {
|
|
3085
3122
|
const { store } = c.get('project');
|
|
3086
3123
|
const id = c.req.param('id');
|
|
3087
3124
|
// An empty/absent body archives (the common case); a malformed body degrades
|
|
@@ -3090,6 +3127,13 @@ export function createApp(deps) {
|
|
|
3090
3127
|
// `setArchived` itself — the bulk sweep must obey it too (spec
|
|
3091
3128
|
// 2026-08-03-auto-resume-after-usage-limit).
|
|
3092
3129
|
const parsed = { data: c.req.valid('json') };
|
|
3130
|
+
// An unknown run is a 404 before any version is compared — the answer it always got.
|
|
3131
|
+
if (!store.getRun(id))
|
|
3132
|
+
return c.json({ error: 'not found' }, 404);
|
|
3133
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3134
|
+
const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
|
|
3135
|
+
if (stale)
|
|
3136
|
+
return c.json(stale, 409);
|
|
3093
3137
|
const run = store.setArchived(id, parsed.data.archived !== false);
|
|
3094
3138
|
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
3095
3139
|
})
|
|
@@ -3097,19 +3141,32 @@ export function createApp(deps) {
|
|
|
3097
3141
|
// twin in every respect: an absent body pins (the common case), the answer is the updated
|
|
3098
3142
|
// record, and the change rides the existing `run` SSE because `setPinned` touches. No new
|
|
3099
3143
|
// event and no new response shape.
|
|
3100
|
-
.post('/runs/:id/pin', jsonZodValidator(
|
|
3144
|
+
.post('/runs/:id/pin', jsonZodValidator(pinRunInputSchema, { absent: ({}) }), (c) => {
|
|
3101
3145
|
const { store } = c.get('project');
|
|
3102
|
-
const
|
|
3146
|
+
const id = c.req.param('id');
|
|
3147
|
+
const { pinned, expectedVersion } = c.req.valid('json');
|
|
3148
|
+
// An unknown run is a 404 before any version is compared — the answer it always got.
|
|
3149
|
+
if (!store.getRun(id))
|
|
3150
|
+
return c.json({ error: 'not found' }, 404);
|
|
3151
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3152
|
+
const stale = staleRunWrite(store, id, expectedVersion);
|
|
3153
|
+
if (stale)
|
|
3154
|
+
return c.json(stale, 409);
|
|
3155
|
+
const run = store.setPinned(id, pinned !== false);
|
|
3103
3156
|
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
3104
3157
|
})
|
|
3105
3158
|
// The per-task off switch for that resume (the workspace setting is Settings → Resources).
|
|
3106
3159
|
// Idempotent: a run with nothing pending answers 200 too, because "this task will not
|
|
3107
3160
|
// resume itself" is equally true either way.
|
|
3108
|
-
.delete('/runs/:id/auto-resume', (c) => {
|
|
3161
|
+
.delete('/runs/:id/auto-resume', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
|
|
3109
3162
|
const { store, manager } = c.get('project');
|
|
3110
3163
|
const id = c.req.param('id');
|
|
3111
3164
|
if (!store.getRun(id))
|
|
3112
3165
|
return c.json({ error: 'not found' }, 404);
|
|
3166
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3167
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3168
|
+
if (stale)
|
|
3169
|
+
return c.json(stale, 409);
|
|
3113
3170
|
manager.cancelAutoResume(id);
|
|
3114
3171
|
return c.json({ cancelled: true });
|
|
3115
3172
|
})
|
|
@@ -3207,6 +3264,15 @@ export function createApp(deps) {
|
|
|
3207
3264
|
const { store } = c.get('project');
|
|
3208
3265
|
const run = store.getRun(c.req.param('id'));
|
|
3209
3266
|
return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
|
|
3267
|
+
})
|
|
3268
|
+
// The run's stale-write token (#250, D-06 § 4.2) — what a caller echoes back as
|
|
3269
|
+
// `expectedVersion` on a run mutation. Its own route rather than a field of the record above,
|
|
3270
|
+
// so the cockpit's reads neither change shape nor pay for it (it scans the run's event file).
|
|
3271
|
+
// A reader that wants both reads THIS first: an older token costs a spurious rejection at
|
|
3272
|
+
// worst, while a token newer than the record it read would let a stale write through.
|
|
3273
|
+
.get('/runs/:id/version', (c) => {
|
|
3274
|
+
const version = runVersion(c.get('project').store, c.req.param('id'));
|
|
3275
|
+
return version === undefined ? c.json({ error: 'not found' }, 404) : c.json({ version });
|
|
3210
3276
|
})
|
|
3211
3277
|
.get('/runs/:id/history', paramZodValidator(runIdParamSchema), queryZodValidator(runHistoryQuerySchema), async (c) => {
|
|
3212
3278
|
const { store, dataDir } = c.get('project');
|
|
@@ -3244,12 +3310,17 @@ export function createApp(deps) {
|
|
|
3244
3310
|
// actually displays). The auto-summarizer only ever fills an *unset*
|
|
3245
3311
|
// titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
|
|
3246
3312
|
// future auto-summary. Answers the updated record.
|
|
3247
|
-
.patch('/runs/:id', jsonZodValidator(
|
|
3313
|
+
.patch('/runs/:id', jsonZodValidator(patchRunInputSchema), async (c) => {
|
|
3248
3314
|
const { store, manager } = c.get('project');
|
|
3249
3315
|
const id = c.req.param('id');
|
|
3250
3316
|
if (!store.getRun(id))
|
|
3251
3317
|
return c.json({ error: 'not found' }, 404);
|
|
3252
3318
|
const parsed = { data: c.req.valid('json') };
|
|
3319
|
+
// The stale-write guard (#250). Everything from here to the writes below is synchronous, so
|
|
3320
|
+
// the check and both writes run in one stretch.
|
|
3321
|
+
const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
|
|
3322
|
+
if (stale)
|
|
3323
|
+
return c.json(stale, 409);
|
|
3253
3324
|
// The prompt is editable only while the run is still queued (#472). Checked
|
|
3254
3325
|
// BEFORE the title write so a rejected PATCH is a no-op rather than a partial
|
|
3255
3326
|
// one. `title` itself keeps working on any status — no regression to #389.
|
|
@@ -3275,17 +3346,21 @@ export function createApp(deps) {
|
|
|
3275
3346
|
}
|
|
3276
3347
|
return c.json(store.getRun(id));
|
|
3277
3348
|
})
|
|
3278
|
-
.post('/runs/:id/cancel', (c) => {
|
|
3349
|
+
.post('/runs/:id/cancel', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
|
|
3279
3350
|
const { store, manager } = c.get('project');
|
|
3280
3351
|
const id = c.req.param('id');
|
|
3281
3352
|
if (!store.getRun(id))
|
|
3282
3353
|
return c.json({ error: 'not found' }, 404);
|
|
3354
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3355
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3356
|
+
if (stale)
|
|
3357
|
+
return c.json(stale, 409);
|
|
3283
3358
|
const cancelled = manager.cancel(id);
|
|
3284
3359
|
return c.json({ cancelled });
|
|
3285
3360
|
})
|
|
3286
3361
|
// Live-session participation (spec 002): deliver a user message (text +
|
|
3287
3362
|
// pasted screenshots) into the run's open claude session.
|
|
3288
|
-
.post('/runs/:id/messages', jsonZodValidator(
|
|
3363
|
+
.post('/runs/:id/messages', jsonZodValidator(messageInputSchema), async (c) => {
|
|
3289
3364
|
const { store, manager } = c.get('project');
|
|
3290
3365
|
const id = c.req.param('id');
|
|
3291
3366
|
const run = store.getRun(id);
|
|
@@ -3302,6 +3377,11 @@ export function createApp(deps) {
|
|
|
3302
3377
|
if (blocked)
|
|
3303
3378
|
return c.json({ error: blocked }, 409);
|
|
3304
3379
|
}
|
|
3380
|
+
// The stale-write guard (#250), after the last `await`: from here through the delivery
|
|
3381
|
+
// ladder every step is synchronous, so the check and the delivery run in one stretch.
|
|
3382
|
+
const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
|
|
3383
|
+
if (stale)
|
|
3384
|
+
return c.json(stale, 409);
|
|
3305
3385
|
const content = [
|
|
3306
3386
|
...parsed.data.images.map(toPastedContent),
|
|
3307
3387
|
...(parsed.data.text.trim() ? [{ type: 'text', text: parsed.data.text }] : []),
|
|
@@ -3344,7 +3424,7 @@ export function createApp(deps) {
|
|
|
3344
3424
|
})
|
|
3345
3425
|
// Edit / remove a stacked message (#472). Registered before any conflicting
|
|
3346
3426
|
// `/:id` route so `queued-messages` never matches as a run id.
|
|
3347
|
-
.patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(
|
|
3427
|
+
.patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchInputSchema), async (c) => {
|
|
3348
3428
|
const { store, manager } = c.get('project');
|
|
3349
3429
|
const id = c.req.param('id');
|
|
3350
3430
|
const run = store.getRun(id);
|
|
@@ -3372,6 +3452,10 @@ export function createApp(deps) {
|
|
|
3372
3452
|
error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
|
|
3373
3453
|
}, 400);
|
|
3374
3454
|
}
|
|
3455
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3456
|
+
const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
|
|
3457
|
+
if (stale)
|
|
3458
|
+
return c.json(stale, 409);
|
|
3375
3459
|
const images = parsed.data.images?.map(toPastedContent);
|
|
3376
3460
|
const message = manager.editQueuedMessage(id, msgId, {
|
|
3377
3461
|
...(parsed.data.text !== undefined ? { text: parsed.data.text } : {}),
|
|
@@ -3381,7 +3465,7 @@ export function createApp(deps) {
|
|
|
3381
3465
|
return c.json({ error: 'run already started' }, 409);
|
|
3382
3466
|
return c.json({ message });
|
|
3383
3467
|
})
|
|
3384
|
-
.delete('/runs/:id/queued-messages/:msgId', (c) => {
|
|
3468
|
+
.delete('/runs/:id/queued-messages/:msgId', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
|
|
3385
3469
|
const { store, manager } = c.get('project');
|
|
3386
3470
|
const id = c.req.param('id');
|
|
3387
3471
|
const run = store.getRun(id);
|
|
@@ -3391,23 +3475,31 @@ export function createApp(deps) {
|
|
|
3391
3475
|
if (!(run.queuedMessages ?? []).some((m) => m.id === msgId)) {
|
|
3392
3476
|
return c.json({ error: 'not found' }, 404);
|
|
3393
3477
|
}
|
|
3478
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3479
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3480
|
+
if (stale)
|
|
3481
|
+
return c.json(stale, 409);
|
|
3394
3482
|
if (!manager.removeQueuedMessage(id, msgId))
|
|
3395
3483
|
return c.json({ error: 'run already started' }, 409);
|
|
3396
3484
|
return c.json({ removed: true });
|
|
3397
3485
|
})
|
|
3398
3486
|
// "Finish": gracefully close a waiting session — the run completes as done.
|
|
3399
|
-
.post('/runs/:id/finish', (c) => {
|
|
3487
|
+
.post('/runs/:id/finish', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), (c) => {
|
|
3400
3488
|
const { store, manager } = c.get('project');
|
|
3401
3489
|
const id = c.req.param('id');
|
|
3402
3490
|
if (!store.getRun(id))
|
|
3403
3491
|
return c.json({ error: 'not found' }, 404);
|
|
3492
|
+
// The stale-write guard (#250): checked in the same synchronous stretch as the write.
|
|
3493
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3494
|
+
if (stale)
|
|
3495
|
+
return c.json(stale, 409);
|
|
3404
3496
|
const finished = manager.finish(id);
|
|
3405
3497
|
if (!finished)
|
|
3406
3498
|
return c.json({ error: 'no open session' }, 409);
|
|
3407
3499
|
return c.json({ finished: true });
|
|
3408
3500
|
})
|
|
3409
3501
|
// "Continue" (spec 003): reopen a finished run's session in-process.
|
|
3410
|
-
.post('/runs/:id/continue', jsonZodValidator(
|
|
3502
|
+
.post('/runs/:id/continue', jsonZodValidator(continueRunInputSchema, { absent: ({}) }), async (c) => {
|
|
3411
3503
|
const { root: repoRoot, store, manager } = c.get('project');
|
|
3412
3504
|
const id = c.req.param('id');
|
|
3413
3505
|
const run = store.getRun(id);
|
|
@@ -3432,6 +3524,11 @@ export function createApp(deps) {
|
|
|
3432
3524
|
if ('error' in account)
|
|
3433
3525
|
return c.json({ error: account.error }, 400);
|
|
3434
3526
|
}
|
|
3527
|
+
// The stale-write guard (#250), after the last `await`, so the check and the reopen run in
|
|
3528
|
+
// one synchronous stretch.
|
|
3529
|
+
const stale = staleRunWrite(store, id, parsed.data.expectedVersion);
|
|
3530
|
+
if (stale)
|
|
3531
|
+
return c.json(stale, 409);
|
|
3435
3532
|
const result = manager.continueRun(id, {
|
|
3436
3533
|
text: parsed.data.text,
|
|
3437
3534
|
images: parsed.data.images?.map(toPastedContent),
|
|
@@ -3635,20 +3732,25 @@ export function createApp(deps) {
|
|
|
3635
3732
|
})
|
|
3636
3733
|
// Task diff (spec 006): what this run changed — its worktree vs its base.
|
|
3637
3734
|
.get('/runs/:id/diff', async (c) => {
|
|
3638
|
-
const
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3735
|
+
const run = await ownedRun(c.get('project'), c.req.param('id'));
|
|
3736
|
+
if (!run) {
|
|
3737
|
+
return c.get('project').store.getRun(c.req.param('id'))
|
|
3738
|
+
? c.json({ error: FOREIGN_WORKTREE }, 409)
|
|
3739
|
+
: c.json({ error: 'not found' }, 404);
|
|
3740
|
+
}
|
|
3642
3741
|
if (!run.worktreePath || !existsSync(run.worktreePath)) {
|
|
3643
3742
|
return c.text('(no worktree — this task ran directly in the repo working tree)');
|
|
3644
3743
|
}
|
|
3645
3744
|
return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
|
|
3646
3745
|
})
|
|
3647
3746
|
.get('/runs/:id/changes', async (c) => {
|
|
3648
|
-
const { root: repoRoot
|
|
3649
|
-
const run =
|
|
3650
|
-
if (!run)
|
|
3651
|
-
return c.
|
|
3747
|
+
const { root: repoRoot } = c.get('project');
|
|
3748
|
+
const run = await ownedRun(c.get('project'), c.req.param('id'));
|
|
3749
|
+
if (!run) {
|
|
3750
|
+
return c.get('project').store.getRun(c.req.param('id'))
|
|
3751
|
+
? c.json({ error: FOREIGN_WORKTREE }, 409)
|
|
3752
|
+
: c.json({ error: 'not found' }, 404);
|
|
3753
|
+
}
|
|
3652
3754
|
const workingDirectory = workingDirectoryOf(run, repoRoot);
|
|
3653
3755
|
if (!workingDirectory)
|
|
3654
3756
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
@@ -3760,28 +3862,36 @@ export function createApp(deps) {
|
|
|
3760
3862
|
...(result.content !== undefined ? { content: result.content } : {}),
|
|
3761
3863
|
});
|
|
3762
3864
|
})
|
|
3763
|
-
.post('/runs/:id/git/commit', jsonZodValidator(
|
|
3865
|
+
.post('/runs/:id/git/commit', jsonZodValidator(gitCommitInputSchema), async (c) => {
|
|
3764
3866
|
const { store } = c.get('project');
|
|
3765
|
-
const run =
|
|
3867
|
+
const run = await ownedRun(c.get('project'), c.req.param('id'));
|
|
3766
3868
|
if (!run)
|
|
3767
|
-
return c.json({ error: 'not found' }, 404);
|
|
3869
|
+
return store.getRun(c.req.param('id')) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
|
|
3768
3870
|
const worktree = worktreeOf(run);
|
|
3769
3871
|
if (!worktree)
|
|
3770
3872
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
3771
3873
|
const parsed = { data: c.req.valid('json') };
|
|
3874
|
+
// The stale-write guard (#250), right before the (async) effect starts.
|
|
3875
|
+
const stale = staleRunWrite(store, run.id, parsed.data.expectedVersion);
|
|
3876
|
+
if (stale)
|
|
3877
|
+
return c.json(stale, 409);
|
|
3772
3878
|
const result = await commitAll(worktree, parsed.data.message);
|
|
3773
3879
|
if (!result.ok)
|
|
3774
3880
|
return c.json({ error: result.error }, 409);
|
|
3775
3881
|
return c.json({ committed: true, sha: result.sha });
|
|
3776
3882
|
})
|
|
3777
|
-
.post('/runs/:id/git/push', async (c) => {
|
|
3883
|
+
.post('/runs/:id/git/push', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
|
|
3778
3884
|
const { root: repoRoot, store } = c.get('project');
|
|
3779
|
-
const run =
|
|
3885
|
+
const run = await ownedRun(c.get('project'), c.req.param('id'));
|
|
3780
3886
|
if (!run)
|
|
3781
|
-
return c.json({ error: 'not found' }, 404);
|
|
3887
|
+
return store.getRun(c.req.param('id')) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
|
|
3782
3888
|
const worktree = worktreeOf(run);
|
|
3783
3889
|
if (!worktree)
|
|
3784
3890
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
3891
|
+
// The stale-write guard (#250), right before the (async) effect starts.
|
|
3892
|
+
const stale = staleRunWrite(store, run.id, c.req.valid('json').expectedVersion);
|
|
3893
|
+
if (stale)
|
|
3894
|
+
return c.json(stale, 409);
|
|
3785
3895
|
const result = await pushCurrentBranch(worktree);
|
|
3786
3896
|
if (!result.ok)
|
|
3787
3897
|
return c.json({ error: result.error }, 409);
|
|
@@ -3803,12 +3913,12 @@ export function createApp(deps) {
|
|
|
3803
3913
|
// `gh pr create --draft`; on success the run completes as done with the PR
|
|
3804
3914
|
// badge. Failures come back as 409 with a `manual` merge command the GUI
|
|
3805
3915
|
// shows next to the toast. XEZ_DRY_RUN=1 fakes the URL (no push, no gh).
|
|
3806
|
-
.post('/runs/:id/pr', async (c) => {
|
|
3916
|
+
.post('/runs/:id/pr', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
|
|
3807
3917
|
const { root: repoRoot, dataDir, store, manager } = c.get('project');
|
|
3808
3918
|
const id = c.req.param('id');
|
|
3809
|
-
const run =
|
|
3919
|
+
const run = await ownedRun(c.get('project'), id);
|
|
3810
3920
|
if (!run)
|
|
3811
|
-
return c.json({ error: 'not found' }, 404);
|
|
3921
|
+
return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
|
|
3812
3922
|
if (manager.isActive(id))
|
|
3813
3923
|
return c.json({ error: 'run is still active — wait for the review gate' }, 409);
|
|
3814
3924
|
if (!run.worktreePath || !existsSync(run.worktreePath) || !run.branch) {
|
|
@@ -3816,6 +3926,10 @@ export function createApp(deps) {
|
|
|
3816
3926
|
error: 'no worktree/branch to publish — this task ran in the repo working tree',
|
|
3817
3927
|
}, 400);
|
|
3818
3928
|
}
|
|
3929
|
+
// The stale-write guard (#250), right before the (async) effect starts.
|
|
3930
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3931
|
+
if (stale)
|
|
3932
|
+
return c.json(stale, 409);
|
|
3819
3933
|
const outcome = await createDraftPr({
|
|
3820
3934
|
repoRoot,
|
|
3821
3935
|
run,
|
|
@@ -3843,43 +3957,56 @@ export function createApp(deps) {
|
|
|
3843
3957
|
})
|
|
3844
3958
|
// Archived tasks keep their worktree for inspection; this is the explicit
|
|
3845
3959
|
// "🧹 Remove worktree" cleanup (spec 006).
|
|
3846
|
-
.post('/runs/:id/remove-worktree', async (c) => {
|
|
3960
|
+
.post('/runs/:id/remove-worktree', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
|
|
3847
3961
|
const { root: repoRoot, store, manager } = c.get('project');
|
|
3848
3962
|
const id = c.req.param('id');
|
|
3849
|
-
const run =
|
|
3963
|
+
const run = await ownedRun(c.get('project'), id);
|
|
3850
3964
|
if (!run)
|
|
3851
|
-
return c.json({ error: 'not found' }, 404);
|
|
3965
|
+
return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
|
|
3852
3966
|
if (manager.isActive(id))
|
|
3853
3967
|
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
3968
|
+
// The stale-write guard (#250), right before the (async) effect starts.
|
|
3969
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3970
|
+
if (stale)
|
|
3971
|
+
return c.json(stale, 409);
|
|
3854
3972
|
if (run.worktreePath)
|
|
3855
3973
|
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
3856
3974
|
store.updateRun(id, { worktreePath: undefined, branch: undefined });
|
|
3857
3975
|
return c.json({ removed: true });
|
|
3858
3976
|
})
|
|
3859
|
-
.delete('/runs/:id', async (c) => {
|
|
3977
|
+
.delete('/runs/:id', optionalJsonZodValidator(runVersionGuardInputSchema, { absent: ({}) }), async (c) => {
|
|
3860
3978
|
const { root: repoRoot, store, manager } = c.get('project');
|
|
3861
3979
|
const id = c.req.param('id');
|
|
3862
3980
|
if (manager.isActive(id))
|
|
3863
3981
|
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
3864
|
-
const run =
|
|
3982
|
+
const run = await ownedRun(c.get('project'), id);
|
|
3865
3983
|
if (!run)
|
|
3866
|
-
return c.json({ error: 'not found' }, 404);
|
|
3984
|
+
return store.getRun(id) ? c.json({ error: FOREIGN_WORKTREE }, 409) : c.json({ error: 'not found' }, 404);
|
|
3985
|
+
// The stale-write guard (#250), right before the (async) effect starts.
|
|
3986
|
+
const stale = staleRunWrite(store, id, c.req.valid('json').expectedVersion);
|
|
3987
|
+
if (stale)
|
|
3988
|
+
return c.json(stale, 409);
|
|
3867
3989
|
// Delete cleans up after itself: worktree + branch go with the run (spec 006).
|
|
3868
3990
|
if (run.worktreePath)
|
|
3869
3991
|
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
3870
3992
|
return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
|
|
3871
3993
|
});
|
|
3872
3994
|
// ---- parallel variants (spec 010) -----------------------------------------
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3995
|
+
// A group's members, in variant order — but only when EVERY member is this project's (#288).
|
|
3996
|
+
// A member's `worktreePath` is data: a copied or hand-edited `.local/xezar` can name another
|
|
3997
|
+
// project's worktree, and the read runs git inside each member's path while the pick deletes the
|
|
3998
|
+
// losers'. `ownGroup` refuses such a group whole, exactly as it does for MCP (M-07), and the
|
|
3999
|
+
// refusal is the same 404 an unknown group gets, so it names nothing of the other project.
|
|
4000
|
+
const ownedGroupRuns = async (project, groupId) => {
|
|
4001
|
+
const owned = await ownGroup(ownershipScope(project), groupId);
|
|
4002
|
+
return owned.ok ? owned.value.runs : null;
|
|
4003
|
+
};
|
|
3877
4004
|
// ---- chained family: variant groups (project-scoped) ----
|
|
3878
4005
|
const groupsRoutes = new Hono()
|
|
3879
4006
|
.get('/groups/:groupId', async (c) => {
|
|
3880
|
-
const { dataDir
|
|
3881
|
-
const runs =
|
|
3882
|
-
if (runs
|
|
4007
|
+
const { dataDir } = c.get('project');
|
|
4008
|
+
const runs = await ownedGroupRuns(c.get('project'), c.req.param('groupId'));
|
|
4009
|
+
if (!runs)
|
|
3883
4010
|
return c.json({ error: 'not found' }, 404);
|
|
3884
4011
|
const detailed = await Promise.all(runs.map(async (r) => ({
|
|
3885
4012
|
id: r.id,
|
|
@@ -3904,10 +4031,10 @@ export function createApp(deps) {
|
|
|
3904
4031
|
// "Pick this one": the winner rests at `review` (spec 009 takes it from
|
|
3905
4032
|
// there — send back / draft PR / finish); the losers are cancelled if
|
|
3906
4033
|
// alive, archived, and their worktrees + branches removed.
|
|
3907
|
-
.post('/groups/:groupId/pick', jsonZodValidator(
|
|
4034
|
+
.post('/groups/:groupId/pick', jsonZodValidator(pickVariantInputSchema), async (c) => {
|
|
3908
4035
|
const { root: repoRoot, dataDir, store, manager } = c.get('project');
|
|
3909
|
-
const runs =
|
|
3910
|
-
if (runs
|
|
4036
|
+
const runs = await ownedGroupRuns(c.get('project'), c.req.param('groupId'));
|
|
4037
|
+
if (!runs)
|
|
3911
4038
|
return c.json({ error: 'not found' }, 404);
|
|
3912
4039
|
const parsed = { data: c.req.valid('json') };
|
|
3913
4040
|
const winner = runs.find((r) => r.id === parsed.data.runId);
|
|
@@ -3921,16 +4048,22 @@ export function createApp(deps) {
|
|
|
3921
4048
|
// (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
|
|
3922
4049
|
// not autonomous. An autonomous / gate-off winner keeps its `done` state with
|
|
3923
4050
|
// the diff left in the worktree; an empty diff (or no worktree) stays too.
|
|
4051
|
+
let toReview = false;
|
|
3924
4052
|
if (winner.status !== 'review' &&
|
|
3925
4053
|
winner.worktreePath &&
|
|
3926
4054
|
existsSync(winner.worktreePath) &&
|
|
3927
4055
|
winner.autonomous !== true &&
|
|
3928
4056
|
reviewGateEnabled(await loadConfig(repoRoot))) {
|
|
3929
4057
|
const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
4058
|
+
toReview = diff.trim().length > 0 && !diff.startsWith('(diff failed');
|
|
4059
|
+
}
|
|
4060
|
+
// The stale-write guard (#250, #271) on the kept variant, after the reads above and right
|
|
4061
|
+
// before the first effect: the losers' worktrees and branches are deleted below, for good.
|
|
4062
|
+
const stale = staleRunWrite(store, winner.id, parsed.data.expectedVersion);
|
|
4063
|
+
if (stale)
|
|
4064
|
+
return c.json(stale, 409);
|
|
4065
|
+
if (toReview)
|
|
4066
|
+
store.updateRun(winner.id, { status: 'review' });
|
|
3934
4067
|
const losers = runs.filter((r) => r.id !== winner.id);
|
|
3935
4068
|
store.appendEvent(winner.id, {
|
|
3936
4069
|
type: 'lifecycle',
|
|
@@ -4524,6 +4657,25 @@ export function createApp(deps) {
|
|
|
4524
4657
|
...(result.code ? { code: result.code } : {}),
|
|
4525
4658
|
...(result.current ? { current: result.current } : {}),
|
|
4526
4659
|
}, result.status);
|
|
4660
|
+
})
|
|
4661
|
+
// Mark a draft pull request ready for review (#262). No cockpit control yet: the leader's
|
|
4662
|
+
// `handoff_git ready` is the caller. The forge re-reads the pull request and refuses a moved head.
|
|
4663
|
+
.post('/github/prs/:number/ready', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(githubPrReadyInputSchema, { message: 'invalid ready request' }), async (c) => {
|
|
4664
|
+
const { root: repoRoot } = c.get('project');
|
|
4665
|
+
const { number } = c.req.valid('param');
|
|
4666
|
+
const forge = resolveForge(await getRepoInfo(repoRoot));
|
|
4667
|
+
if (!forge?.markReady)
|
|
4668
|
+
return c.json({ error: 'GitHub ready-for-review is unavailable' }, 409);
|
|
4669
|
+
const result = await forge.markReady(number, c.req.valid('json'));
|
|
4670
|
+
if (result.ready) {
|
|
4671
|
+
forgetRefStatus(repoRoot, number);
|
|
4672
|
+
return c.json({ ready: true, number: result.number, url: result.url });
|
|
4673
|
+
}
|
|
4674
|
+
return c.json({
|
|
4675
|
+
error: result.error,
|
|
4676
|
+
...(result.code ? { code: result.code } : {}),
|
|
4677
|
+
...(result.current ? { current: result.current } : {}),
|
|
4678
|
+
}, result.status);
|
|
4527
4679
|
})
|
|
4528
4680
|
.get('/github/prs/:number/changes',
|
|
4529
4681
|
// Split out of one `safeParse` over both inputs, because a path param and the query string
|
|
@@ -4708,6 +4860,8 @@ export function createApp(deps) {
|
|
|
4708
4860
|
catch {
|
|
4709
4861
|
// missing or malformed — start fresh
|
|
4710
4862
|
}
|
|
4863
|
+
// What the file held before, per key — so E-05 names only the keys this write really changed.
|
|
4864
|
+
const before = new Map(Object.entries(raw).map(([key, value]) => [key, JSON.stringify(value)]));
|
|
4711
4865
|
if (parsed.data.baseBranch !== undefined) {
|
|
4712
4866
|
if (parsed.data.baseBranch === null)
|
|
4713
4867
|
delete raw.baseBranch;
|
|
@@ -4808,6 +4962,10 @@ export function createApp(deps) {
|
|
|
4808
4962
|
// B2 exists to fix.
|
|
4809
4963
|
if (parsed.data.memoryLimitMb !== undefined)
|
|
4810
4964
|
await deps.semaphore?.refresh();
|
|
4965
|
+
// E-05 (#252): key NAMES only, never values. A write that changed nothing reports nothing.
|
|
4966
|
+
const changed = [...new Set([...before.keys(), ...Object.keys(raw)])].filter((key) => before.get(key) !== (key in raw ? JSON.stringify(raw[key]) : undefined));
|
|
4967
|
+
if (changed.length > 0)
|
|
4968
|
+
reportProjectChange(c.get('project').id, (catalog) => catalog.configChanged({ keys: changed }));
|
|
4811
4969
|
// Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
|
|
4812
4970
|
return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
|
|
4813
4971
|
});
|
|
@@ -4902,8 +5060,53 @@ export function createApp(deps) {
|
|
|
4902
5060
|
return c.json({ error: 'unknown config file' }, 404);
|
|
4903
5061
|
if (!out.ok)
|
|
4904
5062
|
return c.json({ error: out.error }, out.status);
|
|
5063
|
+
// E-05 (#252), after the hosted-mode 409 above and never before it: a refused write is no change.
|
|
5064
|
+
reportProjectChange(c.get('project').id, (catalog) => catalog.agentConfigChanged({ id: c.req.param('id') }));
|
|
4905
5065
|
return c.json(out.read);
|
|
4906
5066
|
});
|
|
5067
|
+
// ---- chained family: the MCP API reference (project-scoped, read-only) ----
|
|
5068
|
+
// #284, spec `mcp-api-reference-spec.md` § 11. The tool registry is loaded LAZILY, as
|
|
5069
|
+
// `startMcpSocket` loads the service, so the cockpit's static import graph never includes it
|
|
5070
|
+
// and a broken MCP module answers `{available: false, reason}` instead of breaking the app.
|
|
5071
|
+
// It does NOT need the MCP service: the listing is static code, and this page is most needed
|
|
5072
|
+
// on a machine where MCP did not start (N-07). The list is fixed for the life of the process
|
|
5073
|
+
// (`listChanged: false`), so the answer is built once. A GET with no side effect — and there
|
|
5074
|
+
// is deliberately no route that runs a tool from here (spec § 13).
|
|
5075
|
+
let mcpReference;
|
|
5076
|
+
const readMcpReference = () => (mcpReference ??= import('../mcp/api-reference.js')
|
|
5077
|
+
.then((m) => m.buildMcpApiReference(version))
|
|
5078
|
+
.catch((err) => {
|
|
5079
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5080
|
+
console.warn(`[xez] MCP API reference unavailable (${message}) — the cockpit works without it`);
|
|
5081
|
+
return { available: false, reason: `The MCP tool list could not be loaded: ${message}` };
|
|
5082
|
+
}));
|
|
5083
|
+
const mcpReferenceRoutes = new Hono().get('/mcp/reference', async (c) => c.json(await readMcpReference()));
|
|
5084
|
+
// ---- chained family: the MCP leader's push-delivery path (project-scoped) ----
|
|
5085
|
+
// #309. Delivery itself is on by default and needs no route: every MCP session that owns the
|
|
5086
|
+
// project gets an event controller when it opens. The route reads that state, and lets the person
|
|
5087
|
+
// point xezar at the leader they run — `attach` an OpenCode `serve` session — or detach it. xezar
|
|
5088
|
+
// starts no agent process (owner decision on #311); there is no `start` or `resume`. It runs no
|
|
5089
|
+
// tool, so the cockpit never becomes a second leader (spec `mcp-api-reference-spec.md` § 13).
|
|
5090
|
+
// Attaching makes this process talk to a local server by URL: a local-machine capability, so
|
|
5091
|
+
// hosted mode refuses it like every other local mutator.
|
|
5092
|
+
const mcpLeaderStatus = (projectId) => projectLeader(projectId)?.status() ?? {
|
|
5093
|
+
available: false,
|
|
5094
|
+
reason: 'The MCP service is not running for this project, so there is no event delivery to report.',
|
|
5095
|
+
};
|
|
5096
|
+
const mcpLeaderRoutes = new Hono()
|
|
5097
|
+
.get('/mcp/leader', (c) => c.json(mcpLeaderStatus(c.get('project').id)))
|
|
5098
|
+
.post('/mcp/leader', jsonZodValidator(() => mcpLeaderActionInputSchema), async (c) => {
|
|
5099
|
+
if (!capabilities().localHandoff) {
|
|
5100
|
+
return c.json({ error: 'a leader session is attached from the machine that owns the checkout (this cockpit runs in hosted mode)' }, 409);
|
|
5101
|
+
}
|
|
5102
|
+
const port = projectLeader(c.get('project').id);
|
|
5103
|
+
if (!port)
|
|
5104
|
+
return c.json({ error: 'the MCP service is not running for this project' }, 409);
|
|
5105
|
+
const out = await port.act(c.req.valid('json'));
|
|
5106
|
+
if (!out.ok)
|
|
5107
|
+
return c.json({ error: out.error }, 409);
|
|
5108
|
+
return c.json(out.status);
|
|
5109
|
+
});
|
|
4907
5110
|
// Repo view branch actions: switch to an existing branch, or create one
|
|
4908
5111
|
// (from `from` or HEAD) and switch. Predictable git failures — invalid
|
|
4909
5112
|
// name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
|
|
@@ -4939,7 +5142,9 @@ export function createApp(deps) {
|
|
|
4939
5142
|
.route('/', githubRoutes)
|
|
4940
5143
|
.route('/', repoRoutes)
|
|
4941
5144
|
.route('/', configRoutes)
|
|
4942
|
-
.route('/', agentConfigRoutes)
|
|
5145
|
+
.route('/', agentConfigRoutes)
|
|
5146
|
+
.route('/', mcpReferenceRoutes)
|
|
5147
|
+
.route('/', mcpLeaderRoutes);
|
|
4943
5148
|
// ---- chained family: the cross-project run index (workspace-level) -------
|
|
4944
5149
|
/**
|
|
4945
5150
|
* How many runs each project may contribute, newest first. The index is a FINDER, not a
|
|
@@ -5209,6 +5414,7 @@ export function startServer(deps, port) {
|
|
|
5209
5414
|
socketHub,
|
|
5210
5415
|
automationsChanged: () => rescheduleAutomations(),
|
|
5211
5416
|
});
|
|
5417
|
+
deps.onApp?.(app);
|
|
5212
5418
|
// SECURITY: default to loopback. This server executes agents locally and its endpoints are
|
|
5213
5419
|
// same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
|
|
5214
5420
|
// expose an agent-executing box to the network. `bindHost` exists only for a deliberate
|