Files
Jyotisha/frontend/src/lib/rectification-entry.ts
T
66f087b588 feat(rectification): submit varga-resolution implementation for review
Review-only snapshot for BUG-1115 through BUG-1117; not merge-ready. New opening and append-turn PostgreSQL permission failures remain blocked. Persisted joint replay has zero completed questions; segment ordering remains off by default. The existing offline replay JSON is retained stale and unchanged after a denied overwrite, including its CRLF line endings. Browser/provider validation and final serial gates remain pending. No deployment, role permission changes, or staging/main push.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 08:04:04 +08:00

157 lines
5.6 KiB
TypeScript

/**
* Client-side rectification entry routing contracts.
*
* Every disposition decision comes from the server Case open API. The browser
* never guesses "resume vs create" from session lists or message counts.
*/
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
}> | null;
latestTerminal: Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
}> | null;
}>;
export type RectificationCardAction = "start" | "restart";
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
start: "开始新的生时校正",
restart: "再次校正",
};
export function resolveRectificationEntryAction(
summary: RectificationEntrySummary,
): RectificationCardAction {
if (summary.hasTerminalCaseWithTime) return "restart";
return "start";
}
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
export type OpenRectificationCaseResponse = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
}>;
const RESUMABLE_STATUSES = new Set([
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
]);
const TERMINAL_STATUSES = new Set([
"confirmed",
"closed",
"abandoned",
"superseded",
]);
export function isResumableRectificationStatus(status: string): boolean {
return RESUMABLE_STATUSES.has(status);
}
export function isTerminalRectificationStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}
/* `GET /api/rectification/cases/entry-summary` answers with the camelCase
object `getRectificationEntrySummary` returns; only the RPC underneath speaks
snake_case. This parser used to read the snake_case keys alone, so every real
response parsed as "nothing on record" and the homepage never saw a resumable
or finished case (BUG-1041). The route's camelCase shape wins; snake_case is
still read so an RPC-shaped payload does not silently collapse either. */
function field(row: Record<string, unknown>, camel: string, snake: string): unknown {
return camel in row ? row[camel] : row[snake];
}
function objectField(row: Record<string, unknown>, camel: string, snake: string): Record<string, unknown> | null {
const value = field(row, camel, snake);
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
if (!value || typeof value !== "object") {
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
}
const row = value as Record<string, unknown>;
const latestResumable = objectField(row, "latestResumable", "latest_resumable");
const latestTerminal = objectField(row, "latestTerminal", "latest_terminal");
const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null;
const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null;
return {
hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true,
hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true,
latestResumable: latestResumable && typeof resumableCaseId === "string"
? {
caseId: resumableCaseId,
status: String(latestResumable.status ?? ""),
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
}
: null,
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
? {
caseId: terminalCaseId,
status: String(latestTerminal.status ?? ""),
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
}
: null,
};
}
export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const disposition = row.disposition;
const caseId = typeof row.caseId === "string" ? row.caseId : "";
const sessionId = typeof row.sessionId === "string" ? row.sessionId : "";
const status = typeof row.status === "string" ? row.status : "";
if (
(disposition !== "created" && disposition !== "resumed" && disposition !== "readonly")
|| !caseId || !sessionId || !status
) {
return null;
}
return {
disposition,
caseId,
sessionId,
status,
shouldStartOpening: row.shouldStartOpening === true,
skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "",
};
}
export type RectificationEntryOpenIntent = "homepage" | "session" | "new";
/** Build the server open request body; the browser never adds business state. */
export function openRectificationRequestBody(
intent: RectificationEntryOpenIntent,
sessionId: string | null,
sourceSessionId: string | null = null,
): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string } {
const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid();
if (intent === "session" && sessionId) {
return { intent, requestId, sessionId };
}
return { intent, requestId, ...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}) };
}
function fallbackUuid(): string {
return "00000000-0000-4000-8000-000000000000";
}