mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(mobile): stop review notes disappearing or coming back after deletion The phone sent its whole notes list on every add or delete, and the host stored it as is. A note added on desktop after the phone loaded was wiped by the phone's next edit, two quick taps could overwrite each other, and a failed save rolled back to an old snapshot. worktree.set now takes an optional diffCommentsBase, like linkedItemsBase: the host applies only the writer's own adds, edits and removals to the stored notes. The phone sends the list it started from, shows the host's merged list from the reply, and undoes only its own change on failure. Older hosts drop the field and replace the list as before; older phones send no base and get the same. Co-authored-by: Neil <neil@stably.ai> * fix(mobile): keep loaded review notes when an early save fails The first load was discarded once any save started, so a save that failed before it landed left the host's notes hidden. The load now applies the phone's changes made during the read onto the host's list instead. * fix(mobile): send review-note requests one at a time Two in-flight saves could reach the host in either order, so adopting the later reply hid a note the earlier save had stored. Session notes now send one request at a time and reconcile every reply with the phone's changes made since it was sent, replacing the sequence counters. * fix(mobile): drop review-note saves queued behind a failed one A save queued behind one that failed still sent the list built on the failed change, and an older host stores that list whole. Those saves now undo their own change instead of sending. Also renames the shared base type so it no longer shares a name with the renderer's mutation record. * fix(mobile): show review notes as the host's list plus pending saves A queued reply was reconciled against the list captured at tap time, so a note desktop deleted meanwhile came back on the phone. The phone now keeps the host's last list and its unanswered saves per workspace and shows the two combined; a reply for another workspace is ignored. The review screen also shows the host's list after a save, so an edit to a note desktop deleted no longer looks saved. * fix(mobile): fail queued review-note requests while disconnected A notes request queued behind a pending load waited for a reconnect with no timeout, holding every later save. Notes requests now fail at once when the connection is down, like terminal input. * fix(mobile): drop every review-note save built on a failed or dropped one A save made while a dropped save was still pending carried its change, so an older host could store it. Each failed or dropped save now moves the failure count. * fix(mobile): send each review-note save as the host's list plus its change Each queued save now builds its payload when it is sent, from the host's last list plus only its own change. A note added before the first load no longer replaces the loaded notes on an older host, and a failed save's change never rides along, so later saves no longer need dropping. Delivered-note cleanup waits for a reconnect again so those notes are not sent twice. --------- Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
import { z } from 'zod'
|
|
import { normalizeExecutionHostId } from '../execution-host'
|
|
import { isTuiAgent } from '../tui-agent-config'
|
|
import type { TuiAgent } from '../tui-agent'
|
|
import {
|
|
OptionalBoolean,
|
|
OptionalFiniteNumber,
|
|
OptionalPlainString,
|
|
OptionalString,
|
|
TriStateLinkedIssue
|
|
} from './rpc-param-primitives'
|
|
import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
|
|
import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema'
|
|
import { WorkspaceAttachmentsSchema } from '../workspace-attachment-schema'
|
|
import { DiffCommentSchema, MobileDiffReviewSchema } from '../diff-comment-schema'
|
|
import { TaskSourceContextSchema } from '../task-source-context-schema'
|
|
import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context'
|
|
|
|
export const OptionalExecutionHostId = z
|
|
.string()
|
|
.transform((value, ctx) => {
|
|
const hostId = normalizeExecutionHostId(value)
|
|
if (!hostId) {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
|
|
return z.NEVER
|
|
}
|
|
return hostId
|
|
})
|
|
.optional()
|
|
|
|
export const OptionalTuiAgent = z
|
|
.unknown()
|
|
.superRefine((value, ctx) => {
|
|
if (value !== undefined && !isTuiAgent(value)) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
|
|
}
|
|
})
|
|
.transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
|
|
.optional()
|
|
|
|
export const AutomationWorkspaceProvenanceRequest = z.object({
|
|
automationId: z.string(),
|
|
automationRunId: z.string(),
|
|
dispatchToken: z.string(),
|
|
createRequestId: z.string()
|
|
})
|
|
|
|
// Descriptive provenance grants no authority; the host stamps createdAt to avoid client-clock sort skew.
|
|
export const CliWorkspaceProvenanceRequest = z.object({
|
|
callerTerminalHandle: OptionalString
|
|
})
|
|
|
|
export const WorktreeListParams = z.object({
|
|
repo: OptionalString,
|
|
limit: OptionalFiniteNumber
|
|
})
|
|
|
|
export const WorktreeDetectedListParams = z.object({
|
|
repo: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing repo selector'))
|
|
})
|
|
|
|
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
|
|
worktreeIds: z.array(z.string().min(1)).max(10_000),
|
|
connectionId: z.string().nullable().optional()
|
|
})
|
|
|
|
export const WorktreePsParams = z.object({
|
|
limit: OptionalFiniteNumber,
|
|
afterSnapshotId: z.string().min(1).max(128).nullable().optional(),
|
|
supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional(),
|
|
// Opt-in: wait lookups read screens and probe PTYs, too costly for pollers; older hosts omit agentWait.
|
|
includeAgentWait: z.literal(true).optional()
|
|
})
|
|
|
|
export const WorktreeSortOrder = z.object({
|
|
orderedIds: z.array(z.string()),
|
|
// Parallel to orderedIds: the copy each id names, so a same-id twin keeps its own order.
|
|
hostIds: z.array(z.string()).optional()
|
|
})
|
|
|
|
export const WorktreeSelector = z.object({
|
|
worktree: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing worktree selector'))
|
|
})
|
|
|
|
// Why optional: an older host strips the unknown field and marks its chats asleep as before.
|
|
export const WorktreeSleepParams = WorktreeSelector.extend({
|
|
markChatsAsleep: z.boolean().optional()
|
|
})
|
|
|
|
export const WorktreeActivate = WorktreeSelector.extend({
|
|
notifyClients: OptionalBoolean,
|
|
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
|
|
})
|
|
|
|
/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */
|
|
export function assertLinkedWorkItemSourceContextMatch(
|
|
params: {
|
|
linkedWorkItem?: z.infer<typeof WorkspaceLinkedItemSchema> | null
|
|
linkedTaskSourceContext?: z.infer<typeof TaskSourceContextSchema> | null
|
|
},
|
|
ctx: z.RefinementCtx
|
|
): void {
|
|
if (
|
|
params.linkedWorkItem &&
|
|
params.linkedTaskSourceContext &&
|
|
!isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Linked work item and source context identities must match'
|
|
})
|
|
}
|
|
}
|
|
|
|
export const WorktreeSet = WorktreeSelector.extend({
|
|
// Empty clears to branch/folder fallback; OptionalString would drop remote clears and restore the old name.
|
|
displayName: OptionalPlainString,
|
|
// Preserve empty comment updates instead of coercing them to absent.
|
|
comment: OptionalPlainString,
|
|
linkedIssue: TriStateLinkedIssue,
|
|
linkedPR: TriStateLinkedIssue,
|
|
suppressedGitHubPR: z.number().int().positive().nullable().optional(),
|
|
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
|
|
linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
|
|
linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
|
|
linkedGitLabMR: TriStateLinkedIssue,
|
|
linkedGitLabIssue: TriStateLinkedIssue,
|
|
linkedBitbucketPR: TriStateLinkedIssue,
|
|
linkedAzureDevOpsPR: TriStateLinkedIssue,
|
|
linkedGiteaPR: TriStateLinkedIssue,
|
|
linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
|
|
linkedItems: WorkspaceAttachmentsSchema.optional(),
|
|
linkedItemsBase: WorkspaceAttachmentsSchema.optional(),
|
|
linkedItemsSelectionChanged: z.boolean().optional(),
|
|
linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
|
|
isArchived: OptionalBoolean,
|
|
isUnread: OptionalBoolean,
|
|
isPinned: OptionalBoolean,
|
|
sortOrder: OptionalFiniteNumber,
|
|
manualOrder: OptionalFiniteNumber,
|
|
lastActivityAt: OptionalFiniteNumber,
|
|
createdAt: OptionalFiniteNumber,
|
|
sparseDirectories: z.array(z.string()).optional(),
|
|
sparseBaseRef: OptionalString,
|
|
sparsePresetId: OptionalString,
|
|
baseRef: OptionalString,
|
|
workspaceStatus: OptionalString,
|
|
pushTarget: z
|
|
.object({
|
|
remoteName: z.string(),
|
|
branchName: z.string(),
|
|
remoteUrl: OptionalString
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
// Older clients may send unsupported review shapes alongside unrelated metadata edits.
|
|
diffComments: z.array(DiffCommentSchema).optional().catch(undefined),
|
|
// The list the writer started from; the host applies only its changes so other devices' notes survive.
|
|
diffCommentsBase: z.array(DiffCommentSchema).optional().catch(undefined),
|
|
mobileDiffReview: MobileDiffReviewSchema.optional().catch(undefined),
|
|
parentWorktree: OptionalString,
|
|
noParent: OptionalBoolean
|
|
}).superRefine((params, ctx) => {
|
|
assertLinkedWorkItemSourceContextMatch(params, ctx)
|
|
if (params.parentWorktree && params.noParent === true) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
|
})
|
|
}
|
|
})
|
|
|
|
export const WorktreeRemove = WorktreeSelector.extend({
|
|
hostId: OptionalExecutionHostId,
|
|
force: OptionalBoolean,
|
|
// Desktop force also means ordinary confirmed delete; PTY-stop waivers need their own field (#11960).
|
|
allowUnverifiedPtyStop: OptionalBoolean,
|
|
runHooks: OptionalBoolean,
|
|
// Waive failed-hook refusal explicitly and record it; independent of force and whether hooks run (#19334).
|
|
allowFailedArchiveHook: OptionalBoolean
|
|
})
|
|
|
|
export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
|
|
hostId: OptionalExecutionHostId,
|
|
branchName: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing branch name')),
|
|
expectedHead: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing expected branch head'))
|
|
})
|
|
|
|
export const WorktreeResolvePrBase = z.object({
|
|
repo: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing repo selector')),
|
|
prNumber: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
|
.pipe(z.number().int().positive('Missing PR number')),
|
|
headRefName: OptionalString,
|
|
baseRefName: OptionalString,
|
|
isCrossRepository: OptionalBoolean
|
|
})
|
|
|
|
export const WorktreeResolveMrBase = z.object({
|
|
repo: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'string' ? v : ''))
|
|
.pipe(z.string().min(1, 'Missing repo selector')),
|
|
mrIid: z
|
|
.unknown()
|
|
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
|
.pipe(z.number().int().positive('Missing MR number')),
|
|
sourceBranch: OptionalString,
|
|
targetBranch: OptionalString,
|
|
isCrossRepository: OptionalBoolean
|
|
})
|