Session open was sending the live registry version into v2, so every Skill bump rejected older cases with an unmapped 500 and no error on the clicked row. Co-authored-by: Cursor <cursoragent@cursor.com>
761 lines
32 KiB
TypeScript
761 lines
32 KiB
TypeScript
/**
|
|
* V9 rectification Case Service.
|
|
*
|
|
* Server-owned facts: profile normalization, baseline snapshot/fingerprint,
|
|
* candidate range derivation and every disposition decision. The browser only
|
|
* ever supplies an intent + requestId (+ exact sessionId); it never passes
|
|
* userId, birth data, candidate range or permission decisions.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts";
|
|
import { declaredClockRange } from "../../declared-birth-window.ts";
|
|
import {
|
|
parseRectificationCaseStage,
|
|
type RectificationCaseStage,
|
|
} from "./block-scan.ts";
|
|
import { resolveAyanamsa, type AyanamsaName } from "../../ayanamsa.ts";
|
|
import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts";
|
|
import {
|
|
SkillPackageRegistryError,
|
|
resolveActiveSkillPackage,
|
|
resolveExactSkillPackage,
|
|
resolveSkillPackageVersion,
|
|
} from "../../skill-package-registry.ts";
|
|
import {
|
|
isRectificationCaseStatus,
|
|
RECTIFICATION_SKILL_NAME,
|
|
RECTIFICATION_SKILL_VERSION,
|
|
type RectificationCaseStatus,
|
|
} from "./case-status.ts";
|
|
export { projectRectificationStepState } from "./step-state.ts";
|
|
export type { RectificationStepState } from "./step-state.ts";
|
|
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
|
|
import {
|
|
deriveDeclaredSearchWindow,
|
|
stageForClockWindow,
|
|
} from "./search-window.ts";
|
|
import {
|
|
openResponse,
|
|
type OpenRectificationCaseRequest,
|
|
type OpenRectificationCaseResponse,
|
|
type RectificationEntrySummary,
|
|
} from "./open-request.ts";
|
|
|
|
type AccountingClient = SupabaseClient;
|
|
|
|
export type V9BaselineSnapshot = Readonly<{
|
|
birth_date: string;
|
|
birth_place_label: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
timezone_id: string;
|
|
timezone_offset: number;
|
|
birth_time_source: string;
|
|
birth_time_period: string | null;
|
|
birth_time_clue: string | null;
|
|
declared_window_start: string | null;
|
|
declared_window_end: string | null;
|
|
reported_birth_time: string | null;
|
|
active_birth_time: string | null;
|
|
uncertainty_before_minutes: number | null;
|
|
uncertainty_after_minutes: number | null;
|
|
ayanamsa: AyanamsaName;
|
|
}>;
|
|
|
|
export type V9RectificationProfile = Readonly<{
|
|
userId: string;
|
|
baseline: V9BaselineSnapshot;
|
|
baselineFingerprint: string;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
stage: RectificationCaseStage;
|
|
}>;
|
|
|
|
export type RectificationSkillIdentityStatus = "verified" | "legacy_unverifiable";
|
|
|
|
export type RectificationSkillIdentityStatusView = Readonly<{
|
|
skillName: string;
|
|
skillVersion: string;
|
|
skillSha256: string | null;
|
|
sourceCommit: string | null;
|
|
status: RectificationSkillIdentityStatus;
|
|
requiresSkillAdoption: boolean;
|
|
}>;
|
|
|
|
export type RectificationCaseView = Readonly<{
|
|
caseId: string;
|
|
sessionId: string;
|
|
status: string;
|
|
skillName: string;
|
|
skillVersion: string;
|
|
skillIdentityStatus: RectificationSkillIdentityStatus;
|
|
requiresSkillAdoption: boolean;
|
|
stage: RectificationCaseStage;
|
|
candidateRange: { start_time: string; end_time: string } | null;
|
|
acceptedTime: string | null;
|
|
confirmedTime: string | null;
|
|
createdAt: string;
|
|
lastActivityAt: string;
|
|
completedAt: string | null;
|
|
closedReason: string | null;
|
|
evidenceCount: number;
|
|
turnCount: number;
|
|
latestResult: unknown;
|
|
}>;
|
|
|
|
export class RectificationCaseServiceError extends Error {
|
|
readonly code: string;
|
|
|
|
constructor(code: string) {
|
|
super(`Rectification case service error: ${code}`);
|
|
this.name = "RectificationCaseServiceError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const clockTime = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
|
|
function timeValue(value: unknown): string | null {
|
|
const time = typeof value === "string" ? value.slice(0, 5) : "";
|
|
return clockTime.test(time) ? time : null;
|
|
}
|
|
|
|
function numberOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function shiftedTime(time: string, offsetMinutes: number): string {
|
|
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
|
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
|
|
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function deriveRectificationOpenPlan(input: {
|
|
reportedTime: string | null;
|
|
source: string;
|
|
period: string | null;
|
|
windowStart: string | null;
|
|
windowEnd: string | null;
|
|
uncertaintyBefore?: number | null;
|
|
uncertaintyAfter?: number | null;
|
|
}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } {
|
|
if (input.reportedTime) {
|
|
const candidateRange = deriveDeclaredSearchWindow({
|
|
reportedTime: input.reportedTime,
|
|
source: input.source,
|
|
uncertaintyBefore: input.uncertaintyBefore ?? null,
|
|
uncertaintyAfter: input.uncertaintyAfter ?? null,
|
|
}) ?? {
|
|
start_time: shiftedTime(input.reportedTime, -15),
|
|
end_time: shiftedTime(input.reportedTime, 15),
|
|
};
|
|
return {
|
|
candidateRange,
|
|
stage: input.source === "unknown" ? "minute" : stageForClockWindow(candidateRange),
|
|
};
|
|
}
|
|
|
|
const range = declaredClockRange({
|
|
source: input.source,
|
|
period: input.period,
|
|
startTime: input.windowStart,
|
|
endTime: input.windowEnd,
|
|
});
|
|
if (!range) throw new RectificationCaseServiceError("profile_incomplete");
|
|
const candidateRange = { start_time: range.startTime, end_time: range.endTime };
|
|
if (input.source === "unknown") {
|
|
return { candidateRange, stage: "block_scan" };
|
|
}
|
|
return {
|
|
candidateRange,
|
|
stage: stageForClockWindow(candidateRange),
|
|
};
|
|
}
|
|
|
|
export function deriveRectificationOpenWindow(input: {
|
|
reportedTime: string | null;
|
|
source: string;
|
|
period: string | null;
|
|
windowStart: string | null;
|
|
windowEnd: string | null;
|
|
uncertaintyBefore?: number | null;
|
|
uncertaintyAfter?: number | null;
|
|
}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } {
|
|
return deriveRectificationOpenPlan(input);
|
|
}
|
|
|
|
function deriveCandidateRange(input: {
|
|
reportedTime: string | null;
|
|
source: string;
|
|
period: string | null;
|
|
windowStart: string | null;
|
|
windowEnd: string | null;
|
|
}): { start_time: string; end_time: string } {
|
|
return deriveRectificationOpenPlan(input).candidateRange;
|
|
}
|
|
|
|
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
|
|
const canonical = [
|
|
baseline.birth_date,
|
|
String(baseline.latitude),
|
|
String(baseline.longitude),
|
|
baseline.timezone_id,
|
|
String(baseline.timezone_offset),
|
|
baseline.birth_time_source,
|
|
baseline.birth_time_period ?? "",
|
|
baseline.declared_window_start ?? "",
|
|
baseline.declared_window_end ?? "",
|
|
baseline.reported_birth_time ?? "",
|
|
String(baseline.uncertainty_before_minutes ?? ""),
|
|
String(baseline.uncertainty_after_minutes ?? ""),
|
|
].join("|");
|
|
return createHash("sha256").update(canonical).digest("hex");
|
|
}
|
|
|
|
export async function loadV9RectificationProfile(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
): Promise<V9RectificationProfile> {
|
|
const { data, error } = await accounting
|
|
.from("profiles")
|
|
.select(
|
|
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa",
|
|
)
|
|
.eq("id", userId)
|
|
.single();
|
|
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
|
|
|
|
let resolvedData: unknown;
|
|
try {
|
|
resolvedData = await resolveMissingBirthTimezoneOffset(data);
|
|
} catch {
|
|
throw new RectificationCaseServiceError("profile_unavailable");
|
|
}
|
|
const row = resolvedData as Record<string, unknown>;
|
|
const birthDate = normalizePersistedBirthDate(row.birth_date);
|
|
const latitude = numberOrNull(row.latitude);
|
|
const longitude = numberOrNull(row.longitude);
|
|
const birthPlaceLabel = typeof row.birth_place_label === "string" ? row.birth_place_label.trim() : "";
|
|
const timezoneId = typeof row.timezone_id === "string" ? row.timezone_id.trim() : "";
|
|
const timezoneOffset = numberOrNull(row.timezone_offset);
|
|
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
|
|
const reportedTime = timeValue(row.reported_birth_time);
|
|
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
|
|
const clue = typeof row.birth_time_clue === "string" && row.birth_time_clue.trim()
|
|
? row.birth_time_clue.trim().slice(0, 240)
|
|
: null;
|
|
const windowStart = timeValue(row.declared_window_start);
|
|
const windowEnd = timeValue(row.declared_window_end);
|
|
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
|
|
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
|
|
if (!birthDate || !birthPlaceLabel || latitude === null || longitude === null || !timezoneId || timezoneOffset === null || !source) {
|
|
throw new RectificationCaseServiceError("profile_incomplete");
|
|
}
|
|
|
|
const baseline: V9BaselineSnapshot = {
|
|
birth_date: birthDate,
|
|
birth_place_label: birthPlaceLabel,
|
|
latitude,
|
|
longitude,
|
|
timezone_id: timezoneId,
|
|
timezone_offset: timezoneOffset,
|
|
birth_time_source: source,
|
|
birth_time_period: period,
|
|
birth_time_clue: clue,
|
|
declared_window_start: windowStart,
|
|
declared_window_end: windowEnd,
|
|
reported_birth_time: reportedTime,
|
|
// A fresh Case must never inherit an accepted/active minute as its new baseline.
|
|
active_birth_time: null,
|
|
uncertainty_before_minutes: uncertaintyBefore,
|
|
uncertainty_after_minutes: uncertaintyAfter,
|
|
ayanamsa: resolveAyanamsa(row),
|
|
};
|
|
|
|
const openPlan = deriveRectificationOpenPlan({
|
|
reportedTime,
|
|
source,
|
|
period,
|
|
windowStart,
|
|
windowEnd,
|
|
uncertaintyBefore,
|
|
uncertaintyAfter,
|
|
});
|
|
|
|
return {
|
|
userId,
|
|
baseline,
|
|
baselineFingerprint: baselineFingerprint(baseline),
|
|
candidateRange: openPlan.candidateRange,
|
|
stage: openPlan.stage,
|
|
};
|
|
}
|
|
|
|
function readRpcData(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value[0] ?? null;
|
|
if (value && typeof value === "object" && "value" in value) {
|
|
return (value as { value?: unknown }).value;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; message: string }>([
|
|
["agentic_rectification_profile_incomplete", { status: 422, code: "profile_incomplete", message: "出生资料不完整" }],
|
|
["agentic_rectification_active_case_conflict", { status: 409, code: "active_case_conflict", message: "仍有未完成的校正,请先继续或明确结束当前校正" }],
|
|
["agentic_rectification_session_not_found", { status: 404, code: "case_session_not_found", message: "校正会话不存在或无权访问" }],
|
|
["agentic_rectification_session_not_rectification", { status: 400, code: "session_not_rectification", message: "该会话不是生时校正会话" }],
|
|
["agentic_rectification_case_not_found", { status: 404, code: "case_not_found", message: "校正记录不存在或无权访问" }],
|
|
["agentic_rectification_case_terminal", { status: 409, code: "case_terminal", message: "该校正已结束,不能继续修改" }],
|
|
["agentic_rectification_case_session_mismatch", { status: 409, code: "case_session_mismatch", message: "校正记录与会话绑定不一致" }],
|
|
["agentic_rectification_case_owner_mismatch", { status: 403, code: "case_owner_mismatch", message: "无权访问该校正记录" }],
|
|
["agentic_rectification_skill_identity_missing", { status: 409, code: "skill_identity_missing", message: "该校正绑定的 Skill 版本不可用,请联系支持人员" }],
|
|
["agentic_rectification_skill_identity_mismatch", { status: 409, code: "skill_identity_mismatch", message: "该校正绑定的规则版本与当前不一致,请刷新后重试" }],
|
|
["agentic_rectification_legacy_skill_identity_unverifiable", { status: 409, code: "skill_identity_unverifiable", message: "该校正绑定的是无法核验的历史 Skill,请先采用当前注册版本" }],
|
|
["agentic_rectification_skill_identity_already_verified", { status: 409, code: "skill_identity_already_verified", message: "该校正已经绑定可核验的 Skill,不能重复采用历史 Skill" }],
|
|
["agentic_rectification_invalid_skill_identity", { status: 400, code: "invalid_skill_identity", message: "Skill 版本身份不正确" }],
|
|
["agentic_rectification_skill_name_mismatch", { status: 409, code: "skill_name_mismatch", message: "不能将校正记录升级到其他 Skill" }],
|
|
["agentic_rectification_invalid_input", { status: 400, code: "invalid_input", message: "请求内容不正确" }],
|
|
["agentic_rectification_invalid_intent", { status: 400, code: "invalid_intent", message: "请求内容不正确" }],
|
|
["agentic_rectification_invalid_range", { status: 400, code: "invalid_range", message: "候选范围不正确" }],
|
|
["agentic_rectification_invalid_block_scan_window", { status: 400, code: "invalid_block_scan_window", message: "当前窗口不能比较时段" }],
|
|
["agentic_rectification_not_block_scan", { status: 409, code: "not_block_scan", message: "当前不是时段比较阶段" }],
|
|
["agentic_rectification_invalid_block_period", { status: 400, code: "invalid_block_period", message: "出生时段不正确" }],
|
|
["agentic_rectification_invalid_block_window", { status: 400, code: "invalid_block_window", message: "新窗口必须落在当前范围内" }],
|
|
["agentic_rectification_invalid_widen_window", { status: 400, code: "invalid_widen_window", message: "只能在当前范围内放宽,且不能超过前后两小时" }],
|
|
["agentic_rectification_not_minute", { status: 409, code: "not_minute", message: "当前不是按分钟比较的阶段" }],
|
|
["agentic_rectification_already_adopted", { status: 409, code: "already_adopted", message: "已经采用过候选时间" }],
|
|
["agentic_rectification_turn_incomplete", { status: 400, code: "turn_incomplete", message: "回合内容不完整" }],
|
|
["agentic_rectification_quote_not_grounded", { status: 422, code: "quote_not_grounded", message: "事件引用未能在本轮消息中找到" }],
|
|
["agentic_rectification_evidence_not_found", { status: 404, code: "evidence_not_found", message: "事件记录不存在或无权访问" }],
|
|
["agentic_rectification_evidence_not_confirmable", { status: 409, code: "evidence_not_confirmable", message: "该事件当前不能确认" }],
|
|
["agentic_rectification_evidence_not_revisable", { status: 409, code: "evidence_not_revisable", message: "该事件当前不能修订" }],
|
|
["agentic_rectification_precision_downgrade", { status: 422, code: "precision_downgrade", message: "不能把已确认的更细日期精度改粗" }],
|
|
["agentic_rectification_stale_probe", { status: 409, code: "stale_probe", message: "这道区分题已经过期,请回答当前问题" }],
|
|
["agentic_rectification_stale_question", { status: 409, code: "stale_question", message: "这道题已经过期,请回答当前问题" }],
|
|
["agentic_rectification_focus_not_active", { status: 409, code: "focus_not_active", message: "当前没有等待回答的问题" }],
|
|
["agentic_rectification_invalid_choice_schema", { status: 409, code: "invalid_choice_schema", message: RECTIFICATION_USER_COPY.questionUpdated }],
|
|
["agentic_rectification_focus_not_found", { status: 409, code: "focus_not_found", message: "当前没有等待回答的问题" }],
|
|
["agentic_rectification_revision_conflict", { status: 409, code: "revision_conflict", message: "推断状态已更新,请刷新后再试" }],
|
|
["agentic_rectification_inference_patch_retired", { status: 409, code: "inference_patch_retired", message: "不能再原地修改推断回执" }],
|
|
]);
|
|
|
|
export type RectificationServiceErrorView = {
|
|
status: number;
|
|
code: string;
|
|
message: string;
|
|
};
|
|
|
|
export function mapRectificationRpcError(error: unknown): RectificationServiceErrorView {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "object" && error !== null && "message" in error
|
|
? String((error as { message?: unknown }).message ?? "")
|
|
: "";
|
|
for (const [code, view] of KNOWN_RPC_ERROR_CODES) {
|
|
if (message.includes(code) || message.includes(view.code)) return view;
|
|
}
|
|
return { status: 500, code: "rectification_service_failed", message: "校正服务暂时不可用" };
|
|
}
|
|
|
|
type OpenSkillIdentity = Readonly<{
|
|
name: string;
|
|
version: string;
|
|
sha256: string;
|
|
sourceCommit: string | null;
|
|
}>;
|
|
|
|
function readBoundSkillIdentity(row: unknown): OpenSkillIdentity {
|
|
if (!row || typeof row !== "object") {
|
|
throw new RectificationCaseServiceError("invalid_skill_identity");
|
|
}
|
|
const value = row as Record<string, unknown>;
|
|
const name = typeof value.skill_name === "string" ? value.skill_name : "";
|
|
const version = typeof value.skill_version === "string" ? value.skill_version : "";
|
|
const sha256 = typeof value.skill_sha256 === "string" ? value.skill_sha256 : "";
|
|
const sourceCommit = value.skill_source_commit === null || value.skill_source_commit === undefined
|
|
? null
|
|
: typeof value.skill_source_commit === "string"
|
|
? value.skill_source_commit
|
|
: null;
|
|
if (
|
|
!name
|
|
|| !version
|
|
|| !/^[0-9a-f]{64}$/.test(sha256)
|
|
|| (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit))
|
|
) {
|
|
throw new RectificationCaseServiceError("invalid_skill_identity");
|
|
}
|
|
return { name, version, sha256, sourceCommit };
|
|
}
|
|
|
|
async function resolveBoundSkillForSessionOpen(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
request: Extract<OpenRectificationCaseRequest, { intent: "session" }>,
|
|
placeholder: OpenSkillIdentity,
|
|
): Promise<OpenSkillIdentity> {
|
|
const { data, error } = await accounting.rpc("open_agentic_rectification_case", {
|
|
p_user_id: userId,
|
|
p_request_id: request.requestId,
|
|
p_intent: request.intent,
|
|
p_session_id: request.sessionId,
|
|
p_skill_name: placeholder.name,
|
|
p_skill_version: placeholder.version,
|
|
p_baseline_profile_fingerprint: "session-view",
|
|
p_baseline_birth_snapshot: {},
|
|
p_candidate_range: { start_time: "00:00", end_time: "23:59" },
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const preview = openResponse(readRpcData(data));
|
|
if (!preview) throw new RectificationCaseServiceError("invalid_open_response");
|
|
|
|
const identity = await accounting.rpc("get_agentic_rectification_skill_identity", {
|
|
p_user_id: userId,
|
|
p_case_id: preview.caseId,
|
|
});
|
|
if (identity.error) throw new RectificationCaseServiceError(mapRectificationRpcError(identity.error).code);
|
|
const bound = readBoundSkillIdentity(readRpcData(identity.data));
|
|
try {
|
|
resolveExactSkillPackage(bound.name, bound.version, bound.sha256);
|
|
} catch (caught) {
|
|
if (caught instanceof SkillPackageRegistryError) {
|
|
throw new RectificationCaseServiceError("skill_identity_missing");
|
|
}
|
|
throw caught;
|
|
}
|
|
return bound;
|
|
}
|
|
|
|
export async function openRectificationCase(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
request: OpenRectificationCaseRequest,
|
|
): Promise<OpenRectificationCaseResponse> {
|
|
let profile: V9RectificationProfile | null = null;
|
|
if (request.intent === "homepage" || request.intent === "new") {
|
|
profile = await loadV9RectificationProfile(accounting, userId);
|
|
}
|
|
|
|
const activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME);
|
|
if (activeSkill.version !== RECTIFICATION_SKILL_VERSION) {
|
|
throw new RectificationCaseServiceError("skill_registry_version_mismatch");
|
|
}
|
|
const skill = request.intent === "session"
|
|
? await resolveBoundSkillForSessionOpen(accounting, userId, request, activeSkill)
|
|
: activeSkill;
|
|
const { data, error } = await accounting.rpc("open_agentic_rectification_case_v2", {
|
|
p_user_id: userId,
|
|
p_request_id: request.requestId,
|
|
p_intent: request.intent,
|
|
p_session_id: request.intent === "session" ? request.sessionId : null,
|
|
p_skill_name: skill.name,
|
|
p_skill_version: skill.version,
|
|
p_skill_sha256: skill.sha256,
|
|
p_skill_source_commit: skill.sourceCommit,
|
|
p_baseline_profile_fingerprint: profile?.baselineFingerprint ?? "session-view",
|
|
p_baseline_birth_snapshot: profile?.baseline ?? {},
|
|
p_candidate_range: profile?.candidateRange ?? { start_time: "00:00", end_time: "23:59" },
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const response = openResponse(readRpcData(data));
|
|
if (!response) throw new RectificationCaseServiceError("invalid_open_response");
|
|
if (profile?.stage === "block_scan" && response.disposition === "created") {
|
|
const staged = await accounting.rpc("set_agentic_rectification_case_stage", {
|
|
p_user_id: userId,
|
|
p_case_id: response.caseId,
|
|
p_stage: "block_scan",
|
|
});
|
|
if (staged.error) throw new RectificationCaseServiceError(mapRectificationRpcError(staged.error).code);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
export async function getRectificationEntrySummary(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
): Promise<RectificationEntrySummary> {
|
|
const { data, error } = await accounting.rpc("get_agentic_rectification_entry_summary", {
|
|
p_user_id: userId,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_entry_summary");
|
|
const value = row as Record<string, unknown>;
|
|
const latestResumable =
|
|
value.latest_resumable && typeof value.latest_resumable === "object"
|
|
? (value.latest_resumable as Record<string, unknown>)
|
|
: null;
|
|
const latestTerminal =
|
|
value.latest_terminal && typeof value.latest_terminal === "object"
|
|
? (value.latest_terminal as Record<string, unknown>)
|
|
: null;
|
|
return {
|
|
hasResumableCase: value.has_resumable_case === true,
|
|
hasTerminalCaseWithTime: value.has_terminal_case_with_time === true,
|
|
latestResumable:
|
|
latestResumable && typeof latestResumable.case_id === "string"
|
|
? {
|
|
caseId: latestResumable.case_id,
|
|
status: isRectificationCaseStatus(latestResumable.status)
|
|
? latestResumable.status
|
|
: "draft",
|
|
lastActivityAt:
|
|
typeof latestResumable.last_activity_at === "string" ? latestResumable.last_activity_at : "",
|
|
}
|
|
: null,
|
|
latestTerminal:
|
|
latestTerminal && typeof latestTerminal.case_id === "string"
|
|
? {
|
|
caseId: latestTerminal.case_id,
|
|
status: isRectificationCaseStatus(latestTerminal.status)
|
|
? latestTerminal.status
|
|
: ("closed" as RectificationCaseStatus),
|
|
hasUsableTime: latestTerminal.has_usable_time === true,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function caseView(row: Record<string, unknown>): RectificationCaseView {
|
|
const range = row.candidate_range && typeof row.candidate_range === "object"
|
|
? (row.candidate_range as { start_time?: unknown; end_time?: unknown })
|
|
: null;
|
|
return {
|
|
caseId: String(row.case_id ?? ""),
|
|
sessionId: String(row.session_id ?? ""),
|
|
status: String(row.status ?? ""),
|
|
skillName: String(row.skill_name ?? ""),
|
|
skillVersion: String(row.skill_version ?? ""),
|
|
skillIdentityStatus: row.skill_identity_status === "legacy_unverifiable" ? "legacy_unverifiable" : "verified",
|
|
requiresSkillAdoption: row.requires_skill_adoption === true,
|
|
stage: parseRectificationCaseStage(row.stage),
|
|
candidateRange:
|
|
range && typeof range.start_time === "string" && typeof range.end_time === "string"
|
|
? { start_time: range.start_time, end_time: range.end_time }
|
|
: null,
|
|
acceptedTime: typeof row.accepted_time === "string" ? row.accepted_time : null,
|
|
confirmedTime: typeof row.confirmed_time === "string" ? row.confirmed_time : null,
|
|
createdAt: String(row.created_at ?? ""),
|
|
lastActivityAt: String(row.last_activity_at ?? ""),
|
|
completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
|
|
closedReason: typeof row.closed_reason === "string" ? row.closed_reason : null,
|
|
evidenceCount: typeof row.evidence_count === "number" ? row.evidence_count : 0,
|
|
turnCount: typeof row.turn_count === "number" ? row.turn_count : 0,
|
|
latestResult: row.latest_result ?? null,
|
|
};
|
|
}
|
|
|
|
export async function getRectificationSkillIdentityStatus(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<RectificationSkillIdentityStatusView> {
|
|
const { data, error } = await accounting.rpc("get_agentic_rectification_skill_identity_status", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") {
|
|
throw new RectificationCaseServiceError("invalid_skill_identity_status");
|
|
}
|
|
const value = row as Record<string, unknown>;
|
|
const status = value.skill_identity_status;
|
|
const skillSha256 = value.skill_sha256 === null ? null : typeof value.skill_sha256 === "string" ? value.skill_sha256 : null;
|
|
const sourceCommit = value.skill_source_commit === null ? null : typeof value.skill_source_commit === "string" ? value.skill_source_commit : null;
|
|
if (typeof value.skill_name !== "string" || !value.skill_name
|
|
|| typeof value.skill_version !== "string" || !value.skill_version
|
|
|| (status !== "verified" && status !== "legacy_unverifiable")
|
|
|| (skillSha256 !== null && !/^[0-9a-f]{64}$/.test(skillSha256))
|
|
|| (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit))
|
|
|| (status === "verified" && skillSha256 === null)
|
|
|| (status === "legacy_unverifiable" && skillSha256 !== null)
|
|
|| (status === "legacy_unverifiable" && value.requires_skill_adoption !== true)) {
|
|
throw new RectificationCaseServiceError("invalid_skill_identity_status");
|
|
}
|
|
return {
|
|
skillName: value.skill_name,
|
|
skillVersion: value.skill_version,
|
|
skillSha256,
|
|
sourceCommit,
|
|
status,
|
|
requiresSkillAdoption: value.requires_skill_adoption === true,
|
|
};
|
|
}
|
|
|
|
export async function getRectificationCase(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<RectificationCaseView> {
|
|
const { data, error } = await accounting.rpc("get_agentic_rectification_case", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_case_view");
|
|
return caseView(row as Record<string, unknown>);
|
|
}
|
|
|
|
export type RectificationCloseResult = Readonly<{
|
|
success: boolean;
|
|
caseId: string;
|
|
status: string;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export async function closeRectificationCase(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
reason: "completed_by_user" | "abandoned_by_user" | "other",
|
|
): Promise<RectificationCloseResult> {
|
|
const { data, error } = await accounting.rpc("close_agentic_rectification_case", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_reason: reason,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_close_result");
|
|
const value = row as Record<string, unknown>;
|
|
return {
|
|
success: value.success === true,
|
|
caseId: String(value.case_id ?? caseId),
|
|
status: String(value.status ?? "closed"),
|
|
idempotent: value.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export type RectificationUpgradeResult = Readonly<{
|
|
success: boolean;
|
|
caseId: string;
|
|
previousSkillVersion: string;
|
|
previousSkillSha256: string | null;
|
|
skillVersion: string;
|
|
skillSha256: string;
|
|
sourceCommit: string | null;
|
|
receiptId: string | null;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export type RectificationSkillAdoptionResult = Readonly<{
|
|
success: true;
|
|
caseId: string;
|
|
previousSkillName: string;
|
|
previousSkillVersion: string;
|
|
previousSkillSha256: null;
|
|
previousSourceCommit: string | null;
|
|
previousIdentityStatus: "legacy_unverifiable";
|
|
upgradeKind: "legacy_adoption";
|
|
skillName: string;
|
|
skillVersion: string;
|
|
skillSha256: string;
|
|
sourceCommit: string | null;
|
|
receiptId: string;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export async function adoptLegacyRectificationSkill(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
skillVersion: string,
|
|
): Promise<RectificationSkillAdoptionResult> {
|
|
const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion);
|
|
const { data, error } = await accounting.rpc("adopt_agentic_rectification_skill_v1", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_skill_name: target.name,
|
|
p_skill_version: target.version,
|
|
p_skill_sha256: target.sha256,
|
|
p_skill_source_commit: target.sourceCommit,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_adoption_result");
|
|
const value = row as Record<string, unknown>;
|
|
const previousSourceCommit = value.previous_source_commit === null
|
|
? null
|
|
: typeof value.previous_source_commit === "string"
|
|
? value.previous_source_commit
|
|
: null;
|
|
const sourceCommit = value.source_commit === null
|
|
? null
|
|
: typeof value.source_commit === "string"
|
|
? value.source_commit
|
|
: null;
|
|
const receiptId = typeof value.receipt_id === "string" ? value.receipt_id : "";
|
|
if (
|
|
value.success !== true
|
|
|| value.case_id !== caseId
|
|
|| typeof value.previous_skill_name !== "string"
|
|
|| !value.previous_skill_name
|
|
|| typeof value.previous_skill_version !== "string"
|
|
|| !value.previous_skill_version
|
|
|| value.previous_skill_sha256 !== null
|
|
|| (previousSourceCommit !== null && !/^[0-9a-f]{40}$/.test(previousSourceCommit))
|
|
|| value.previous_identity_status !== "legacy_unverifiable"
|
|
|| value.upgrade_kind !== "legacy_adoption"
|
|
|| value.skill_name !== target.name
|
|
|| value.skill_version !== target.version
|
|
|| value.skill_sha256 !== target.sha256
|
|
|| sourceCommit !== target.sourceCommit
|
|
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receiptId)
|
|
) {
|
|
throw new RectificationCaseServiceError("invalid_adoption_result");
|
|
}
|
|
return {
|
|
success: true,
|
|
caseId,
|
|
previousSkillName: value.previous_skill_name,
|
|
previousSkillVersion: value.previous_skill_version,
|
|
previousSkillSha256: null,
|
|
previousSourceCommit,
|
|
previousIdentityStatus: "legacy_unverifiable",
|
|
upgradeKind: "legacy_adoption",
|
|
skillName: target.name,
|
|
skillVersion: target.version,
|
|
skillSha256: target.sha256,
|
|
sourceCommit,
|
|
receiptId,
|
|
idempotent: value.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export async function upgradeRectificationSkill(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
skillVersion: string,
|
|
): Promise<RectificationUpgradeResult> {
|
|
const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion);
|
|
const { data, error } = await accounting.rpc("upgrade_agentic_rectification_skill_v2", {
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_skill_name: target.name,
|
|
p_skill_version: target.version,
|
|
p_skill_sha256: target.sha256,
|
|
p_skill_source_commit: target.sourceCommit,
|
|
});
|
|
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
|
const row = readRpcData(data);
|
|
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_upgrade_result");
|
|
const value = row as Record<string, unknown>;
|
|
return {
|
|
success: value.success === true,
|
|
caseId: String(value.case_id ?? caseId),
|
|
previousSkillVersion: String(value.previous_skill_version ?? ""),
|
|
previousSkillSha256: value.previous_skill_sha256 === null || value.previous_skill_sha256 === undefined
|
|
? null
|
|
: String(value.previous_skill_sha256),
|
|
skillVersion: String(value.skill_version ?? skillVersion),
|
|
skillSha256: String(value.skill_sha256 ?? target.sha256),
|
|
sourceCommit: typeof value.source_commit === "string" ? value.source_commit : null,
|
|
receiptId: typeof value.receipt_id === "string" ? value.receipt_id : null,
|
|
idempotent: value.idempotent === true,
|
|
};
|
|
}
|