mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from the task list to see the latest messages and to reply to an agent > - Before the task chat shows its first frame, it waits for about ten separate reads: the issue, recent comments, comment input facts, recent activity, recent runs, interactions, attachments, live runs and the active run > - On a busy instance each read waits for the database, and the slowest read decides when the chat appears. A cold open takes 1.5–4 s on staging > - This pull request adds one read that returns all of that data for up to ten tasks, in the same JSON shapes as the existing endpoints, plus a single-task form > - The benefit is that the task list can preload its top tasks, and a cold open needs one round trip instead of about ten. The UI change is in the stacked pull request ## Linked Issues or Issue Description **Problem or motivation** A task open waits for about ten reads. With the database under load, these reads queue, and a cold open takes seconds. The list cannot preload a task cheaply, because each task costs about ten requests. **Proposed solution** `GET /api/companies/:companyId/issues/chat-bootstrap?ids=` returns the first-frame data for up to 10 tasks. `GET /api/issues/:ref/chat-bootstrap` returns it for one task. Each field equals the matching single endpoint's JSON exactly. **Alternatives considered** - Prefetch the existing endpoints for each list row. For 30 rows this sends about 210 requests to a database that is already the bottleneck. - A new projection with different shapes. This would duplicate client merge logic, and freshness rules could drift. **Roadmap alignment** This is part of the task-loading performance plan in #20276. It follows #20277, #20284 and #20286. ## What Changed - `packages/shared`: - `ISSUE_CHAT_BOOTSTRAP_MAX_IDS` (10). - `issueChatBootstrapQuerySchema`: UUIDs or identifiers, normalized, deduplicated and strict. - The `IssueChatBootstrap` and `IssueChatBootstrapItem` types. - New `server/src/services/issue-chat-bootstrap.ts`: - Resolves the refs inside the company and applies the `issue:read` decision to each task. - Reads the tasks with a concurrency of 2, and returns them in request order. - `server/src/routes/issues.ts`: - The core issue read and the recent-comments read are now shared helpers, so the bootstrap and the single endpoints use the same code. The core view keeps its Server-Timing header. - Adds the batch route and the single-task route. - Excludes the single-task route from the issue ETag middleware, so a fetch without validators always gets a 200 with `Cache-Control: private, no-store`. - New `server/src/services/issue-run-presence.ts`: - The live-runs and active-run payload builders, moved from `agents.ts` without change. - The existing routes call them, and their behavior is unchanged. - `server/src/routes/openapi.ts`: entries for both routes. ## Verification - `cd server && npx vitest run src/__tests__/issue-chat-bootstrap-routes.test.ts` passes 17 tests. They run on embedded Postgres with privacy enforcement on. They cover: - Each field deep-equals its single endpoint's JSON, for an ordinary task and for a conversation task. - The single-task route equals the batch item, by UUID and by identifier. It is never answered with a 304. - Items keep the request order, with UUIDs and identifiers mixed. - Private, other-company, missing and unknown tasks are omitted, and none of their data appears. The single-task route returns 404 for them. - Malformed, extra or repeated parameters return 400. More than 10 ids also return 400. - A task-bridge key gets 403. - A 10-task request writes nothing: no inserts, updates or deletes, and no activity-log or comment changes. - The issue, agent, activity, live-run, cold-open, summaries, ETag and OpenAPI route suites pass: 56 files, 1,771 tests. - `cd server && pnpm typecheck` passes. - Query cost, local embedded Postgres, one 10-task request: about 49 SQL statements per task, 55–130 ms in total. A test fails if it goes above 60 per task. ## Risks - **Database load:** the stacked UI preloads up to 30 tasks, about 1,500 statements at idle time. This is the same work as opening those tasks, but it is done before the person clicks. The work runs in sequential requests of 10, with 2 tasks at a time. Most of the cost is existing run decoration. Live runs and the active run decorate the same running run, so that work runs twice. Sharing it is a follow-up. - **Stricter access:** live runs and the active run now apply the `issue:read` policy inside this endpoint. The existing routes keep their company-only check. - Hidden tasks are returned, as `GET /issues/:id` returns them. - No migration and no persisted-state change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, browser automation, file editing, subagents). ## 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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2401 lines
85 KiB
TypeScript
2401 lines
85 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_CHAT_BOOTSTRAP_MAX_IDS, ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
|
|
import { normalizeIssueIdentifier } from "../issue-references.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();
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/** A task reference as the UI routes use it: a UUID or an identifier such as `ABC-12`. */
|
|
function normalizeIssueRef(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (UUID_RE.test(trimmed)) return trimmed.toLowerCase();
|
|
return normalizeIssueIdentifier(trimmed);
|
|
}
|
|
|
|
export const issueChatBootstrapQuerySchema = z.object({
|
|
ids: z.string()
|
|
.transform((value, ctx) => {
|
|
const refs = value.split(",").filter((ref) => ref.trim()).map(normalizeIssueRef);
|
|
if (refs.some((ref) => ref === null)) {
|
|
ctx.addIssue({ code: "custom", message: "Each id must be a task UUID or identifier" });
|
|
return z.NEVER;
|
|
}
|
|
return [...new Set(refs as string[])];
|
|
})
|
|
.pipe(z.array(z.string()).min(1).max(ISSUE_CHAT_BOOTSTRAP_MAX_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();
|
|
|
|
/** Board attestation that a native run's previous server stopped, for a
|
|
* controller row an ephemeral server cannot judge by PID. */
|
|
export const confirmNativeControllerStoppedSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
stopEvidence: z.string().trim().min(20).max(12000),
|
|
}).strict();
|
|
|
|
export const confirmRunStoppedSchema = z.object({
|
|
runId: z.string().guid(),
|
|
stopEvidence: z.string().trim().min(20).max(12000),
|
|
}).strict();
|
|
|
|
/** Board attestation that the owner of a record Paperclip cannot judge (saved
|
|
* before owners were recorded) has stopped, so recovery may settle the record. */
|
|
export const confirmOwnerGoneSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
recordId: z.string().guid(),
|
|
stopEvidence: z.string().trim().min(20).max(12000),
|
|
/** The record version the board saw (`evidence.recordUpdatedAt`); required when the request names one. */
|
|
recordVersion: z.string().datetime({ offset: true }).optional(),
|
|
}).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),
|
|
controllerStopped: z.literal(true).optional(),
|
|
controllerAuthorityDigest: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
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" });
|
|
}
|
|
const reconciliation = value.executionReconciliation;
|
|
const controllerContainment = reconciliation?.controllerStopped !== undefined
|
|
|| reconciliation?.controllerAuthorityDigest !== undefined;
|
|
if (controllerContainment && (!value.actionId || value.outcome !== "restored"
|
|
|| reconciliation?.controllerStopped !== true || !reconciliation?.controllerAuthorityDigest)) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["executionReconciliation"],
|
|
message: "Controller containment requires the exact recovery action, both stop confirmations, and its authority digest" });
|
|
return;
|
|
}
|
|
if (value.outcome === "restored") {
|
|
// Source repair and complete controller containment record evidence
|
|
// without changing task state. The route checks the exact locked source,
|
|
// unchanged status, board authority, lease expiry, and cleanup evidence.
|
|
if (value.executionReconciliation?.workspaceRepairEvidence || controllerContainment) 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(),
|
|
/**
|
|
* Inherit the project and workspace strategy of this issue. The new task never
|
|
* shares the source issue's realized workspace; it gets its own from the project base.
|
|
*/
|
|
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(),
|
|
attachmentPreparation: z.object({ attachmentId: z.uuid() }).strict().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 }).nullable(),
|
|
});
|
|
|
|
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
|
|
>;
|