308 lines
9.4 KiB
TypeScript
308 lines
9.4 KiB
TypeScript
import { createLogger } from '@shared/utils/logger';
|
|
import type {
|
|
BoardTaskActivityLinkKind,
|
|
BoardTaskActivityPhase,
|
|
BoardTaskActivityTargetRole,
|
|
BoardTaskActorRelation,
|
|
BoardTaskLocator,
|
|
} from '@shared/types';
|
|
|
|
const logger = createLogger('Service:BoardTaskTranscriptContract');
|
|
|
|
export interface ParsedBoardTaskActorContext {
|
|
relation: BoardTaskActorRelation;
|
|
activeTask?: BoardTaskLocator;
|
|
activePhase?: BoardTaskActivityPhase;
|
|
activeExecutionSeq?: number;
|
|
}
|
|
|
|
export interface ParsedBoardTaskLink {
|
|
schemaVersion: 1;
|
|
toolUseId?: string;
|
|
task: BoardTaskLocator;
|
|
targetRole: BoardTaskActivityTargetRole;
|
|
linkKind: BoardTaskActivityLinkKind;
|
|
taskArgumentSlot?: 'taskId' | 'targetId';
|
|
actorContext: ParsedBoardTaskActorContext;
|
|
}
|
|
|
|
export interface ParsedBoardTaskToolAction {
|
|
schemaVersion: 1;
|
|
toolUseId: string;
|
|
canonicalToolName: string;
|
|
input?: {
|
|
status?: 'pending' | 'in_progress' | 'completed' | 'deleted';
|
|
owner?: string | null;
|
|
clarification?: 'lead' | 'user' | null;
|
|
reviewer?: string;
|
|
relationship?: 'blocked-by' | 'blocks' | 'related';
|
|
commentId?: string;
|
|
};
|
|
resultRefs?: {
|
|
commentId?: string;
|
|
attachmentId?: string;
|
|
filename?: string;
|
|
};
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function asNonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
}
|
|
|
|
function parseNullableOwner(value: unknown): string | null | undefined {
|
|
if (value === null) return null;
|
|
const normalized = asNonEmptyString(value);
|
|
if (!normalized) return undefined;
|
|
if (normalized === 'clear' || normalized === 'none') {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function parseStatus(
|
|
value: unknown
|
|
): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined {
|
|
const normalized = asNonEmptyString(value);
|
|
if (
|
|
normalized === 'pending' ||
|
|
normalized === 'in_progress' ||
|
|
normalized === 'completed' ||
|
|
normalized === 'deleted'
|
|
) {
|
|
return normalized;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseRelationship(value: unknown): 'blocked-by' | 'blocks' | 'related' | undefined {
|
|
const normalized = asNonEmptyString(value);
|
|
if (normalized === 'blocked-by' || normalized === 'blocks' || normalized === 'related') {
|
|
return normalized;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseClarification(value: unknown): 'lead' | 'user' | null | undefined {
|
|
if (value === null) return null;
|
|
const normalized = asNonEmptyString(value);
|
|
if (!normalized) return undefined;
|
|
if (normalized === 'lead' || normalized === 'user') {
|
|
return normalized;
|
|
}
|
|
if (normalized === 'clear') {
|
|
return null;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function noteReadDiagnostic(
|
|
event: string,
|
|
details: Record<string, string | number | undefined> = {}
|
|
): void {
|
|
const suffix = Object.entries(details)
|
|
.filter(([, value]) => value !== undefined)
|
|
.map(([key, value]) => `${key}=${String(value)}`)
|
|
.join(' ');
|
|
|
|
logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`);
|
|
}
|
|
|
|
function parseSchemaVersion(record: Record<string, unknown>): 1 | null {
|
|
if (record.schemaVersion === 1) {
|
|
return 1;
|
|
}
|
|
if (record.version === 1) {
|
|
return 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
|
|
const ref = asNonEmptyString(record.ref);
|
|
const refKind = asNonEmptyString(record.refKind);
|
|
if (!ref || (refKind !== 'canonical' && refKind !== 'display' && refKind !== 'unknown')) {
|
|
return null;
|
|
}
|
|
|
|
const canonicalId = asNonEmptyString(record.canonicalId);
|
|
return {
|
|
ref,
|
|
refKind,
|
|
...(canonicalId ? { canonicalId } : {}),
|
|
};
|
|
}
|
|
|
|
function parseActorContext(value: unknown): ParsedBoardTaskActorContext | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
|
|
const relation = asNonEmptyString(record.relation);
|
|
if (
|
|
relation !== 'same_task' &&
|
|
relation !== 'other_active_task' &&
|
|
relation !== 'idle' &&
|
|
relation !== 'ambiguous'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const activeTask = parseBoardTaskLocator(record.activeTask);
|
|
const activePhase = asNonEmptyString(record.activePhase);
|
|
const activeExecutionSeq =
|
|
typeof record.activeExecutionSeq === 'number' && Number.isFinite(record.activeExecutionSeq)
|
|
? record.activeExecutionSeq
|
|
: undefined;
|
|
|
|
if (relation !== 'other_active_task') {
|
|
return { relation };
|
|
}
|
|
|
|
return {
|
|
relation,
|
|
...(activeTask ? { activeTask } : {}),
|
|
...(activePhase === 'work' || activePhase === 'review' ? { activePhase } : {}),
|
|
...(activeExecutionSeq ? { activeExecutionSeq } : {}),
|
|
};
|
|
}
|
|
|
|
export function parseBoardTaskLinks(value: unknown): ParsedBoardTaskLink[] {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const parsed: ParsedBoardTaskLink[] = [];
|
|
for (const item of value) {
|
|
const record = asRecord(item);
|
|
if (!record) {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'not_object' });
|
|
continue;
|
|
}
|
|
|
|
const schemaVersion = parseSchemaVersion(record);
|
|
if (schemaVersion !== 1) {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'unsupported_version' });
|
|
continue;
|
|
}
|
|
|
|
const task = parseBoardTaskLocator(record.task);
|
|
const targetRole = asNonEmptyString(record.targetRole);
|
|
const linkKind = asNonEmptyString(record.linkKind);
|
|
const actorContext = parseActorContext(record.actorContext);
|
|
const rawTaskArgumentSlot = asNonEmptyString(record.taskArgumentSlot);
|
|
const taskArgumentSlot =
|
|
rawTaskArgumentSlot === 'taskId' || rawTaskArgumentSlot === 'targetId'
|
|
? rawTaskArgumentSlot
|
|
: undefined;
|
|
const toolUseId = asNonEmptyString(record.toolUseId);
|
|
|
|
if (!task) {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_task' });
|
|
continue;
|
|
}
|
|
if (!actorContext) {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_actor_context' });
|
|
continue;
|
|
}
|
|
if (targetRole !== 'subject' && targetRole !== 'related') {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_target_role' });
|
|
continue;
|
|
}
|
|
if (linkKind !== 'execution' && linkKind !== 'lifecycle' && linkKind !== 'board_action') {
|
|
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_link_kind' });
|
|
continue;
|
|
}
|
|
const sanitizedToolUseId = toolUseId;
|
|
const sanitizedTaskArgumentSlot = linkKind === 'execution' ? undefined : taskArgumentSlot;
|
|
|
|
parsed.push({
|
|
schemaVersion: 1,
|
|
task,
|
|
targetRole,
|
|
linkKind,
|
|
actorContext,
|
|
...(sanitizedToolUseId ? { toolUseId: sanitizedToolUseId } : {}),
|
|
...(sanitizedTaskArgumentSlot ? { taskArgumentSlot: sanitizedTaskArgumentSlot } : {}),
|
|
});
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
export function parseBoardTaskToolActions(value: unknown): ParsedBoardTaskToolAction[] {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const parsed: ParsedBoardTaskToolAction[] = [];
|
|
for (const item of value) {
|
|
const record = asRecord(item);
|
|
if (!record) {
|
|
noteReadDiagnostic('action_parse_dropped', { reason: 'not_object' });
|
|
continue;
|
|
}
|
|
if (parseSchemaVersion(record) !== 1) {
|
|
noteReadDiagnostic('action_parse_dropped', { reason: 'unsupported_version' });
|
|
continue;
|
|
}
|
|
|
|
const toolUseId = asNonEmptyString(record.toolUseId);
|
|
const canonicalToolName = asNonEmptyString(record.canonicalToolName);
|
|
if (!toolUseId || !canonicalToolName) {
|
|
noteReadDiagnostic('action_parse_dropped', { reason: 'missing_identity' });
|
|
continue;
|
|
}
|
|
|
|
const inputRecord = asRecord(record.input);
|
|
const resultRefsRecord = asRecord(record.resultRefs);
|
|
|
|
parsed.push({
|
|
schemaVersion: 1,
|
|
toolUseId,
|
|
canonicalToolName,
|
|
...(inputRecord
|
|
? {
|
|
input: {
|
|
...(parseStatus(inputRecord.status) !== undefined
|
|
? { status: parseStatus(inputRecord.status) }
|
|
: {}),
|
|
...(parseNullableOwner(inputRecord.owner) !== undefined
|
|
? { owner: parseNullableOwner(inputRecord.owner) }
|
|
: {}),
|
|
...(parseClarification(inputRecord.clarification) !== undefined
|
|
? { clarification: parseClarification(inputRecord.clarification) }
|
|
: {}),
|
|
...(asNonEmptyString(inputRecord.reviewer)
|
|
? { reviewer: asNonEmptyString(inputRecord.reviewer) }
|
|
: {}),
|
|
...(parseRelationship(inputRecord.relationship) !== undefined
|
|
? { relationship: parseRelationship(inputRecord.relationship) }
|
|
: {}),
|
|
...(asNonEmptyString(inputRecord.commentId)
|
|
? { commentId: asNonEmptyString(inputRecord.commentId) }
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
...(resultRefsRecord
|
|
? {
|
|
resultRefs: {
|
|
...(asNonEmptyString(resultRefsRecord.commentId)
|
|
? { commentId: asNonEmptyString(resultRefsRecord.commentId) }
|
|
: {}),
|
|
...(asNonEmptyString(resultRefsRecord.attachmentId)
|
|
? { attachmentId: asNonEmptyString(resultRefsRecord.attachmentId) }
|
|
: {}),
|
|
...(asNonEmptyString(resultRefsRecord.filename)
|
|
? { filename: asNonEmptyString(resultRefsRecord.filename) }
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
return parsed;
|
|
}
|