mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Carry bounded text-only initialText through ACP normalization, Rust durable validation, shared contracts and the question UI. Saved edits and explicit clears survive reload. Preserve canonical submitted text while retaining legacy custom-answer normalization and existing validation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2149 lines
72 KiB
TypeScript
2149 lines
72 KiB
TypeScript
import { z } from "zod";
|
|
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";
|
|
|
|
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()).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 issueExecutionPolicySchema = z.object({
|
|
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 retryWorkspaceExportSchema = z.object({
|
|
actionId: z.string().guid(),
|
|
runId: z.string().guid(),
|
|
repairNote: z.string().trim().min(20).max(12000),
|
|
}).strict();
|
|
|
|
export const resolveIssueRecoveryActionSchema = z
|
|
.object({
|
|
executionReconciliation: z
|
|
.object({
|
|
runId: z.string().guid(),
|
|
providerStopped: z.literal(true),
|
|
actionOutcome: z.enum(["completed", "not_performed", "mixed"]),
|
|
outcomeEvidence: z.string().trim().min(20).max(12000),
|
|
workspaceRepairEvidence: z.string().trim().min(20).max(12000).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
actionId: z.string().guid().optional(),
|
|
outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES),
|
|
sourceIssueStatus: z.enum(["todo", "done", "in_review", "blocked"]),
|
|
resolutionNote: multilineTextSchema.optional().nullable(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (value.outcome === "restored") {
|
|
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(),
|
|
parentId: z.string().guid().optional().nullable(),
|
|
blockedByIssueIds: z.array(z.string().guid()).optional(),
|
|
unblockDescriptor: z
|
|
.object({
|
|
owner: z.union([
|
|
z.object({ agentId: z.string().guid() }).strict(),
|
|
z.object({ userId: z.string().trim().min(1) }).strict(),
|
|
z.literal("board"),
|
|
]),
|
|
action: multilineTextSchema.pipe(z.string().trim().min(1).max(2_000)),
|
|
})
|
|
.strict()
|
|
.optional()
|
|
.nullable(),
|
|
inheritExecutionWorkspaceFromIssueId: z.string().guid().optional().nullable(),
|
|
title: z.string().min(1),
|
|
description: multilineTextSchema.optional().nullable(),
|
|
status: z.enum(ISSUE_STATUSES),
|
|
workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"),
|
|
harnessKind: z.enum(ISSUE_HARNESS_KINDS).optional().nullable(),
|
|
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
|
reviewPolicy: z.enum(ISSUE_REVIEW_POLICIES).optional().nullable(),
|
|
assigneeAgentId: z.string().guid().optional().nullable(),
|
|
assigneeUserId: z.string().optional().nullable(),
|
|
requestDepth: issueRequestDepthInputSchema.optional().default(0),
|
|
createdByUserId: z.string().optional().nullable(),
|
|
responsibleUserId: z.string().optional().nullable(),
|
|
billingCode: z.string().optional().nullable(),
|
|
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema
|
|
.optional()
|
|
.nullable(),
|
|
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
|
|
executionWorkspaceId: z.string().guid().optional().nullable(),
|
|
executionWorkspacePreference: z
|
|
.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES)
|
|
.optional()
|
|
.nullable(),
|
|
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({
|
|
status: createIssueBaseSchema.shape.status.optional(),
|
|
...createIssueDuplicateGuardSchema,
|
|
...onboardingFirstTaskMarkerSchema,
|
|
});
|
|
|
|
export const createIssueSchema = withCreateIssueStatusDefault(
|
|
createIssueBaseSchema.extend({
|
|
...createIssueDuplicateGuardSchema,
|
|
...onboardingFirstTaskMarkerSchema,
|
|
}),
|
|
).superRefine(requireBlockedStatusForUnblockDescriptor);
|
|
|
|
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({
|
|
acceptanceCriteria: z
|
|
.array(z.string().trim().min(1).max(500))
|
|
.max(20)
|
|
.optional(),
|
|
blockParentUntilDone: z.boolean().optional().default(false),
|
|
}),
|
|
).superRefine(requireBlockedStatusForUnblockDescriptor);
|
|
|
|
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({
|
|
createdByUserId: true,
|
|
responsibleUserId: true,
|
|
watchdog: true,
|
|
}),
|
|
)
|
|
.partial()
|
|
.extend({
|
|
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),
|
|
sourceChannel: z.literal("imessage-photon").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 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({
|
|
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.literal("ai").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(),
|
|
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: z.string().min(1).max(160),
|
|
label: z.string().min(1).max(1000),
|
|
description: z.string().max(4000).optional(),
|
|
recommended: z.boolean().optional(),
|
|
});
|
|
|
|
const paperclipQuestionSchema = z
|
|
.object({
|
|
id: z.string().min(1).max(160),
|
|
header: z.string().max(1000).optional(),
|
|
prompt: z.string().min(1).max(4000),
|
|
helpText: z.string().max(4000).optional(),
|
|
required: z.boolean(),
|
|
answerMode: z.enum(["single_select", "multi_select", "text"]),
|
|
initialText: z.string().max(100000).optional(),
|
|
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
|
|
customAnswer: z
|
|
.object({
|
|
enabled: z.literal(true),
|
|
label: z.string().max(1000).optional(),
|
|
placeholder: z.string().max(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: z.string().max(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: z.string().max(1000).optional(),
|
|
description: z.string().max(100_000).optional(),
|
|
submitLabel: z.string().max(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"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const askUserQuestionsPayloadSchema = z
|
|
.object({
|
|
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(),
|
|
})
|
|
.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 requestConfirmationTargetSchema = z.discriminatedUnion("type", [
|
|
requestConfirmationIssueDocumentTargetSchema,
|
|
requestConfirmationCustomTargetSchema,
|
|
]);
|
|
|
|
export const requestConfirmationToolActionPayloadSchema = z.object({
|
|
version: z.literal(1),
|
|
actionRequestId: z.string().guid(),
|
|
invocationId: z.string().guid(),
|
|
toolName: z.string().trim().min(1).max(500),
|
|
toolDisplayName: z.string().trim().min(1).max(500),
|
|
connectionId: z.string().guid().nullable(),
|
|
applicationId: z.string().guid().nullable(),
|
|
appDisplayName: z.string().trim().min(1).max(500).nullable(),
|
|
risk: z.enum(["read", "write", "destructive"]),
|
|
rememberActionScope: z.string().trim().min(1).max(1000).optional(),
|
|
previewMarkdown: z.string().trim().min(1).max(20000),
|
|
argumentsSummaryJson: z.string().max(20000),
|
|
argumentsHash: z.string().trim().min(1).max(255),
|
|
expiresAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
export const requestConfirmationSecretProposalPayloadSchema = z.object({
|
|
version: z.literal(1),
|
|
proposalId: z.string().guid(),
|
|
sourceSecretLabel: z.string().trim().min(1).max(500),
|
|
configPath: z.string().trim().min(1).max(500),
|
|
targetAgentId: z.string().guid(),
|
|
targetAgentName: z.string().trim().min(1).max(500),
|
|
justification: z.string().trim().min(1).max(20000),
|
|
expiresAt: z.string().datetime({ offset: true }),
|
|
});
|
|
|
|
export const requestConfirmationPayloadSchema = z.object({
|
|
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({
|
|
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>();
|
|
for (const [index, item] of value.items.entries()) {
|
|
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 createAskUserQuestionsPayloadSchema = 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 !== 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]));
|
|
if (choices.length !== canonical.size || choices.some((option) => canonical.get(option.id) !== option.label)) mismatch("options");
|
|
}
|
|
}
|
|
});
|
|
|
|
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 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
|
|
>;
|
|
|
|
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
|
|
>;
|