mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The shared validators use zod to check API input, and the Runner contracts check some of the same values again > - zod below 4.6.6 has a high-severity memory issue (GHSA-mrhx-c7xc-m3w5), so this bump is necessary > - zod 4.6 also changes string `min`, `max`, and `length`: they now count Unicode code points, not UTF-16 units > - The Runner question parser and the goal readers still use JavaScript string length, so emoji text can pass zod and then fail later > - This pull request bumps zod and keeps the string-length bound on those schemas > - The benefit is that we get the security fix, and these inputs still fail at validation ## Linked Issues or Issue Description Refs GHSA-mrhx-c7xc-m3w5. **What happened?** With zod 4.6, three CI tests failed on this bump. Two showed an emoji body that is over the agent commentary ceiling and that zod now accepts. The third is the scheduler timeout described under Verification. **Expected behavior** A dependency bump does not widen an API limit. Text over the limit gets a validation error. **Impact** Emoji text between the two bounds passed validation and failed later: - A question set is created, and then each answer gets a 422. The question stays pending and nobody can answer it. A schema and parser run confirmed the mismatch. This was not run over HTTP. - The Muse route returns a 500 because `parsePaperclipQuestionSet` throws an error that is not an `HttpError`. This comes from reading the code. It was not run. - A Runner goal normalizes to null and replaces the stored goal, or it is marked blocked. This comes from reading the code. It was not run. ## What Changed - Bump zod to `^4.6.6`. The lockfile resolves 4.6.7. (Dependabot) - Add `maxStringLength` to `packages/shared/src/validators/text.ts`. It checks zod `max` and JavaScript string length. - Use `maxStringLength` for the question-set text fields, the Runner goal objective, and the agent commentary body. - Add regression tests for the question-set bound and the goal objective bound. - Dependabot recreated the branch on current `master` (`@dependabot recreate`) to resolve `pnpm-lock.yaml` conflicts. This happened twice. The lockfile is Dependabot's. Each time, the two fix commits were applied again without changes. ## Verification - Compared zod 4.4.3, 4.6.5, 4.6.6, and 4.6.7 directly. The change to `max` is in 4.6.5 or earlier, and 4.4.3 does not have it. - Passed locally: `packages/shared` tests `agent-commentary`, `validators/issue`, and `runner-goal`, plus `tsc --noEmit` in `packages/shared`. - Passed locally on the first fix commit: server `agent-commentary.integration`, `agent-commentary-helper`, and `openapi-routes`. The later commit changes these schemas again. CI passed these tests at `b5e9dc7352`. - Local testing was focused. GitHub CI is the full regression check. CI passed at `b5e9dc7352`, including `ci / verify` and `ci / e2e`. - Greptile reviewed `b5e9dc7352`. The score is 5/5, with no inline comments. - After the recreate, the fix commits applied cleanly as `bad62c3ba5` and `3294552e65` on Dependabot's `46789d1487`. Their diff is the same as the diff Greptile reviewed. The lockfile still resolves zod 4.6.7. The focused `packages/shared` tests (`validators/issue`, `runner-goal`) and `tsc --noEmit` in `packages/shared` passed again locally. A second `pnpm-lock.yaml` conflict came before CI finished at `3294552e65`. - Second recreate: Dependabot ignored the first `@dependabot recreate` (no reaction and no update job after 20 minutes). It acted on the second command and pushed `83c328c15c`. The fix commits applied cleanly as `dd3e87d93e` and `95b450048f`, with the same diff. The lockfile still resolves zod 4.6.7. The same focused tests and `tsc --noEmit` passed again locally. Greptile first scored `95b450048f` 4/5 with one P2: the description did not explain the source changes. Greptile read Dependabot's replacement description, which was live during that review. The description was restored and the thread resolved. The new review scored `95b450048f` 5/5 with no findings. CI failed at `95b450048f` in two shards. Neither failure comes from the zod change: - `server (9/12)`: `server/scripts/reporting-lock-policy.test.mjs` fails with "Reporting lock changed; review the dependency correction before install". #19283 added this test today. It requires the committed `pnpm-lock.yaml` hash to match `tests/runner-e2e/report-lock.json`. Every pull request that changes the lockfile fails it until the hashes are renewed, for example #17172 and #17911. - `workspaces-a (1/2)`: `cli/src/__tests__/company-import-export-e2e.test.ts` timed out waiting for its child server. The same test failed the same way on two unrelated branches in the same hour (runs 42115721271 and 42109846042). A rerun of this shard at `95b450048f` passed. The rerun of `server (9/12)` failed again on the reporting-lock test. - #19356 fixed the reporting-lock gate for Dependabot lockfiles and merged. A re-run reuses the old merge commit, so the empty commit `482d76686e` starts a run against current `master`. `pnpm-lock.yaml` and the reporting-lock proof on `master` did not change since this branch's base. - CI at `482d76686e`: `ci / policy` passed, and `server (9/12)` skipped the reporting-lock test as #19356 intends. `e2e shard (6/8)` failed once in `cloud-auth.spec.ts` ("apiResponse.json: Response has been disposed", a Playwright race in a route handler). The same test passed at `95b450048f` with the same code. A rerun passed. All checks pass, including `ci / verify` and `ci / e2e`. - Earlier failure: `issue-monitor-scheduler.test.ts` ("lets the board check a scheduled monitor on an in_progress task") timed out in CI at `d60b5f56d8`. It passed 5 of 5 local runs with the first fix. The cause is unknown. The test waits 3 seconds for heartbeat runs to settle, so CI load is possible, but this is not proven. The test passed in the CI run at `b5e9dc7352`. This shows recovery. It does not explain the earlier timeout. ## Risks - All other zod string bounds now count code points. Emoji text can be up to 2N UTF-16 units and 4N UTF-8 bytes (it was N units and at most 3N bytes). This pull request does not change those bounds. - An audit of those bounds found no other concrete failure. It checked index limits, byte limits, `.length` guards, provider limits, and security `min` checks. It used code search and sampling, so it is not exhaustive. - Not verified: Slack `topic` and `purpose` use `.max(250)`, and we did not confirm how Slack counts that limit. - `.min(N)` is now stricter for emoji text. A single emoji no longer passes `.min(2)`. - `@dependabot rebase` or `@dependabot recreate` on this branch will remove the manual commits. This happened twice to resolve lockfile conflicts, and the commits were applied again each time. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused tests only; see Verification) - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes (no documentation change needed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green (at `482d76686e`) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (5/5 at `95b450048f`) - [x] I will address all Greptile and reviewer comments before requesting merge --- Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.6.6. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/colinhacks/zod/releases">zod's releases</a>.</em></p> <blockquote> <h2>v4.6.6</h2> <blockquote> <p>Six malicious commits pushed to <code>main</code> on September 21 with a compromised git credential were reverted the same day in <a href="https://redirect.github.com/colinhacks/zod/issues/6620">#6620</a> and have since been removed from the history of <code>main</code>. None were ever published to npm. The npm provenance for 4.6.6 names 284243fb, the pre-rewrite copy of 9c219833 with identical contents.</p> </blockquote> <h2>Commits:</h2> <ul> <li>0cd3af5e9f50ad11bbcd4407a46a21b8848ba574 docs: load the en locale directly in Zod Mini</li> <li>9d5e60f162997b764b40d45aa02481888db50dea docs: import locales from zod/locales, not z.locales</li> <li>34a626f307871ecbb0123947559328dc52ed5e3e docs: tighten the z.locales tree-shaking note</li> <li>74304ce5b5796b5edb9a88ceb8e170b6ea3e82df release: stage npm versions instead of publishing them (<a href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li> <li>b2ef983e0711ff6517d6020b021071e2e5653efa docs: add Code for Japan to bronze sponsors</li> <li>8fb2faf0584828a6a027d8279db36f6f52784462 docs: crop the Code for Japan logo and list it first in bronze</li> <li>bacad518c41610d654658172d325874bf0ee6290 docs: square the Code for Japan logo to match the other bronze tiles</li> <li>c42596bd1c87e941d7241d10e012c5c196adcc10 release: hold the run on the release-approval environment until the staged versions are approved (<a href="https://redirect.github.com/colinhacks/zod/issues/6638">#6638</a>)</li> <li>a2ba4a67986a3e6b2b131be30084a2f460d9ea67 fix(types): declare record output and input as members, not base type arguments (<a href="https://redirect.github.com/colinhacks/zod/issues/6645">#6645</a>)</li> <li>a0ebf970f2d752e5f58527cd2942ff48be4f46bd fix(v4): check array, set and map size bounds before parsing elements (<a href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li> <li>529f78dd9431b07f49647fee3aa0819b3f8b66b7 4.6.6</li> <li>9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1 ci: stage at verbose so a failed OIDC token exchange says why</li> </ul> <h2>v4.6.5</h2> <h2>Commits:</h2> <ul> <li>d2b135cfb7a3582b9eb515756b9166bcb9521f4a docs: add the 4.6.x patch highlights to the 4.6 post</li> <li>f1448f7cee00df9fe1e9ad84a000aa1828cc8bc1 docs: fold the 4.6.x patch highlights into the 4.6 post's own sections</li> <li>de65a5cb39ed22a507fac935788f718fa88d104f docs: lead the properties section with the check and add a Zod Mini tab (<a href="https://redirect.github.com/colinhacks/zod/issues/6598">#6598</a>)</li> <li>56222cd1532c07bcb91b67df529cab4c0a215330 feat(instanceof): key the .properties() shape off the instance type (<a href="https://redirect.github.com/colinhacks/zod/issues/6600">#6600</a>)</li> <li>ca0229a404818290e6cdcfefcd7eb2d04bcbb543 Revert "feat: add z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI (<a href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)"</li> <li>cc4cd4ee9c52fcaa10964e48cc144541e41a5ed9 Revert "Revert "feat: add z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI (<a href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)""</li> <li>0f3f5ee3ca56c7574bf849e54f79e9a6e02562ee 4.6.5</li> <li>59bbc03e10c636b9eb3c393dfeb552819774ec21 chore: re-pin the integration peers to the workspace zod after the 4.6.5 bump</li> </ul> <h2>v4.6.4</h2> <p>A patch on top of <a href="https://github.com/colinhacks/zod/releases/tag/v4.6.3">4.6.3</a>.</p> <ul> <li><a href="https://github.com/colinhacks/zod/commit/d6bc1e30"><code>d6bc1e30</code></a> feat: add <code>z.currencyCode()</code> over a vendored ISO 4217 list, refreshed weekly by CI (<a href="https://redirect.github.com/colinhacks/zod/pull/6595">#6595</a>)</li> <li><a href="https://github.com/colinhacks/zod/commit/ad32d751"><code>ad32d751</code></a> perf: <code>z.url()</code> rejects an invalid URL with <code>URL.canParse()</code> instead of a throwing constructor, about 50x faster; fewer allocations on the validation path (<a href="https://redirect.github.com/colinhacks/zod/pull/6588">#6588</a>)</li> <li><a href="https://github.com/colinhacks/zod/commit/2bb08717"><code>2bb08717</code></a> chore: re-pin the integration peers to the workspace zod after the 4.6.4 bump</li> <li><a href="https://github.com/colinhacks/zod/commit/f6e1701a"><code>f6e1701a</code></a> chore(deps): bump next to 15.5.25 and vite to 7.3.6 (<a href="https://redirect.github.com/colinhacks/zod/pull/6153">#6153</a>)</li> </ul> <h2>v4.6.3</h2> <p>A patch on top of <a href="https://github.com/colinhacks/zod/releases/tag/v4.6.2">4.6.2</a>.</p> <ul> <li><a href="https://github.com/colinhacks/zod/commit/413cce9a"><code>413cce9a</code></a> fix(v4): make z.properties() a check again (<a href="https://redirect.github.com/colinhacks/zod/pull/6594">#6594</a>) — removes the standalone <code>z.properties()</code> schema from 4.6.0; <code>z.instanceof().properties()</code> and <code>.check(...z.properties())</code> are unchanged</li> <li><a href="https://github.com/colinhacks/zod/commit/75d63ee1"><code>75d63ee1</code></a> docs: show only the <code>.properties()</code> method form in the 4.6 post</li> <li><a href="https://github.com/colinhacks/zod/commit/46da9572"><code>46da9572</code></a> docs: match the error-message examples to what the parsers emit</li> </ul> <h2>v4.6.2</h2> <p>A patch on top of <a href="https://github.com/colinhacks/zod/releases/tag/v4.6.1">4.6.1</a>.</p> <ul> <li><a href="https://github.com/colinhacks/zod/commit/9446b5cc"><code>9446b5cc</code></a> fix: preserve undefined prefault outputs and object keys (<a href="https://redirect.github.com/colinhacks/zod/pull/6587">#6587</a>) — closes <a href="https://redirect.github.com/colinhacks/zod/issues/6585">#6585</a></li> <li><a href="https://github.com/colinhacks/zod/commit/0c483c58"><code>0c483c58</code></a> docs: the <a href="https://zod.dev/blog/zod-4-6">Zod 4.6 announcement post</a> (<a href="https://redirect.github.com/colinhacks/zod/pull/6546">#6546</a>)</li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/colinhacks/zod/commit/9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1"><code>9c21983</code></a> ci: stage at verbose so a failed OIDC token exchange says why</li> <li><a href="https://github.com/colinhacks/zod/commit/529f78dd9431b07f49647fee3aa0819b3f8b66b7"><code>529f78d</code></a> 4.6.6</li> <li><a href="https://github.com/colinhacks/zod/commit/a0ebf970f2d752e5f58527cd2942ff48be4f46bd"><code>a0ebf97</code></a> fix(v4): check array, set and map size bounds before parsing elements (<a href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li> <li><a href="https://github.com/colinhacks/zod/commit/a2ba4a67986a3e6b2b131be30084a2f460d9ea67"><code>a2ba4a6</code></a> fix(types): declare record output and input as members, not base type argumen...</li> <li><a href="https://github.com/colinhacks/zod/commit/c42596bd1c87e941d7241d10e012c5c196adcc10"><code>c42596b</code></a> release: hold the run on the release-approval environment until the staged ve...</li> <li><a href="https://github.com/colinhacks/zod/commit/bacad518c41610d654658172d325874bf0ee6290"><code>bacad51</code></a> docs: square the Code for Japan logo to match the other bronze tiles</li> <li><a href="https://github.com/colinhacks/zod/commit/8fb2faf0584828a6a027d8279db36f6f52784462"><code>8fb2faf</code></a> docs: crop the Code for Japan logo and list it first in bronze</li> <li><a href="https://github.com/colinhacks/zod/commit/b2ef983e0711ff6517d6020b021071e2e5653efa"><code>b2ef983</code></a> docs: add Code for Japan to bronze sponsors</li> <li><a href="https://github.com/colinhacks/zod/commit/74304ce5b5796b5edb9a88ceb8e170b6ea3e82df"><code>74304ce</code></a> release: stage npm versions instead of publishing them (<a href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li> <li><a href="https://github.com/colinhacks/zod/commit/34a626f307871ecbb0123947559328dc52ed5e3e"><code>34a626f</code></a> docs: tighten the z.locales tree-shaking note</li> <li>Additional commits viewable in <a href="https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.6">compare view</a></li> </ul> </details> <br /> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicky Leach <nicky@paperclip.ing> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2338 lines
82 KiB
TypeScript
2338 lines
82 KiB
TypeScript
import { CHAT_PROVIDERS } from "../types/chat-channels.js";
|
|
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
|
|
import { z } from "zod";
|
|
import { maxStringLength } from "./text.js";
|
|
import { ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
|
|
|
|
import {
|
|
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
|
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
|
|
ISSUE_EXECUTION_MONITOR_KINDS,
|
|
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
|
|
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
|
|
ISSUE_EXECUTION_POLICY_MODES,
|
|
ISSUE_EXECUTION_STAGE_TYPES,
|
|
ISSUE_EXECUTION_STATE_STATUSES,
|
|
ISSUE_COMMENT_AUTHOR_TYPES,
|
|
ISSUE_COMMENT_METADATA_ROW_TYPES,
|
|
ISSUE_COMMENT_PRESENTATION_KINDS,
|
|
ISSUE_COMMENT_PRESENTATION_TONES,
|
|
ISSUE_COMMENT_PRESENTATION_DENSITIES,
|
|
ISSUE_HARNESS_KINDS,
|
|
ISSUE_MONITOR_SCHEDULED_BY,
|
|
ISSUE_PRIORITIES,
|
|
ISSUE_RECOVERY_ACTION_KINDS,
|
|
ISSUE_RECOVERY_ACTION_OUTCOMES,
|
|
ISSUE_RECOVERY_ACTION_OWNER_TYPES,
|
|
ISSUE_RECOVERY_ACTION_STATUSES,
|
|
ISSUE_REVIEW_POLICIES,
|
|
ISSUE_WORK_MODES,
|
|
clampIssueRequestDepth,
|
|
ISSUE_STATUSES,
|
|
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
|
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
|
ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES,
|
|
ISSUE_THREAD_INTERACTION_KINDS,
|
|
ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES,
|
|
ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES,
|
|
ISSUE_THREAD_INTERACTION_STATUSES,
|
|
ISSUE_WATCHDOG_DISCOVERY_KINDS,
|
|
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
|
REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
|
|
} from "../constants.js";
|
|
import { multilineTextSchema } from "./text.js";
|
|
import {
|
|
lowTrustReviewPresetPolicySchema,
|
|
trustAuthorizationPolicySchema,
|
|
} from "./trust-policy.js";
|
|
import { objectWithoutDefaults } from "./partial.js";
|
|
import { questionSetToAskUserQuestionsPayload } from "../question-set.js";
|
|
|
|
export const issueSummariesQuerySchema = z.object({
|
|
ids: z.string().transform((value) => value.split(",").map((id) => id.trim().toLowerCase()))
|
|
.pipe(z.array(z.string().guid()).min(1).max(ISSUE_SUMMARIES_MAX_IDS))
|
|
.transform((ids) => [...new Set(ids)]),
|
|
}).strict();
|
|
|
|
export const issueBlockedInboxStateSchema = z.enum([
|
|
"needs_attention",
|
|
"awaiting_decision",
|
|
"external_wait",
|
|
"recovery_open",
|
|
"missing_disposition",
|
|
]);
|
|
|
|
export const issueBlockedInboxSeveritySchema = z.enum([
|
|
"critical",
|
|
"high",
|
|
"medium",
|
|
"low",
|
|
]);
|
|
|
|
export const issueBlockedInboxReasonSchema = z.enum([
|
|
"blocked_by_unassigned_issue",
|
|
"blocked_by_assigned_backlog_issue",
|
|
"blocked_by_uninvokable_assignee",
|
|
"blocked_by_cancelled_issue",
|
|
"blocked_chain_stalled",
|
|
"invalid_review_participant",
|
|
"in_review_without_action_path",
|
|
"missing_successful_run_disposition",
|
|
"pending_board_decision",
|
|
"pending_user_decision",
|
|
"external_owner_action",
|
|
"open_recovery_issue",
|
|
]);
|
|
|
|
export const issueBlockedInboxIssueRefSchema = z
|
|
.object({
|
|
id: z.string().guid(),
|
|
identifier: z.string().nullable(),
|
|
title: z.string(),
|
|
status: z.enum(ISSUE_STATUSES),
|
|
priority: z.enum(ISSUE_PRIORITIES),
|
|
assigneeAgentId: z.string().guid().nullable(),
|
|
assigneeUserId: z.string().nullable(),
|
|
})
|
|
.strict();
|
|
|
|
export const issueBlockedInboxAttentionSchema = z
|
|
.object({
|
|
kind: z.literal("blocked"),
|
|
state: issueBlockedInboxStateSchema,
|
|
reason: issueBlockedInboxReasonSchema,
|
|
severity: issueBlockedInboxSeveritySchema,
|
|
stoppedSinceAt: z.string().datetime().nullable(),
|
|
owner: z
|
|
.object({
|
|
type: z.enum(["agent", "user", "board", "external", "unknown"]),
|
|
agentId: z.string().guid().nullable(),
|
|
userId: z.string().nullable(),
|
|
label: z.string().nullable(),
|
|
})
|
|
.strict(),
|
|
action: z
|
|
.object({
|
|
label: z.string().trim().min(1),
|
|
detail: z.string().nullable(),
|
|
})
|
|
.strict(),
|
|
sourceIssue: issueBlockedInboxIssueRefSchema.nullable(),
|
|
leafIssue: issueBlockedInboxIssueRefSchema.nullable(),
|
|
recoveryIssue: issueBlockedInboxIssueRefSchema.nullable(),
|
|
approvalId: z.string().guid().nullable(),
|
|
interactionId: z.string().guid().nullable(),
|
|
sampleIssueIdentifier: z.string().nullable(),
|
|
redaction: z
|
|
.object({
|
|
externalDetailsRedacted: z.boolean(),
|
|
secretFieldsOmitted: z.literal(true),
|
|
})
|
|
.strict(),
|
|
})
|
|
.strict();
|
|
|
|
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
|
|
"inherit",
|
|
"shared_workspace",
|
|
"isolated_workspace",
|
|
"operator_branch",
|
|
"reuse_existing",
|
|
"agent_default",
|
|
] as const;
|
|
|
|
// Mirrors git check-ref-format for a single branch name. The runtime verifies
|
|
// the branch actually exists before attaching, so this only rejects values
|
|
// that could never be a branch (path escapes, option injection, ref syntax).
|
|
export function isValidExistingBranchName(value: string): boolean {
|
|
if (value.length === 0 || value.length > 255) return false;
|
|
if (value.startsWith("-") || value.startsWith("/") || value.startsWith("."))
|
|
return false;
|
|
if (value.endsWith("/") || value.endsWith(".") || value.endsWith(".lock"))
|
|
return false;
|
|
if (
|
|
value.includes("..") ||
|
|
value.includes("//") ||
|
|
value.includes("@{") ||
|
|
value.includes("/.")
|
|
)
|
|
return false;
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x20\x7f~^:?*[\\]/.test(value)) return false;
|
|
return true;
|
|
}
|
|
|
|
const executionWorkspaceStrategySchema = z
|
|
.object({
|
|
type: z
|
|
.enum([
|
|
"project_primary",
|
|
"git_worktree",
|
|
"adapter_managed",
|
|
"cloud_sandbox",
|
|
])
|
|
.optional(),
|
|
baseRef: z.string().optional().nullable(),
|
|
branchTemplate: z.string().optional().nullable(),
|
|
existingBranch: z
|
|
.string()
|
|
.trim()
|
|
.refine(isValidExistingBranchName, {
|
|
message: "existingBranch must be a valid git branch name",
|
|
})
|
|
.optional()
|
|
.nullable(),
|
|
worktreeParentDir: z.string().optional().nullable(),
|
|
provisionCommand: z.string().optional().nullable(),
|
|
runtimeProvisionCommand: z.string().optional().nullable(),
|
|
teardownCommand: z.string().optional().nullable(),
|
|
})
|
|
.strict()
|
|
.superRefine((strategy, ctx) => {
|
|
if (!strategy.existingBranch) return;
|
|
if (strategy.type !== "git_worktree") {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["existingBranch"],
|
|
message:
|
|
'existingBranch requires workspaceStrategy.type "git_worktree"',
|
|
});
|
|
}
|
|
if (strategy.branchTemplate) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["existingBranch"],
|
|
message: "existingBranch and branchTemplate are mutually exclusive",
|
|
});
|
|
}
|
|
});
|
|
|
|
const ipv4CidrPattern =
|
|
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\/(?:3[0-2]|[12]?\d)$/;
|
|
const protectedTaskEgressCidrs = [
|
|
"0.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"224.0.0.0/4",
|
|
] as const;
|
|
|
|
function ipv4CidrRange(cidr: string): [number, number] | null {
|
|
if (!ipv4CidrPattern.test(cidr)) return null;
|
|
const [address, prefixText] = cidr.split("/");
|
|
const addressValue = address
|
|
.split(".")
|
|
.reduce((value, octet) => value * 256 + Number(octet), 0);
|
|
const prefix = Number(prefixText);
|
|
const blockSize = 2 ** (32 - prefix);
|
|
const start = Math.floor(addressValue / blockSize) * blockSize;
|
|
return [start, start + blockSize - 1];
|
|
}
|
|
|
|
function isAllowedTaskEgressCidr(cidr: string): boolean {
|
|
const range = ipv4CidrRange(cidr);
|
|
if (!range) return false;
|
|
return protectedTaskEgressCidrs.every((protectedCidr) => {
|
|
const protectedRange = ipv4CidrRange(protectedCidr);
|
|
return (
|
|
protectedRange !== null &&
|
|
(range[1] < protectedRange[0] || range[0] > protectedRange[1])
|
|
);
|
|
});
|
|
}
|
|
|
|
export const issueExecutionWorkspaceSettingsSchema = z
|
|
.object({
|
|
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
|
|
sharedWorkspaceConcurrency: z
|
|
.enum(["auto", "serialize", "allow"])
|
|
.optional(),
|
|
environmentId: z.string().guid().optional().nullable(),
|
|
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
|
workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
networkEgress: z
|
|
.object({
|
|
allowFqdns: z
|
|
.array(
|
|
z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.regex(
|
|
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
|
|
"Network egress FQDNs must be hostnames without a URL scheme or path",
|
|
)
|
|
.max(253),
|
|
)
|
|
.max(100)
|
|
.optional(),
|
|
allowCidrs: z
|
|
.array(
|
|
z
|
|
.string()
|
|
.trim()
|
|
.regex(
|
|
ipv4CidrPattern,
|
|
"Invalid IPv4 CIDR (must use octets 0-255 and prefix 0-32)",
|
|
)
|
|
.max(64)
|
|
.refine(
|
|
isAllowedTaskEgressCidr,
|
|
"Task-scoped network egress CIDRs cannot overlap private, loopback, link-local, CGNAT, or multicast ranges",
|
|
),
|
|
)
|
|
.max(100)
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.optional()
|
|
.nullable(),
|
|
})
|
|
.strict()
|
|
.superRefine((settings, ctx) => {
|
|
if (
|
|
settings.workspaceStrategy?.existingBranch &&
|
|
settings.mode !== "isolated_workspace"
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["workspaceStrategy", "existingBranch"],
|
|
message: 'existingBranch requires mode "isolated_workspace"',
|
|
});
|
|
}
|
|
});
|
|
|
|
export const issueAssigneeAdapterOverridesSchema = z
|
|
.object({
|
|
adapterConfig: z.record(z.string(), z.unknown())
|
|
.refine(config => !Object.prototype.hasOwnProperty.call(config, "homeMode"), "homeMode must be set by an operator on the agent, not on a task")
|
|
.refine(config => !config.env || typeof config.env !== "object" || !Object.keys(config.env).some(key => key.toUpperCase() === "PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME"), "Prepared harness homes must be provisioned by the execution environment, not on a task")
|
|
.refine(config => !config.env || typeof config.env !== "object" || !Object.keys(config.env).some(key => ["BASH_ENV", "ZDOTDIR", "GH_CONFIG_DIR", "PAPERCLIP_GITHUB_LAUNCHER_DIR"].includes(key.toUpperCase())), "Managed shell helpers must be provisioned by the execution environment, not on a task")
|
|
.optional(),
|
|
useProjectWorkspace: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const issueExecutionStagePrincipalBaseSchema = z.object({
|
|
type: z.enum(["agent", "user"]),
|
|
agentId: z.string().guid().optional().nullable(),
|
|
userId: z.string().optional().nullable(),
|
|
});
|
|
|
|
export const issueExecutionStagePrincipalSchema =
|
|
issueExecutionStagePrincipalBaseSchema.superRefine((value, ctx) => {
|
|
if (value.type === "agent") {
|
|
if (!value.agentId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Agent participants require agentId",
|
|
path: ["agentId"],
|
|
});
|
|
}
|
|
if (value.userId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Agent participants cannot set userId",
|
|
path: ["userId"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (!value.userId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "User participants require userId",
|
|
path: ["userId"],
|
|
});
|
|
}
|
|
if (value.agentId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "User participants cannot set agentId",
|
|
path: ["agentId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const issueExecutionStageParticipantSchema =
|
|
issueExecutionStagePrincipalBaseSchema
|
|
.extend({
|
|
id: z.string().guid().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (value.type === "agent") {
|
|
if (!value.agentId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Agent participants require agentId",
|
|
path: ["agentId"],
|
|
});
|
|
}
|
|
if (value.userId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Agent participants cannot set userId",
|
|
path: ["userId"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (!value.userId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "User participants require userId",
|
|
path: ["userId"],
|
|
});
|
|
}
|
|
if (value.agentId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "User participants cannot set agentId",
|
|
path: ["agentId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const issueExecutionStageSchema = z.object({
|
|
id: z.string().guid().optional(),
|
|
type: z.enum(ISSUE_EXECUTION_STAGE_TYPES),
|
|
approvalsNeeded: z.literal(1).optional().default(1),
|
|
participants: z.array(issueExecutionStageParticipantSchema).default([]),
|
|
});
|
|
|
|
export const issueExecutionMonitorPolicySchema = z.object({
|
|
nextCheckAt: z.string().datetime(),
|
|
notes: z.string().max(500).optional().nullable().default(null),
|
|
scheduledBy: z
|
|
.enum(ISSUE_MONITOR_SCHEDULED_BY)
|
|
.optional()
|
|
.default("assignee"),
|
|
kind: z
|
|
.enum(ISSUE_EXECUTION_MONITOR_KINDS)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
serviceName: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(120)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
externalRef: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(500)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
timeoutAt: z.string().datetime().optional().nullable().default(null),
|
|
maxAttempts: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.max(100)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
recoveryPolicy: z
|
|
.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
});
|
|
|
|
export const requiredQualityReviewSchema = z.object({
|
|
version: z.literal(1),
|
|
deliverables: z.array(z.object({
|
|
key: z.string().regex(/^[a-z][a-z0-9_-]{0,79}$/),
|
|
label: z.string().trim().min(1).max(120),
|
|
}).strict()).min(1).max(10),
|
|
}).strict().superRefine((value, ctx) => {
|
|
if (new Set(value.deliverables.map(item => item.key)).size !== value.deliverables.length)
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Quality review deliverable keys must be unique", path: ["deliverables"] });
|
|
});
|
|
|
|
export const issueExecutionPolicySchema = z.object({
|
|
requiredQualityReview: requiredQualityReviewSchema.optional(),
|
|
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
|
|
commentRequired: z.boolean().optional().default(true),
|
|
stages: z.array(issueExecutionStageSchema).default([]),
|
|
monitor: issueExecutionMonitorPolicySchema.optional().nullable(),
|
|
reviewPreset: lowTrustReviewPresetPolicySchema.optional(),
|
|
authorizationPolicy: trustAuthorizationPolicySchema.optional(),
|
|
maxReviewRounds: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.max(50)
|
|
.optional()
|
|
.nullable()
|
|
.default(null),
|
|
});
|
|
|
|
export const issueExecutionMonitorStateSchema = z.object({
|
|
status: z.enum(ISSUE_EXECUTION_MONITOR_STATE_STATUSES),
|
|
nextCheckAt: z.string().datetime().nullable(),
|
|
lastTriggeredAt: z.string().datetime().nullable(),
|
|
attemptCount: z.number().int().nonnegative().default(0),
|
|
notes: z.string().max(500).nullable(),
|
|
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).nullable(),
|
|
kind: z
|
|
.enum(ISSUE_EXECUTION_MONITOR_KINDS)
|
|
.nullable()
|
|
.optional()
|
|
.default(null),
|
|
serviceName: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(120)
|
|
.nullable()
|
|
.optional()
|
|
.default(null),
|
|
externalRef: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(500)
|
|
.nullable()
|
|
.optional()
|
|
.default(null),
|
|
timeoutAt: z.string().datetime().nullable().optional().default(null),
|
|
maxAttempts: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.max(100)
|
|
.nullable()
|
|
.optional()
|
|
.default(null),
|
|
recoveryPolicy: z
|
|
.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)
|
|
.nullable()
|
|
.optional()
|
|
.default(null),
|
|
clearedAt: z.string().datetime().nullable(),
|
|
clearReason: z.enum(ISSUE_EXECUTION_MONITOR_CLEAR_REASONS).nullable(),
|
|
});
|
|
|
|
export const issueReviewRequestSchema = z
|
|
.object({
|
|
instructions: z.string().trim().min(1).max(20000),
|
|
})
|
|
.strict();
|
|
|
|
export const issueExecutionStateSchema = z.object({
|
|
status: z.enum(ISSUE_EXECUTION_STATE_STATUSES),
|
|
currentStageId: z.string().guid().nullable(),
|
|
currentStageIndex: z.number().int().nonnegative().nullable(),
|
|
currentStageType: z.enum(ISSUE_EXECUTION_STAGE_TYPES).nullable(),
|
|
currentParticipant: issueExecutionStagePrincipalSchema.nullable(),
|
|
returnAssignee: issueExecutionStagePrincipalSchema.nullable(),
|
|
reviewRequest: issueReviewRequestSchema.nullable().optional().default(null),
|
|
completedStageIds: z.array(z.string().guid()).default([]),
|
|
lastDecisionId: z.string().guid().nullable(),
|
|
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
|
|
monitor: issueExecutionMonitorStateSchema.optional().nullable(),
|
|
changesRequestedCount: z.number().int().nonnegative().optional().default(0),
|
|
});
|
|
|
|
export const issueRecoveryActionReadModelSchema = z.object({
|
|
id: z.string().guid(),
|
|
companyId: z.string().guid(),
|
|
sourceIssueId: z.string().guid(),
|
|
recoveryIssueId: z.string().guid().nullable(),
|
|
kind: z.enum(ISSUE_RECOVERY_ACTION_KINDS),
|
|
status: z.enum(ISSUE_RECOVERY_ACTION_STATUSES),
|
|
ownerType: z.enum(ISSUE_RECOVERY_ACTION_OWNER_TYPES),
|
|
ownerAgentId: z.string().guid().nullable(),
|
|
ownerUserId: z.string().nullable(),
|
|
previousOwnerAgentId: z.string().guid().nullable(),
|
|
returnOwnerAgentId: z.string().guid().nullable(),
|
|
cause: z.string().min(1),
|
|
fingerprint: z.string().min(1),
|
|
evidence: z.record(z.string(), z.unknown()),
|
|
nextAction: z.string().min(1),
|
|
wakePolicy: z.record(z.string(), z.unknown()).nullable(),
|
|
monitorPolicy: z.record(z.string(), z.unknown()).nullable(),
|
|
attemptCount: z.number().int().nonnegative(),
|
|
maxAttempts: z.number().int().positive().nullable(),
|
|
timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(),
|
|
lastAttemptAt: z.union([z.date(), z.string().datetime()]).nullable(),
|
|
outcome: z.enum(ISSUE_RECOVERY_ACTION_OUTCOMES).nullable(),
|
|
resolutionNote: z.string().nullable(),
|
|
resolvedAt: z.union([z.date(), z.string().datetime()]).nullable(),
|
|
createdAt: z.union([z.date(), z.string().datetime()]),
|
|
updatedAt: z.union([z.date(), z.string().datetime()]),
|
|
});
|
|
|
|
export type IssueRecoveryActionReadModel = z.infer<
|
|
typeof issueRecoveryActionReadModelSchema
|
|
>;
|
|
|
|
const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [
|
|
"restored",
|
|
"false_positive",
|
|
"blocked",
|
|
"cancelled",
|
|
] as const;
|
|
|
|
export const resumeWorkspaceFinalizationSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
ownerToken: z.string().guid(),
|
|
controllerAndCopybackStopped: z.literal(true),
|
|
stopEvidence: z.string().trim().min(20).max(12000),
|
|
}).strict();
|
|
|
|
export const retryWorkspaceExportSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
repairNote: z.string().trim().min(20).max(12000),
|
|
}).strict();
|
|
|
|
export const taskRetrySchema = z.object({
|
|
idempotencyKey: z.string().guid(),
|
|
observedRunId: z.string().guid().nullable(),
|
|
}).strict();
|
|
|
|
export const recoverWorkspaceSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
idempotencyKey: z.string().guid(),
|
|
}).strict();
|
|
|
|
export const reconstructWorkspaceSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
expectedStatusVersion: z.number().int().nonnegative(),
|
|
attachmentIds: z.array(z.string().guid()).min(1).max(20).refine(ids => new Set(ids).size === ids.length),
|
|
missingWork: z.string().trim().min(20).max(12000),
|
|
actionOutcome: z.enum(["completed", "not_performed", "mixed"]),
|
|
outcomeEvidence: z.string().trim().min(20).max(12000),
|
|
acknowledgeLostWork: z.literal(true),
|
|
}).strict();
|
|
|
|
export const resolveIssueRecoveryActionSchema = z
|
|
.object({
|
|
workspaceBaseRef: z.object({
|
|
requestedRef: z.string().trim().min(1).max(1024),
|
|
branch: z.string().trim().refine(isValidExistingBranchName, { message: "Choose a valid Git branch name" }),
|
|
}).strict().optional(),
|
|
executionReconciliation: z
|
|
.object({
|
|
runId: z.string().guid(),
|
|
providerStopped: z.literal(true),
|
|
actionOutcome: z.enum(["completed", "not_performed", "mixed"]),
|
|
outcomeEvidence: z.string().trim().min(20).max(12000),
|
|
workspaceRepairEvidence: z.string().trim().min(20).max(12000).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
actionId: z.string().guid().optional(),
|
|
outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES),
|
|
sourceIssueStatus: z.enum(ISSUE_STATUSES),
|
|
resolutionNote: multilineTextSchema.optional().nullable(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (value.workspaceBaseRef && (!value.actionId || value.outcome !== "restored" || value.sourceIssueStatus !== "todo" || value.executionReconciliation)) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceBaseRef"], message: "Branch repair must retry the exact recovery action" });
|
|
}
|
|
if (value.outcome === "restored") {
|
|
// A retained-source repair records evidence without changing task state.
|
|
// The route verifies the exact server-owned source and unchanged status.
|
|
if (value.executionReconciliation?.workspaceRepairEvidence) return;
|
|
if (
|
|
value.sourceIssueStatus !== "todo" &&
|
|
value.sourceIssueStatus !== "done" &&
|
|
value.sourceIssueStatus !== "in_review"
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"Restored recovery actions must move the source issue to todo, done, or in_review",
|
|
path: ["sourceIssueStatus"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (value.outcome === "blocked") {
|
|
if (value.sourceIssueStatus !== "blocked") {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"Blocked recovery actions must move the source issue to blocked",
|
|
path: ["sourceIssueStatus"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (value.outcome === "false_positive" || value.outcome === "cancelled") {
|
|
if (
|
|
value.sourceIssueStatus !== "done" &&
|
|
value.sourceIssueStatus !== "in_review"
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"This recovery outcome requires sourceIssueStatus to be done or in_review",
|
|
path: ["sourceIssueStatus"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
export type ResolveIssueRecoveryAction = z.infer<
|
|
typeof resolveIssueRecoveryActionSchema
|
|
>;
|
|
|
|
const issueRequestDepthInputSchema = z
|
|
.number()
|
|
.int()
|
|
.nonnegative()
|
|
.transform((value) => clampIssueRequestDepth(value));
|
|
|
|
type IssueCreateStatusDefaultInput = {
|
|
status?: unknown;
|
|
assigneeAgentId?: unknown;
|
|
assigneeUserId?: unknown;
|
|
};
|
|
|
|
export function resolveCreateIssueStatusDefault(
|
|
input: IssueCreateStatusDefaultInput,
|
|
): {
|
|
status: (typeof ISSUE_STATUSES)[number];
|
|
defaulted: boolean;
|
|
reason: "explicit" | "assigned_omitted_status" | "unassigned_omitted_status";
|
|
} {
|
|
if (typeof input.status === "string") {
|
|
return {
|
|
status: input.status as (typeof ISSUE_STATUSES)[number],
|
|
defaulted: false,
|
|
reason: "explicit",
|
|
};
|
|
}
|
|
|
|
const hasAssignee =
|
|
(typeof input.assigneeAgentId === "string" &&
|
|
input.assigneeAgentId.length > 0) ||
|
|
(typeof input.assigneeUserId === "string" &&
|
|
input.assigneeUserId.length > 0);
|
|
return {
|
|
status: hasAssignee ? "todo" : "backlog",
|
|
defaulted: true,
|
|
reason: hasAssignee
|
|
? "assigned_omitted_status"
|
|
: "unassigned_omitted_status",
|
|
};
|
|
}
|
|
|
|
function withCreateIssueStatusDefault<T extends z.ZodRawShape>(
|
|
schema: z.ZodObject<T>,
|
|
) {
|
|
return z.preprocess((input) => {
|
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
return input;
|
|
const raw = input as Record<string, unknown>;
|
|
if (raw.status !== undefined) return input;
|
|
return {
|
|
...raw,
|
|
status: resolveCreateIssueStatusDefault(raw).status,
|
|
};
|
|
}, schema);
|
|
}
|
|
|
|
const createIssueBaseSchema = z.object({
|
|
projectId: z.string().guid().optional().nullable(),
|
|
projectWorkspaceId: z.string().guid().optional().nullable(),
|
|
goalId: z.string().guid().optional().nullable(),
|
|
visibility: z.enum(["open", "private"]).optional().default("open"),
|
|
parentId: z.string().guid().optional().nullable(),
|
|
blockedByIssueIds: z.array(z.string().guid()).optional(),
|
|
unblockDescriptor: z
|
|
.object({
|
|
owner: z.union([
|
|
z.object({ agentId: z.string().guid() }).strict(),
|
|
z.object({ userId: z.string().trim().min(1) }).strict(),
|
|
z.literal("board"),
|
|
]),
|
|
action: multilineTextSchema.pipe(z.string().trim().min(1).max(2_000)),
|
|
})
|
|
.strict()
|
|
.optional()
|
|
.nullable(),
|
|
inheritExecutionWorkspaceFromIssueId: z.string().guid().optional().nullable(),
|
|
title: z.string().min(1),
|
|
description: multilineTextSchema.optional().nullable(),
|
|
status: z.enum(ISSUE_STATUSES),
|
|
workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"),
|
|
harnessKind: z.enum(ISSUE_HARNESS_KINDS).optional().nullable(),
|
|
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
|
reviewPolicy: z.enum(ISSUE_REVIEW_POLICIES).optional().nullable(),
|
|
assigneeAgentId: z.string().guid().optional().nullable(),
|
|
assigneeUserId: z.string().optional().nullable(),
|
|
requestDepth: issueRequestDepthInputSchema.optional().default(0),
|
|
createdByUserId: z.string().optional().nullable(),
|
|
responsibleUserId: z.string().optional().nullable(),
|
|
billingCode: z.string().optional().nullable(),
|
|
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema
|
|
.optional()
|
|
.nullable(),
|
|
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
|
|
executionWorkspaceId: z.string().guid().optional().nullable(),
|
|
executionWorkspacePreference: z
|
|
.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES)
|
|
.optional()
|
|
.nullable(),
|
|
workspaceSelection: taskWorkspaceSelectionSchema.optional(),
|
|
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema
|
|
.optional()
|
|
.nullable(),
|
|
labelIds: z.array(z.string().guid()).optional(),
|
|
watchdogDiscovery: z
|
|
.object({
|
|
kind: z.enum(ISSUE_WATCHDOG_DISCOVERY_KINDS),
|
|
evidenceMarkdown: multilineTextSchema.optional().nullable(),
|
|
})
|
|
.strict()
|
|
.optional()
|
|
.nullable(),
|
|
watchdog: z
|
|
.object({
|
|
agentId: z.string().guid(),
|
|
instructions: multilineTextSchema.optional().nullable(),
|
|
})
|
|
.strict()
|
|
.optional()
|
|
.nullable(),
|
|
});
|
|
|
|
function requireBlockedStatusForUnblockDescriptor(
|
|
value: { status?: string; unblockDescriptor?: unknown },
|
|
ctx: z.RefinementCtx,
|
|
) {
|
|
if (
|
|
value.unblockDescriptor != null &&
|
|
value.status !== undefined &&
|
|
value.status !== "blocked"
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "unblockDescriptor requires blocked status",
|
|
path: ["unblockDescriptor"],
|
|
});
|
|
}
|
|
}
|
|
|
|
const createIssueDuplicateGuardSchema = {
|
|
initialPlan: z.string().min(1).max(200000).optional().nullable(),
|
|
idempotencyKey: z.string().trim().min(1).max(255).optional().nullable(),
|
|
allowDuplicate: z
|
|
.boolean()
|
|
.describe(
|
|
"Bypasses recent-title duplicate detection; idempotency keys always replay their original issue",
|
|
)
|
|
.optional()
|
|
.default(false),
|
|
};
|
|
|
|
// Narrow intent flag set by the onboarding wizard on the single first task. The
|
|
// server owns the resulting origin kind (clients cannot set arbitrary origin
|
|
// kinds) and uses it to seed the agent greeting instead of an LLM welcome step.
|
|
const onboardingFirstTaskMarkerSchema = {
|
|
onboardingFirstTask: z.boolean().optional(),
|
|
};
|
|
|
|
export const createIssueInputSchema = createIssueBaseSchema.extend({
|
|
title: z.string().optional(),
|
|
status: createIssueBaseSchema.shape.status.optional(),
|
|
...createIssueDuplicateGuardSchema,
|
|
...onboardingFirstTaskMarkerSchema,
|
|
});
|
|
|
|
function requireTitleOrDescription(
|
|
value: { title?: string; description?: string | null },
|
|
ctx: z.RefinementCtx,
|
|
) {
|
|
if (!value.title?.trim() && !value.description?.trim()) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["title"], message: "Provide a title or task description" });
|
|
}
|
|
}
|
|
|
|
export const createIssueSchema = withCreateIssueStatusDefault(
|
|
createIssueBaseSchema.extend({
|
|
title: z.string().optional(),
|
|
...createIssueDuplicateGuardSchema,
|
|
...onboardingFirstTaskMarkerSchema,
|
|
}),
|
|
).superRefine(requireBlockedStatusForUnblockDescriptor).superRefine(requireTitleOrDescription);
|
|
|
|
export const setIssueTitleSchema = z.object({
|
|
title: z.string().trim().min(1).max(240),
|
|
onlyIfProvisional: z.boolean().optional().default(false),
|
|
idempotencyKey: z.string().trim().min(1).max(240).optional(),
|
|
}).strict();
|
|
|
|
export type SetIssueTitle = z.input<typeof setIssueTitleSchema>;
|
|
|
|
export type CreateIssue = z.infer<typeof createIssueSchema>;
|
|
|
|
export const upsertIssueWatchdogSchema = z
|
|
.object({
|
|
agentId: z.string().guid(),
|
|
instructions: multilineTextSchema.optional().nullable(),
|
|
})
|
|
.strict();
|
|
|
|
export type UpsertIssueWatchdog = z.infer<typeof upsertIssueWatchdogSchema>;
|
|
|
|
export const createChildIssueSchema = withCreateIssueStatusDefault(
|
|
createIssueBaseSchema
|
|
.omit({
|
|
parentId: true,
|
|
inheritExecutionWorkspaceFromIssueId: true,
|
|
watchdogDiscovery: true,
|
|
})
|
|
.extend({
|
|
title: z.string().optional(),
|
|
acceptanceCriteria: z
|
|
.array(z.string().trim().min(1).max(500))
|
|
.max(20)
|
|
.optional(),
|
|
blockParentUntilDone: z.boolean().optional().default(false),
|
|
}),
|
|
).superRefine(requireBlockedStatusForUnblockDescriptor).superRefine(requireTitleOrDescription);
|
|
|
|
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
|
|
|
|
export const createAcceptedPlanDecompositionSchema = z.object({
|
|
acceptedPlanRevisionId: z.string().guid(),
|
|
children: z.array(createChildIssueSchema).min(1).max(25),
|
|
});
|
|
|
|
export type CreateAcceptedPlanDecomposition = z.infer<
|
|
typeof createAcceptedPlanDecompositionSchema
|
|
>;
|
|
|
|
export const createIssueLabelSchema = z.object({
|
|
name: z.string().trim().min(1).max(48),
|
|
color: z
|
|
.string()
|
|
.regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
|
|
});
|
|
|
|
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
|
|
|
const issueCommentAttachmentIdsSchema = z
|
|
.array(z.string().uuid())
|
|
.max(20)
|
|
.refine((ids) => new Set(ids).size === ids.length, {
|
|
message: "Attachment ids must be unique",
|
|
});
|
|
|
|
export const updateIssueSchema = objectWithoutDefaults(
|
|
createIssueBaseSchema.omit({
|
|
workspaceSelection: true,
|
|
createdByUserId: true,
|
|
responsibleUserId: true,
|
|
watchdog: true,
|
|
}),
|
|
)
|
|
.partial()
|
|
.extend({
|
|
expectedStatusVersion: z.number().int().nonnegative().optional(),
|
|
/** Reject a policy write if the policy changed after the caller read it. */
|
|
expectedExecutionPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
requestDepth: issueRequestDepthInputSchema.optional(),
|
|
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
|
|
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
|
|
commentClientRequestId: z.string().uuid().optional(),
|
|
/** Only valid with a comment; the route binds these in the update transaction. */
|
|
attachmentIds: issueCommentAttachmentIdsSchema.optional(),
|
|
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
|
|
reviewInteractionId: z.string().guid().optional(),
|
|
reviewRequest: issueReviewRequestSchema.optional().nullable(),
|
|
reopen: z.boolean().optional(),
|
|
resume: z.boolean().optional(),
|
|
interrupt: z.boolean().optional(),
|
|
/** Assignment-only handoff; the following structured goal action owns the wake. */
|
|
deferWakeForGoal: z.boolean().optional(),
|
|
hiddenAt: z.string().datetime().nullable().optional(),
|
|
});
|
|
|
|
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
|
|
export type IssueExecutionWorkspaceSettings = z.infer<
|
|
typeof issueExecutionWorkspaceSettingsSchema
|
|
>;
|
|
|
|
export const stalledReviewDecisionSchema = z
|
|
.object({
|
|
action: z.enum(["approve", "request_changes", "send_back"]),
|
|
note: multilineTextSchema.pipe(z.string().min(1)).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (value.action === "request_changes" && !value.note?.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["note"],
|
|
message: "Request changes requires a note",
|
|
});
|
|
}
|
|
});
|
|
|
|
export type StalledReviewDecision = z.infer<typeof stalledReviewDecisionSchema>;
|
|
|
|
export const checkoutIssueSchema = z.object({
|
|
agentId: z.string().guid(),
|
|
expectedStatuses: z.array(z.enum(ISSUE_STATUSES)).nonempty(),
|
|
});
|
|
|
|
export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
|
|
|
|
const commentMetadataLabelSchema = z.string().trim().min(1).max(120);
|
|
const commentMetadataTextSchema = z.string().trim().min(1).max(2000);
|
|
|
|
export const issueCommentAuthorTypeSchema = z.enum(ISSUE_COMMENT_AUTHOR_TYPES);
|
|
|
|
export const issueCommentPresentationSchema = z
|
|
.object({
|
|
kind: z.enum(ISSUE_COMMENT_PRESENTATION_KINDS).default("message"),
|
|
tone: z.enum(ISSUE_COMMENT_PRESENTATION_TONES).default("neutral"),
|
|
title: z.string().trim().min(1).max(160).nullable().optional(),
|
|
detailsDefaultOpen: z.boolean().optional().default(false),
|
|
density: z.enum(ISSUE_COMMENT_PRESENTATION_DENSITIES).optional(),
|
|
})
|
|
.strict();
|
|
|
|
export type IssueCommentPresentation = z.infer<
|
|
typeof issueCommentPresentationSchema
|
|
>;
|
|
|
|
const issueCommentMetadataBaseRowSchema = z.object({
|
|
type: z.enum(ISSUE_COMMENT_METADATA_ROW_TYPES),
|
|
label: commentMetadataLabelSchema.nullable().optional(),
|
|
});
|
|
|
|
const issueCommentMetadataTextRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("text"),
|
|
text: commentMetadataTextSchema,
|
|
})
|
|
.strict();
|
|
|
|
const issueCommentMetadataCodeRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("code"),
|
|
code: z.string().min(1).max(4000),
|
|
language: z.string().trim().min(1).max(40).nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const issueCommentMetadataKeyValueRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("key_value"),
|
|
label: commentMetadataLabelSchema,
|
|
value: commentMetadataTextSchema,
|
|
})
|
|
.strict();
|
|
|
|
const issueCommentMetadataIssueLinkRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("issue_link"),
|
|
issueId: z.string().guid().nullable().optional(),
|
|
identifier: z.string().trim().min(1).max(80).nullable().optional(),
|
|
title: z.string().trim().min(1).max(240).nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("agent_link"),
|
|
agentId: z.string().guid(),
|
|
name: z.string().trim().min(1).max(160).nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema
|
|
.extend({
|
|
type: z.literal("run_link"),
|
|
runId: z.string().guid(),
|
|
agentId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().min(1).max(160).nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const issueCommentMetadataRowSchema = z
|
|
.discriminatedUnion("type", [
|
|
issueCommentMetadataTextRowSchema,
|
|
issueCommentMetadataCodeRowSchema,
|
|
issueCommentMetadataKeyValueRowSchema,
|
|
issueCommentMetadataIssueLinkRowSchema,
|
|
issueCommentMetadataAgentLinkRowSchema,
|
|
issueCommentMetadataRunLinkRowSchema,
|
|
])
|
|
.superRefine((value, ctx) => {
|
|
if (value.type === "issue_link" && !value.issueId && !value.identifier) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Issue link rows require issueId or identifier",
|
|
path: ["issueId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const issueCommentMetadataSectionSchema = z
|
|
.object({
|
|
title: z.string().trim().min(1).max(160).nullable().optional(),
|
|
rows: z.array(issueCommentMetadataRowSchema).min(1).max(50),
|
|
})
|
|
.strict();
|
|
|
|
export const issueCommentMetadataSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
chatMessage: z.object({
|
|
sourceTaskId: z.uuid(),
|
|
sourceTaskIdentifier: z.string().min(1).max(80),
|
|
sourceRunId: z.uuid(),
|
|
contentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
}).strict().optional(),
|
|
recoveryResponsibility: z.enum(["system", "user"]).optional(),
|
|
sourceChannel: z.enum(CHAT_PROVIDERS).optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
authorizationReason: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(160)
|
|
.nullable()
|
|
.optional(),
|
|
recovery: z.object({
|
|
kind: z.literal("disposition_repair_escalated"),
|
|
actionId: z.string().guid(),
|
|
attemptCount: z.number().int().nonnegative(),
|
|
maxAttempts: z.number().int().positive(),
|
|
reason: z.string().trim().min(1).max(160),
|
|
assigneeAgentId: z.string().guid().nullable(),
|
|
}).strict().optional(),
|
|
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
|
|
})
|
|
.strict();
|
|
|
|
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
|
|
|
|
export const sendChatMessageSchema = z.object({
|
|
body: multilineTextSchema.pipe(z.string().trim().min(1).max(20000)),
|
|
idempotencyKey: z.string().trim().min(1).max(240),
|
|
}).strict();
|
|
|
|
export const markIssueReadSchema = z.object({ throughCommentId: z.uuid().optional() }).strict();
|
|
|
|
export const addIssueCommentSchema = z.object({
|
|
clientRequestId: z.string().uuid().optional(),
|
|
body: multilineTextSchema.pipe(z.string().min(1)),
|
|
attachmentIds: issueCommentAttachmentIdsSchema.optional(),
|
|
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
|
|
authorType: issueCommentAuthorTypeSchema.optional(),
|
|
presentation: issueCommentPresentationSchema.nullable().optional(),
|
|
metadata: issueCommentMetadataSchema.nullable().optional(),
|
|
reopen: z.boolean().optional(),
|
|
resume: z.boolean().optional(),
|
|
interrupt: z.boolean().optional(),
|
|
});
|
|
|
|
export type AddIssueComment = z.infer<typeof addIssueCommentSchema>;
|
|
|
|
export const issueThreadInteractionStatusSchema = z.enum(
|
|
ISSUE_THREAD_INTERACTION_STATUSES,
|
|
);
|
|
export const issueThreadInteractionKindSchema = z.enum(
|
|
ISSUE_THREAD_INTERACTION_KINDS,
|
|
);
|
|
export const issueThreadInteractionCanonicalResolverPolicySchema = z
|
|
.enum(ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES)
|
|
.describe("Canonical resolver audience: anyone, not_creator, or human_only.");
|
|
export const issueThreadInteractionResolverPolicySchema = z
|
|
.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES)
|
|
.describe(
|
|
"Resolver audience. Use anyone, not_creator, or human_only; board_or_agents and board_only are deprecated compatibility aliases.",
|
|
);
|
|
export const issueThreadInteractionResolverPolicyProvenanceSchema = z.enum(
|
|
ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES,
|
|
);
|
|
export const issueThreadInteractionEffectiveResolverPolicySourceSchema = z.enum(
|
|
ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES,
|
|
);
|
|
export const issueThreadInteractionContinuationPolicySchema = z.enum(
|
|
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
|
);
|
|
|
|
export const connectionIntentPhaseSchema = z.enum([
|
|
"requested",
|
|
"authorizing",
|
|
"needs_retry",
|
|
]);
|
|
const connectionIntentBrandAssetSchema = z
|
|
.string()
|
|
.max(2048)
|
|
.refine((value) => {
|
|
if (/^\/brands\/apps\/[a-z0-9][a-z0-9._-]*\.(?:svg|png)$/i.test(value))
|
|
return true;
|
|
try {
|
|
return new URL(value).protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, "Connection intent brand assets must be HTTPS URLs or local app brand paths");
|
|
|
|
export const connectionIntentPayloadSchema = z
|
|
.object({
|
|
setupAccess: z.object({
|
|
connectionId: z.string().guid(),
|
|
access: z.union([z.literal("all_agents"), z.object({ agentIds: z.array(z.string().guid()).max(250) }).strict()]),
|
|
}).strict().optional(),
|
|
savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid(), action: z.enum(["enter_credentials", "rotate_credentials", "confirm_identity"]).optional(), credentialRotation: z.object({ previousId: z.string().guid().nullable() }).strict().optional(), credentialEntry: z.object({
|
|
previousGenerations: z.record(z.string().refine(field => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(field)), z.string().uuid().nullable()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 5),
|
|
requireAll: z.boolean(),
|
|
}).strict().optional() }).strict()
|
|
.refine(value => value.action !== "rotate_credentials" || !!value.credentialRotation, "Rotation requests need a completion boundary").optional(),
|
|
accessRequest: z.object({
|
|
connectionId: z.string().guid(),
|
|
connectionName: z.string().trim().min(1).max(160),
|
|
tools: z.array(z.object({
|
|
catalogEntryId: z.string().guid(),
|
|
toolName: z.string().trim().min(1).max(160),
|
|
versionHash: z.string().min(1).max(256),
|
|
permission: z.enum(["allowed", "ask_first"]),
|
|
}).strict()).min(1).max(500).refine(tools => new Set(tools.map(tool => tool.catalogEntryId)).size === tools.length, "Requested tools must be unique"),
|
|
}).strict().optional(),
|
|
upstreamService: z.object({ slug: z.string().min(1).max(120), name: z.string().min(1).max(160), selectionInteractionId: z.string().guid().optional() }).strict().optional(),
|
|
purpose: z.enum(["ai", "channel"]).optional(),
|
|
version: z.literal(1),
|
|
serviceSlug: z.string().trim().min(1).max(120),
|
|
serviceName: z.string().trim().min(1).max(160),
|
|
serviceLogoUrl: connectionIntentBrandAssetSchema.nullable().optional(),
|
|
serviceDarkLogoUrl: connectionIntentBrandAssetSchema.nullable().optional(),
|
|
requestingAgentId: z.string().guid(),
|
|
requestingAgentName: z.string().trim().min(1).max(160),
|
|
phase: connectionIntentPhaseSchema,
|
|
})
|
|
.strict();
|
|
|
|
export const connectionIntentResultSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
instruction: z.string().max(4000).optional(),
|
|
outcome: z.enum(["connected", "declined", "superseded", "expired"]),
|
|
connectionId: z.string().guid().nullable().optional(),
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
supersededByInteractionId: z.string().guid().nullable().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (value.outcome === "connected" && !value.connectionId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["connectionId"],
|
|
message: "Connected intents require a connection id",
|
|
});
|
|
}
|
|
if (value.outcome === "superseded" && !value.supersededByInteractionId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["supersededByInteractionId"],
|
|
message: "Superseded intents require the replacement interaction id",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const issueDocumentKeySchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(64)
|
|
.regex(
|
|
/^[a-z0-9][a-z0-9_-]*$/,
|
|
"Document key must be lowercase letters, numbers, _ or -",
|
|
);
|
|
|
|
export const suggestedTaskDraftSchema = z
|
|
.object({
|
|
clientKey: z.string().trim().min(1).max(120),
|
|
parentClientKey: z.string().trim().min(1).max(120).nullable().optional(),
|
|
parentId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().min(1).max(240),
|
|
description: multilineTextSchema
|
|
.pipe(z.string().trim().max(20000))
|
|
.nullable()
|
|
.optional(),
|
|
initialPlan: z.string().trim().min(1).max(200000).nullable().optional(),
|
|
requiredQualityReview: requiredQualityReviewSchema.optional(),
|
|
priority: z.enum(ISSUE_PRIORITIES).nullable().optional(),
|
|
workMode: z.enum(ISSUE_WORK_MODES).nullable().optional(),
|
|
assigneeAgentId: z.string().guid().nullable().optional(),
|
|
assigneeUserId: z.string().trim().min(1).nullable().optional(),
|
|
projectId: z.string().guid().nullable().optional(),
|
|
goalId: z.string().guid().nullable().optional(),
|
|
billingCode: z.string().trim().max(120).nullable().optional(),
|
|
labels: z.array(z.string().trim().min(1).max(48)).max(20).optional(),
|
|
hiddenInPreview: z.boolean().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (value.assigneeAgentId && value.assigneeUserId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Suggested tasks can only target one assignee",
|
|
path: ["assigneeAgentId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const suggestTasksPayloadSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
defaultParentId: z.string().guid().nullable().optional(),
|
|
tasks: z.array(suggestedTaskDraftSchema).min(1).max(50),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const seenClientKeys = new Set<string>();
|
|
for (const [index, task] of value.tasks.entries()) {
|
|
if (seenClientKeys.has(task.clientKey)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "clientKey must be unique within one interaction",
|
|
path: ["tasks", index, "clientKey"],
|
|
});
|
|
continue;
|
|
}
|
|
seenClientKeys.add(task.clientKey);
|
|
}
|
|
});
|
|
|
|
export const suggestTasksResultCreatedTaskSchema = z.object({
|
|
clientKey: z.string().trim().min(1).max(120),
|
|
issueId: z.string().guid(),
|
|
identifier: z.string().trim().min(1).nullable().optional(),
|
|
title: z.string().trim().min(1).nullable().optional(),
|
|
parentIssueId: z.string().guid().nullable().optional(),
|
|
parentIdentifier: z.string().trim().min(1).nullable().optional(),
|
|
});
|
|
|
|
export const suggestTasksResultSchema = z.object({
|
|
version: z.literal(1),
|
|
outcome: z
|
|
.enum(["skipped", "withdrawn", "issue_closed", "addressee_deleted"])
|
|
.optional(),
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(),
|
|
skippedClientKeys: z
|
|
.array(z.string().trim().min(1).max(120))
|
|
.max(50)
|
|
.optional(),
|
|
rejectionReason: z.string().trim().max(4000).nullable().optional(),
|
|
});
|
|
|
|
export const askUserQuestionsQuestionOptionSchema = z.object({
|
|
id: z.string().trim().min(1).max(160),
|
|
label: z.string().trim().min(1).max(1000),
|
|
description: z.string().trim().max(4000).nullable().optional(),
|
|
freeText: z
|
|
.boolean()
|
|
.optional()
|
|
.describe(
|
|
"When true, selecting this option reveals an inline text field; the typed value is returned as the question's otherText. Use this for a real \"I'll describe it\" choice instead of authoring a dead option that does nothing. At most one free-text option per question.",
|
|
),
|
|
});
|
|
|
|
export const askUserQuestionsQuestionSchema = z.object({
|
|
id: z.string().trim().min(1).max(160),
|
|
prompt: z.string().trim().min(1).max(4000),
|
|
helpText: z.string().trim().max(4000).nullable().optional(),
|
|
selectionMode: z.enum(["single", "multi"]),
|
|
required: z.boolean().optional(),
|
|
allowOther: z.boolean().optional(),
|
|
options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(129),
|
|
});
|
|
|
|
const paperclipQuestionOptionSchema = z.object({
|
|
id: maxStringLength(z.string().min(1), 160),
|
|
label: maxStringLength(z.string().min(1), 1000),
|
|
description: maxStringLength(z.string(), 4000).optional(),
|
|
recommended: z.boolean().optional(),
|
|
});
|
|
|
|
const paperclipQuestionSchema = z
|
|
.object({
|
|
id: maxStringLength(z.string().min(1), 160),
|
|
header: maxStringLength(z.string(), 1000).optional(),
|
|
prompt: maxStringLength(z.string().min(1), 4000),
|
|
helpText: maxStringLength(z.string(), 4000).optional(),
|
|
required: z.boolean(),
|
|
answerMode: z.enum(["single_select", "multi_select", "text"]),
|
|
initialText: z.string().refine(value => value.length <= 200000 && Array.from(value).length <= 100000, "initial text exceeds 100000 Unicode code points").optional(),
|
|
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
|
|
customAnswer: z
|
|
.object({
|
|
enabled: z.literal(true),
|
|
label: maxStringLength(z.string(), 1000).optional(),
|
|
placeholder: maxStringLength(z.string(), 1000).optional(),
|
|
})
|
|
.optional(),
|
|
textValidation: z
|
|
.object({
|
|
minLength: z.number().int().min(0).max(100000).optional(),
|
|
maxLength: z.number().int().min(0).max(100000).optional(),
|
|
pattern: maxStringLength(z.string(), 1000).optional(),
|
|
inputType: z.enum(["text", "number", "integer"]).optional(),
|
|
minimum: z.number().finite().optional(),
|
|
maximum: z.number().finite().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (value.initialText !== undefined && value.answerMode !== "text") {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "only text questions can define initial text", path: ["initialText"] });
|
|
}
|
|
if (
|
|
value.answerMode === "text" &&
|
|
value.options &&
|
|
value.options.length > 0
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "text questions cannot define options",
|
|
path: ["options"],
|
|
});
|
|
}
|
|
if (
|
|
value.answerMode !== "text" &&
|
|
(!value.options || value.options.length === 0)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "select questions require options",
|
|
path: ["options"],
|
|
});
|
|
}
|
|
if (value.answerMode === "text" && value.customAnswer) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "text questions cannot define customAnswer",
|
|
path: ["customAnswer"],
|
|
});
|
|
}
|
|
if (
|
|
value.textValidation?.minLength !== undefined &&
|
|
value.textValidation.maxLength !== undefined &&
|
|
value.textValidation.minLength > value.textValidation.maxLength
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "minLength cannot exceed maxLength",
|
|
path: ["textValidation"],
|
|
});
|
|
}
|
|
if (
|
|
value.textValidation?.minimum !== undefined &&
|
|
value.textValidation.maximum !== undefined &&
|
|
value.textValidation.minimum > value.textValidation.maximum
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "minimum cannot exceed maximum",
|
|
path: ["textValidation"],
|
|
});
|
|
}
|
|
if (value.textValidation?.pattern !== undefined) {
|
|
try {
|
|
new RegExp(value.textValidation.pattern);
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "pattern must be a valid regular expression",
|
|
path: ["textValidation", "pattern"],
|
|
});
|
|
}
|
|
}
|
|
const optionIds = value.options?.map((option) => option.id) ?? [];
|
|
if (new Set(optionIds).size !== optionIds.length) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "option ids must be unique",
|
|
path: ["options"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const paperclipQuestionSetPayloadSchema = z
|
|
.object({
|
|
schema: z.literal("paperclip.question_set.v1"),
|
|
title: maxStringLength(z.string(), 1000).optional(),
|
|
description: maxStringLength(z.string(), 100_000).optional(),
|
|
submitLabel: maxStringLength(z.string(), 200).optional(),
|
|
questions: z.array(paperclipQuestionSchema).min(1).max(64),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const questionIds = value.questions.map((question) => question.id);
|
|
if (new Set(questionIds).size !== questionIds.length) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "question ids must be unique",
|
|
path: ["questions"],
|
|
});
|
|
}
|
|
});
|
|
|
|
const askUserQuestionsPayloadFields = {
|
|
version: z.literal(1),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
submitLabel: z.string().trim().max(120).nullable().optional(),
|
|
supersedeOnUserComment: z.boolean().optional(),
|
|
questions: z.array(askUserQuestionsQuestionSchema).min(1).max(64),
|
|
/** Exact canonical presentation retained for a recovered harness request. */
|
|
questionSet: paperclipQuestionSetPayloadSchema.optional(),
|
|
/** Stable correlation for draft handoff from a live runtime request. */
|
|
runtimeRequestId: z.string().trim().min(1).max(255).nullable().optional(),
|
|
};
|
|
|
|
export const askUserQuestionsPayloadSchema = z
|
|
.object(askUserQuestionsPayloadFields)
|
|
.superRefine((value, ctx) => {
|
|
const seenQuestionIds = new Set<string>();
|
|
for (const [questionIndex, question] of value.questions.entries()) {
|
|
if (seenQuestionIds.has(question.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Question ids must be unique within one interaction",
|
|
path: ["questions", questionIndex, "id"],
|
|
});
|
|
}
|
|
seenQuestionIds.add(question.id);
|
|
|
|
const seenOptionIds = new Set<string>();
|
|
let freeTextOptionCount = 0;
|
|
for (const [optionIndex, option] of question.options.entries()) {
|
|
if (seenOptionIds.has(option.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Option ids must be unique within one question",
|
|
path: ["questions", questionIndex, "options", optionIndex, "id"],
|
|
});
|
|
}
|
|
seenOptionIds.add(option.id);
|
|
if (option.freeText) {
|
|
freeTextOptionCount += 1;
|
|
if (freeTextOptionCount > 1) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "A question may declare at most one free-text option",
|
|
path: [
|
|
"questions",
|
|
questionIndex,
|
|
"options",
|
|
optionIndex,
|
|
"freeText",
|
|
],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
export const askUserQuestionsAnswerSchema = z.object({
|
|
questionId: z.string().trim().min(1).max(160),
|
|
optionIds: z.array(z.string().trim().min(1).max(160)).max(129),
|
|
// The persisted question determines whether this is exact editor text or a
|
|
// legacy custom answer; normalize only after that trusted context is known.
|
|
otherText: z.string().max(100000)
|
|
.nullable()
|
|
.optional(),
|
|
});
|
|
|
|
export const askUserQuestionsResultSchema = z.object({
|
|
version: z.literal(1),
|
|
outcome: z
|
|
.enum(["skipped", "withdrawn", "issue_closed", "addressee_deleted"])
|
|
.optional(),
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
answers: z.array(askUserQuestionsAnswerSchema).max(64),
|
|
cancelled: z.literal(true).optional(),
|
|
cancellationReason: z.string().trim().max(4000).nullable().optional(),
|
|
expirationReason: z
|
|
.enum(["superseded_by_comment", "superseded_by_newer_interaction"])
|
|
.optional(),
|
|
commentId: z.string().guid().nullable().optional(),
|
|
// Set alongside expirationReason "superseded_by_newer_interaction": the id of
|
|
// the newer sibling ask_user_questions that replaced this one (PAP-437).
|
|
supersededByInteractionId: z.string().guid().nullable().optional(),
|
|
summaryMarkdown: z.string().max(20000).nullable().optional(),
|
|
});
|
|
|
|
const requestConfirmationHrefSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(2000)
|
|
.refine((value) => {
|
|
if (value.startsWith("#")) return true;
|
|
if (value.startsWith("/")) return !value.startsWith("//");
|
|
return /^https?:\/\//i.test(value);
|
|
}, "href must be a root-relative path, same-page fragment, or http(s) URL");
|
|
|
|
const requestConfirmationTargetBaseSchema = z.object({
|
|
label: z.string().trim().min(1).max(120).nullable().optional(),
|
|
href: requestConfirmationHrefSchema.nullable().optional(),
|
|
});
|
|
|
|
export const requestConfirmationIssueDocumentTargetSchema =
|
|
requestConfirmationTargetBaseSchema.extend({
|
|
type: z.literal("issue_document"),
|
|
issueId: z.string().guid().nullable().optional(),
|
|
documentId: z.string().guid().nullable().optional(),
|
|
key: issueDocumentKeySchema,
|
|
revisionId: z.string().guid(),
|
|
revisionNumber: z.number().int().positive().nullable().optional(),
|
|
});
|
|
|
|
export const requestConfirmationCustomTargetSchema =
|
|
requestConfirmationTargetBaseSchema.extend({
|
|
type: z.literal("custom"),
|
|
key: z.string().trim().min(1).max(120),
|
|
revisionId: z.string().trim().min(1).max(255).nullable().optional(),
|
|
revisionNumber: z.number().int().positive().nullable().optional(),
|
|
});
|
|
|
|
export const requestConfirmationIssueAttachmentTargetSchema =
|
|
requestConfirmationTargetBaseSchema.extend({
|
|
type: z.literal("issue_attachment"),
|
|
issueId: z.string().guid().nullable().optional(),
|
|
attachmentId: z.string().guid(),
|
|
assetId: z.string().guid(),
|
|
sha256: z.string().regex(/^[a-f0-9]{64}$/, "sha256 must identify the exact attachment bytes"),
|
|
});
|
|
|
|
export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [
|
|
requestConfirmationIssueDocumentTargetSchema,
|
|
requestConfirmationIssueAttachmentTargetSchema,
|
|
requestConfirmationCustomTargetSchema,
|
|
]);
|
|
|
|
export const requestConfirmationToolActionPayloadSchema = z.object({
|
|
version: z.literal(1),
|
|
actionRequestId: z.string().guid(),
|
|
invocationId: z.string().guid(),
|
|
toolName: z.string().trim().min(1).max(500),
|
|
toolDisplayName: z.string().trim().min(1).max(500),
|
|
connectionId: z.string().guid().nullable(),
|
|
applicationId: z.string().guid().nullable(),
|
|
appDisplayName: z.string().trim().min(1).max(500).nullable(),
|
|
risk: z.enum(["read", "write", "destructive"]),
|
|
rememberActionScope: z.string().trim().min(1).max(1000).optional(),
|
|
previewMarkdown: z.string().trim().min(1).max(20000),
|
|
argumentsSummaryJson: z.string().max(20000),
|
|
argumentsHash: z.string().trim().min(1).max(255),
|
|
expiresAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
export const requestConfirmationSecretProposalPayloadSchema = z.object({
|
|
version: z.literal(1),
|
|
proposalId: z.string().guid(),
|
|
sourceSecretLabel: z.string().trim().min(1).max(500),
|
|
configPath: z.string().trim().min(1).max(500),
|
|
targetAgentId: z.string().guid(),
|
|
targetAgentName: z.string().trim().min(1).max(500),
|
|
justification: z.string().trim().min(1).max(20000),
|
|
expiresAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
const qualityReviewBindingSchema = z.object({
|
|
key: z.string().regex(/^[a-z][a-z0-9_-]{0,79}$/),
|
|
requirementHash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
}).strict();
|
|
|
|
export const requestConfirmationPayloadSchema = z.object({
|
|
qualityReview: qualityReviewBindingSchema.optional(),
|
|
version: z.literal(1),
|
|
prompt: z.string().trim().min(1).max(1000),
|
|
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
|
|
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
|
|
rejectRequiresReason: z.boolean().optional(),
|
|
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
|
|
allowDeclineReason: z.boolean().optional().default(true),
|
|
declineReasonPlaceholder: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(240)
|
|
.nullable()
|
|
.optional(),
|
|
detailsMarkdown: z.string().max(20000).nullable().optional(),
|
|
supersedeOnUserComment: z.boolean().optional(),
|
|
target: requestConfirmationTargetSchema.nullable().optional(),
|
|
toolAction: requestConfirmationToolActionPayloadSchema.optional(),
|
|
secretProposal: requestConfirmationSecretProposalPayloadSchema.optional(),
|
|
});
|
|
|
|
export const requestCheckboxConfirmationOptionSchema = z.object({
|
|
id: z.string().trim().min(1).max(120),
|
|
label: z.string().trim().min(1).max(120),
|
|
description: z.string().trim().max(500).nullable().optional(),
|
|
});
|
|
|
|
export const requestCheckboxConfirmationPayloadSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
prompt: z.string().trim().min(1).max(1000),
|
|
detailsMarkdown: z.string().max(20000).nullable().optional(),
|
|
options: z
|
|
.array(requestCheckboxConfirmationOptionSchema)
|
|
.min(1)
|
|
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT),
|
|
defaultSelectedOptionIds: z
|
|
.array(z.string().trim().min(1).max(120))
|
|
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
|
|
.optional()
|
|
.default([]),
|
|
minSelected: z.number().int().min(0).optional().default(0),
|
|
maxSelected: z.number().int().min(0).nullable().optional(),
|
|
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
|
|
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
|
|
rejectRequiresReason: z.boolean().optional(),
|
|
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
|
|
allowDeclineReason: z.boolean().optional().default(true),
|
|
declineReasonPlaceholder: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(240)
|
|
.nullable()
|
|
.optional(),
|
|
supersedeOnUserComment: z.boolean().optional(),
|
|
target: requestConfirmationTargetSchema.nullable().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const optionIds = new Set<string>();
|
|
for (const [index, option] of value.options.entries()) {
|
|
if (optionIds.has(option.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Option ids must be unique within one checkbox confirmation",
|
|
path: ["options", index, "id"],
|
|
});
|
|
}
|
|
optionIds.add(option.id);
|
|
}
|
|
|
|
const defaultSelectedOptionIds = new Set<string>();
|
|
for (const [index, optionId] of value.defaultSelectedOptionIds.entries()) {
|
|
if (defaultSelectedOptionIds.has(optionId)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "defaultSelectedOptionIds must be unique",
|
|
path: ["defaultSelectedOptionIds", index],
|
|
});
|
|
continue;
|
|
}
|
|
defaultSelectedOptionIds.add(optionId);
|
|
if (!optionIds.has(optionId)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message:
|
|
"defaultSelectedOptionIds must reference existing option ids",
|
|
path: ["defaultSelectedOptionIds", index],
|
|
});
|
|
}
|
|
}
|
|
|
|
const maxSelected = value.maxSelected ?? null;
|
|
if (value.minSelected > value.options.length) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "minSelected cannot exceed the option count",
|
|
path: ["minSelected"],
|
|
});
|
|
}
|
|
if (value.defaultSelectedOptionIds.length < value.minSelected) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "defaultSelectedOptionIds must satisfy minSelected",
|
|
path: ["defaultSelectedOptionIds"],
|
|
});
|
|
}
|
|
if (maxSelected != null) {
|
|
if (maxSelected < value.minSelected) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "maxSelected must be greater than or equal to minSelected",
|
|
path: ["maxSelected"],
|
|
});
|
|
}
|
|
if (maxSelected > value.options.length) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "maxSelected cannot exceed the option count",
|
|
path: ["maxSelected"],
|
|
});
|
|
}
|
|
if (value.defaultSelectedOptionIds.length > maxSelected) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "defaultSelectedOptionIds cannot exceed maxSelected",
|
|
path: ["defaultSelectedOptionIds"],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export const requestConfirmationResumeFailureSchema = z.object({
|
|
status: z.enum(["retrying", "needs_attention"]),
|
|
errorCode: z.string().trim().min(1).max(120).nullable(),
|
|
attempt: z.number().int().min(0).max(100),
|
|
maxAttempts: z.number().int().min(0).max(100),
|
|
runId: z.string().guid().nullable().optional(),
|
|
retryRunId: z.string().guid().nullable().optional(),
|
|
recoveryActionId: z.string().guid().nullable().optional(),
|
|
updatedAt: z.string().trim().min(1).nullable().optional(),
|
|
});
|
|
|
|
export const requestConfirmationToolActionResultSchema = z.object({
|
|
version: z.literal(1),
|
|
rememberedAction: z.boolean().optional(),
|
|
status: z.enum(["approved", "executing", "executed", "failed", "expired"]),
|
|
errorCode: z.string().trim().min(1).max(120).nullable().optional(),
|
|
errorMessage: z.string().trim().min(1).max(4000).nullable().optional(),
|
|
// Populated on `executed` so the card can report the outcome (e.g. "Row 42
|
|
// added") instead of a bare checkmark, with an optional deep-link when the
|
|
// connector returns a URL (PAP-13745 §5 Executed / Peak-End).
|
|
resultSummary: z.string().trim().min(1).max(4000).nullable().optional(),
|
|
resultHref: z.string().trim().url().max(2000).nullable().optional(),
|
|
updatedAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
export const requestConfirmationSecretProposalResultSchema = z.object({
|
|
version: z.literal(1),
|
|
status: z.enum(["executed", "failed", "rejected", "withdrawn", "expired"]),
|
|
errorCode: z.string().trim().min(1).max(120).nullable().optional(),
|
|
updatedAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
export const requestConfirmationResultSchema = z.object({
|
|
version: z.literal(1),
|
|
outcome: z.enum([
|
|
"accepted",
|
|
"rejected",
|
|
"superseded_by_comment",
|
|
"superseded_by_newer_request",
|
|
"stale_target",
|
|
"skipped",
|
|
"withdrawn",
|
|
"issue_closed",
|
|
"addressee_deleted",
|
|
]),
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
commentId: z.string().guid().nullable().optional(),
|
|
supersededByInteractionId: z.string().guid().nullable().optional(),
|
|
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
|
|
resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(),
|
|
toolAction: requestConfirmationToolActionResultSchema.optional(),
|
|
secretProposal: requestConfirmationSecretProposalResultSchema.optional(),
|
|
});
|
|
|
|
export const requestCheckboxConfirmationResultSchema =
|
|
requestConfirmationResultSchema
|
|
.extend({
|
|
selectedOptionIds: z
|
|
.array(z.string().trim().min(1).max(120))
|
|
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
|
|
.optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const selectedOptionIds = value.selectedOptionIds ?? [];
|
|
const seenOptionIds = new Set<string>();
|
|
for (const [index, optionId] of selectedOptionIds.entries()) {
|
|
if (seenOptionIds.has(optionId)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "selectedOptionIds must be unique",
|
|
path: ["selectedOptionIds", index],
|
|
});
|
|
}
|
|
seenOptionIds.add(optionId);
|
|
}
|
|
});
|
|
|
|
export const requestItemVerdictValueSchema = z.enum([
|
|
"approve",
|
|
"reject",
|
|
"defer",
|
|
]);
|
|
|
|
export const requestItemVerdictsItemSchema = z.object({
|
|
qualityReview: qualityReviewBindingSchema.optional(),
|
|
target: requestConfirmationTargetSchema.nullable().optional(),
|
|
id: z.string().trim().min(1).max(120),
|
|
label: z.string().trim().min(1).max(120),
|
|
description: z.string().trim().max(500).nullable().optional(),
|
|
previewMarkdown: z.string().max(20000).nullable().optional(),
|
|
href: requestConfirmationHrefSchema.nullable().optional(),
|
|
attachmentId: z.string().guid().nullable().optional(),
|
|
});
|
|
|
|
export const requestItemVerdictsPayloadSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
prompt: z.string().trim().min(1).max(1000),
|
|
detailsMarkdown: z.string().max(20000).nullable().optional(),
|
|
items: z
|
|
.array(requestItemVerdictsItemSchema)
|
|
.min(1)
|
|
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
|
verdicts: z
|
|
.array(requestItemVerdictValueSchema)
|
|
.min(2)
|
|
.max(3)
|
|
.optional()
|
|
.default(["approve", "reject"]),
|
|
requireReasonOn: z
|
|
.array(requestItemVerdictValueSchema)
|
|
.max(3)
|
|
.optional()
|
|
.default(["reject"]),
|
|
reasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
|
|
allowBulkApprove: z.boolean().optional().default(true),
|
|
supersedeOnUserComment: z.boolean().optional(),
|
|
target: requestConfirmationTargetSchema.nullable().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const itemIds = new Set<string>();
|
|
const qualityKeys = new Set<string>();
|
|
for (const [index, item] of value.items.entries()) {
|
|
if (item.qualityReview) {
|
|
if (qualityKeys.has(item.qualityReview.key)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Quality review keys must be unique within one request", path: ["items", index, "qualityReview", "key"] });
|
|
qualityKeys.add(item.qualityReview.key);
|
|
}
|
|
if (itemIds.has(item.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Item ids must be unique within one item verdict request",
|
|
path: ["items", index, "id"],
|
|
});
|
|
}
|
|
itemIds.add(item.id);
|
|
}
|
|
|
|
const verdicts = new Set<string>();
|
|
for (const [index, verdict] of value.verdicts.entries()) {
|
|
if (verdicts.has(verdict)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "verdicts must be unique",
|
|
path: ["verdicts", index],
|
|
});
|
|
}
|
|
verdicts.add(verdict);
|
|
}
|
|
if (!verdicts.has("approve") || !verdicts.has("reject")) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "verdicts must include approve and reject; defer is optional",
|
|
path: ["verdicts"],
|
|
});
|
|
}
|
|
|
|
const reasonVerdicts = new Set<string>();
|
|
for (const [index, verdict] of value.requireReasonOn.entries()) {
|
|
if (reasonVerdicts.has(verdict)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "requireReasonOn must be unique",
|
|
path: ["requireReasonOn", index],
|
|
});
|
|
continue;
|
|
}
|
|
reasonVerdicts.add(verdict);
|
|
if (!verdicts.has(verdict)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "requireReasonOn must reference enabled verdicts",
|
|
path: ["requireReasonOn", index],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export const requestItemVerdictsResultItemSchema = z
|
|
.object({
|
|
id: z.string().trim().min(1).max(120),
|
|
verdict: requestItemVerdictValueSchema,
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
resolvedByUserId: z.string().trim().min(1).max(255).nullable().optional(),
|
|
resolvedByAgentId: z.string().guid().nullable().optional(),
|
|
resolvedByRunId: z.string().guid().nullable().optional(),
|
|
resolvedAt: z.union([z.string().datetime(), z.date()]),
|
|
commentId: z.string().guid().nullable().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (!value.resolvedByUserId && !value.resolvedByAgentId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "a user or agent resolver is required",
|
|
path: ["resolvedByUserId"],
|
|
});
|
|
}
|
|
if (value.resolvedByAgentId && !value.resolvedByRunId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "resolvedByRunId is required for an agent resolver",
|
|
path: ["resolvedByRunId"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const requestItemVerdictsResultSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
outcome: z.enum([
|
|
"resolved",
|
|
"superseded_by_comment",
|
|
"stale_target",
|
|
"cancelled",
|
|
"skipped",
|
|
"withdrawn",
|
|
"issue_closed",
|
|
"addressee_deleted",
|
|
]),
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
complete: z.boolean(),
|
|
items: z
|
|
.array(requestItemVerdictsResultItemSchema)
|
|
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
|
commentId: z.string().guid().nullable().optional(),
|
|
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const itemIds = new Set<string>();
|
|
for (const [index, item] of value.items.entries()) {
|
|
if (itemIds.has(item.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "result item ids must be unique",
|
|
path: ["items", index, "id"],
|
|
});
|
|
}
|
|
itemIds.add(item.id);
|
|
}
|
|
});
|
|
|
|
const createIssueThreadInteractionCommon = {
|
|
resolverPolicy: issueThreadInteractionResolverPolicySchema.optional(),
|
|
addresseeAgentId: z.string().guid().nullable().optional(),
|
|
addresseeUserId: z.string().trim().min(1).nullable().optional(),
|
|
};
|
|
|
|
// Validate dual representations on creation, not when reading historical rows.
|
|
// Otherwise a partial canonical form can hide required storage questions.
|
|
const createLegacyAskUserQuestionsPayloadSchema = askUserQuestionsPayloadSchema.superRefine((value, ctx) => {
|
|
if (!value.questionSet) return;
|
|
const shown = new Set(value.questionSet.questions.map((question) => question.id));
|
|
const stored = new Set(value.questions.map((question) => question.id));
|
|
if (shown.size !== stored.size || [...shown].some((id) => !stored.has(id))) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["questionSet", "questions"],
|
|
message: "questionSet must present every questions entry with the same question IDs. Include choice questions as well as text questions; a partial form hides required answers.",
|
|
});
|
|
}
|
|
const storedById = new Map(value.questions.map((question) => [question.id, question]));
|
|
for (const [index, question] of value.questionSet.questions.entries()) {
|
|
const storage = storedById.get(question.id);
|
|
if (!storage) continue;
|
|
const mismatch = (field: string) => ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["questionSet", "questions", index, field],
|
|
message: `questionSet ${field} must match the corresponding questions entry.`,
|
|
});
|
|
if (question.prompt.trim() !== storage.prompt) mismatch("prompt");
|
|
// An omitted storage flag adds no constraint; an explicit flag must agree.
|
|
if (storage.required !== undefined && question.required !== storage.required) mismatch("required");
|
|
const mode = question.answerMode === "multi_select" ? "multi" : "single";
|
|
if (storage.selectionMode !== mode) mismatch("answerMode");
|
|
if (question.answerMode === "text") {
|
|
if (storage.options.length !== 1 || !storage.options[0].freeText) mismatch("answerMode");
|
|
} else {
|
|
// Text/custom-answer sentinels are storage compatibility, not visible choices.
|
|
const choices = storage.options.filter((option) => !option.freeText);
|
|
const canonical = new Map((question.options ?? []).map((option) => [option.id, option.label.trim()]));
|
|
if (choices.length !== canonical.size || choices.some((option) => canonical.get(option.id) !== option.label)) mismatch("options");
|
|
const allowsCustomAnswer = storage.allowOther === true || storage.options.some((option) => option.freeText);
|
|
if (Boolean(question.customAnswer?.enabled) !== allowsCustomAnswer) mismatch("customAnswer");
|
|
}
|
|
}
|
|
});
|
|
|
|
const createAskUserQuestionsPayloadSchema = z.union([
|
|
createLegacyAskUserQuestionsPayloadSchema,
|
|
z.object({
|
|
...askUserQuestionsPayloadFields,
|
|
// Do not discard an explicitly supplied conflicting compatibility form.
|
|
questions: z.never().optional(),
|
|
questionSet: paperclipQuestionSetPayloadSchema,
|
|
}).transform(({ questions: _questions, ...value }): z.input<typeof createLegacyAskUserQuestionsPayloadSchema> => ({
|
|
...questionSetToAskUserQuestionsPayload(value.questionSet),
|
|
...value,
|
|
})).pipe(createLegacyAskUserQuestionsPayloadSchema),
|
|
]);
|
|
|
|
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
|
z.object({
|
|
...createIssueThreadInteractionCommon,
|
|
kind: z.literal("suggest_tasks"),
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().guid().nullable().optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema
|
|
.optional()
|
|
.default("wake_assignee"),
|
|
payload: suggestTasksPayloadSchema,
|
|
}),
|
|
z.object({
|
|
...createIssueThreadInteractionCommon,
|
|
kind: z.literal("ask_user_questions"),
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().guid().nullable().optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema
|
|
.optional()
|
|
.default("wake_assignee"),
|
|
payload: createAskUserQuestionsPayloadSchema,
|
|
}),
|
|
z.object({
|
|
...createIssueThreadInteractionCommon,
|
|
kind: z.literal("request_confirmation"),
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().guid().nullable().optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema
|
|
.optional()
|
|
.default("none"),
|
|
payload: requestConfirmationPayloadSchema,
|
|
}),
|
|
z.object({
|
|
...createIssueThreadInteractionCommon,
|
|
kind: z.literal("request_checkbox_confirmation"),
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().guid().nullable().optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema
|
|
.optional()
|
|
.default("wake_assignee"),
|
|
payload: requestCheckboxConfirmationPayloadSchema,
|
|
}),
|
|
z.object({
|
|
...createIssueThreadInteractionCommon,
|
|
kind: z.literal("request_item_verdicts"),
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().guid().nullable().optional(),
|
|
sourceRunId: z.string().guid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema
|
|
.optional()
|
|
.default("wake_assignee"),
|
|
payload: requestItemVerdictsPayloadSchema,
|
|
}),
|
|
]);
|
|
|
|
export type CreateIssueThreadInteraction = z.infer<
|
|
typeof createIssueThreadInteractionSchema
|
|
>;
|
|
export type CreateIssueThreadInteractionInput = z.input<
|
|
typeof createIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const acceptIssueThreadInteractionSchema = z
|
|
.object({
|
|
rememberAction: z.boolean().optional(),
|
|
selectedClientKeys: z
|
|
.array(z.string().trim().min(1).max(120))
|
|
.min(1)
|
|
.max(50)
|
|
.optional(),
|
|
selectedOptionIds: z
|
|
.array(z.string().trim().min(1).max(120))
|
|
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
|
|
.optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const seenClientKeys = new Set<string>();
|
|
for (const [index, clientKey] of (
|
|
value.selectedClientKeys ?? []
|
|
).entries()) {
|
|
if (seenClientKeys.has(clientKey)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "selectedClientKeys must be unique",
|
|
path: ["selectedClientKeys", index],
|
|
});
|
|
continue;
|
|
}
|
|
seenClientKeys.add(clientKey);
|
|
}
|
|
|
|
const seenOptionIds = new Set<string>();
|
|
for (const [index, optionId] of (value.selectedOptionIds ?? []).entries()) {
|
|
if (seenOptionIds.has(optionId)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "selectedOptionIds must be unique",
|
|
path: ["selectedOptionIds", index],
|
|
});
|
|
continue;
|
|
}
|
|
seenOptionIds.add(optionId);
|
|
}
|
|
});
|
|
export type AcceptIssueThreadInteraction = z.infer<
|
|
typeof acceptIssueThreadInteractionSchema
|
|
>;
|
|
|
|
/** Records an agent's interpretation of a real user reply without widening resolver permissions. */
|
|
export const resolveConfirmationFromCommentSchema = z.object({
|
|
commentId: z.string().guid(),
|
|
decision: z.enum(["accept", "reject"]),
|
|
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
|
|
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT).optional(),
|
|
reason: z.string().trim().max(4000).optional(),
|
|
}).strict().superRefine((value, ctx) => {
|
|
if (value.decision === "reject" && value.selectedOptionIds !== undefined) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selectedOptionIds"], message: "Selections apply only to acceptance" });
|
|
}
|
|
if (value.selectedOptionIds && new Set(value.selectedOptionIds).size !== value.selectedOptionIds.length) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selectedOptionIds"], message: "Selections must be unique" });
|
|
}
|
|
});
|
|
export type ResolveConfirmationFromComment = z.infer<typeof resolveConfirmationFromCommentSchema>;
|
|
|
|
export const rejectIssueThreadInteractionSchema = z.object({
|
|
reason: z.string().trim().max(4000).optional(),
|
|
});
|
|
export type RejectIssueThreadInteraction = z.infer<
|
|
typeof rejectIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const cancelIssueThreadInteractionSchema = z.object({
|
|
reason: z.string().trim().max(4000).optional(),
|
|
});
|
|
export type CancelIssueThreadInteraction = z.infer<
|
|
typeof cancelIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const skipIssueThreadInteractionSchema = z.object({
|
|
reason: z.string().trim().max(4000).optional(),
|
|
});
|
|
export type SkipIssueThreadInteraction = z.infer<
|
|
typeof skipIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const withdrawIssueThreadInteractionSchema = z.object({
|
|
reason: z.string().trim().max(4000).optional(),
|
|
});
|
|
export type WithdrawIssueThreadInteraction = z.infer<
|
|
typeof withdrawIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const respondIssueThreadInteractionSchema = z.object({
|
|
answers: z.array(askUserQuestionsAnswerSchema).max(20),
|
|
summaryMarkdown: multilineTextSchema
|
|
.pipe(z.string().max(20000))
|
|
.nullable()
|
|
.optional(),
|
|
});
|
|
export type RespondIssueThreadInteraction = z.infer<
|
|
typeof respondIssueThreadInteractionSchema
|
|
>;
|
|
|
|
export const submitIssueThreadInteractionVerdictsSchema = z
|
|
.object({
|
|
verdicts: z
|
|
.array(
|
|
z.object({
|
|
id: z.string().trim().min(1).max(120),
|
|
verdict: requestItemVerdictValueSchema,
|
|
reason: z.string().trim().max(4000).nullable().optional(),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const itemIds = new Set<string>();
|
|
for (const [index, verdict] of value.verdicts.entries()) {
|
|
if (itemIds.has(verdict.id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "verdict item ids must be unique",
|
|
path: ["verdicts", index, "id"],
|
|
});
|
|
}
|
|
itemIds.add(verdict.id);
|
|
}
|
|
});
|
|
export type SubmitIssueThreadInteractionVerdicts = z.infer<
|
|
typeof submitIssueThreadInteractionVerdictsSchema
|
|
>;
|
|
|
|
export const linkIssueApprovalSchema = z.object({
|
|
approvalId: z.string().guid(),
|
|
});
|
|
|
|
export type LinkIssueApproval = z.infer<typeof linkIssueApprovalSchema>;
|
|
|
|
export const createIssueAttachmentMetadataSchema = z.object({
|
|
issueCommentId: z.string().guid().optional().nullable(),
|
|
});
|
|
|
|
export type CreateIssueAttachmentMetadata = z.infer<
|
|
typeof createIssueAttachmentMetadataSchema
|
|
>;
|
|
|
|
export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const;
|
|
|
|
export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS);
|
|
|
|
export const upsertIssueDocumentSchema = z.object({
|
|
title: z.string().trim().max(200).nullable().optional(),
|
|
format: issueDocumentFormatSchema,
|
|
body: multilineTextSchema.pipe(z.string().max(524288)),
|
|
changeSummary: z.string().trim().max(500).nullable().optional(),
|
|
baseRevisionId: z.string().guid().nullable().optional(),
|
|
});
|
|
|
|
export const restoreIssueDocumentRevisionSchema = z.object({});
|
|
|
|
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
|
|
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
|
|
export type RestoreIssueDocumentRevision = z.infer<
|
|
typeof restoreIssueDocumentRevisionSchema
|
|
>;
|