- Removed broken LaunchSwarmDialog (formula-based) from TopBar/LeftPanel - All Rocket buttons (TopBar, LeftPanel, DAG nodes, social cards) now open AssignmentPanel (archetype-based) which actually works - Every Rocket clears taskId first so assignMode && !taskId condition passes - Conversation button priority: taskId always shows conversation, not assign panel - Added TelemetryStrip: minimized right sidebar with status dots when non-telemetry panel (conversation/assignment) is active - Live feed has minimize button → restores last taskId or assignMode - DAG nodes: Signal icon → restores telemetry feed - Social button on DAG nodes: single router.push to avoid race (setView + setTaskId) - Fixed social card message button: opens right panel with drawer:closed (no popup) Co-Authored-By: Oz <oz-agent@warp.dev>
335 lines
9.3 KiB
TypeScript
335 lines
9.3 KiB
TypeScript
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
|
import { issuesEventBus } from './realtime';
|
|
|
|
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
|
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
|
|
|
interface MutationBasePayload {
|
|
projectRoot: string;
|
|
bdPath?: string;
|
|
actor?: string;
|
|
}
|
|
|
|
export interface CreateMutationPayload extends MutationBasePayload {
|
|
title: string;
|
|
description?: string;
|
|
priority?: number;
|
|
issueType?: string;
|
|
assignee?: string;
|
|
labels?: string[];
|
|
}
|
|
|
|
export interface UpdateMutationPayload extends MutationBasePayload {
|
|
id: string;
|
|
title?: string;
|
|
description?: string;
|
|
status?: MutationStatus;
|
|
priority?: number;
|
|
issueType?: string;
|
|
assignee?: string;
|
|
labels?: string[];
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface CloseMutationPayload extends MutationBasePayload {
|
|
id: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface ReopenMutationPayload extends MutationBasePayload {
|
|
id: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface CommentMutationPayload extends MutationBasePayload {
|
|
id: string;
|
|
text: string;
|
|
}
|
|
|
|
export type MutationPayload =
|
|
| CreateMutationPayload
|
|
| UpdateMutationPayload
|
|
| CloseMutationPayload
|
|
| ReopenMutationPayload
|
|
| CommentMutationPayload;
|
|
|
|
export interface MutationErrorShape {
|
|
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
|
message: string;
|
|
}
|
|
|
|
export interface MutationResponse {
|
|
ok: boolean;
|
|
operation: MutationOperation;
|
|
command: RunBdCommandResult;
|
|
error?: MutationErrorShape;
|
|
}
|
|
|
|
export class MutationValidationError extends Error {
|
|
readonly code = 'MUTATION_VALIDATION_ERROR';
|
|
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'MutationValidationError';
|
|
}
|
|
}
|
|
|
|
function asNonEmptyString(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new MutationValidationError(`"${field}" is required.`);
|
|
}
|
|
const trimmed = value.trim();
|
|
// Remove control characters that could cause issues in command execution
|
|
// Preserve backslashes for Windows paths and punctuation for user text
|
|
const sanitized = trimmed.replace(/[\x00-\x1f\x7f]/g, '');
|
|
if (!sanitized) {
|
|
throw new MutationValidationError(`"${field}" contains only invalid characters.`);
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
function asOptionalString(value: unknown): string | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
throw new MutationValidationError('Expected a string value.');
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function asOptionalPriority(value: unknown): number | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
|
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function asOptionalLabels(value: unknown): string[] | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(value)) {
|
|
throw new MutationValidationError('"labels" must be an array of strings.');
|
|
}
|
|
const labels = value.map((label) => {
|
|
if (typeof label !== 'string' || !label.trim()) {
|
|
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
|
}
|
|
return label.trim();
|
|
});
|
|
|
|
return labels.length ? labels : undefined;
|
|
}
|
|
|
|
function asOptionalMetadata(value: unknown): Record<string, unknown> | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new MutationValidationError('"metadata" must be a JSON object.');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
const status = asNonEmptyString(value, 'status');
|
|
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
|
throw new MutationValidationError('"status" is invalid.');
|
|
}
|
|
return status as MutationStatus;
|
|
}
|
|
|
|
function parseBasePayload(raw: unknown): MutationBasePayload {
|
|
if (!raw || typeof raw !== 'object') {
|
|
throw new MutationValidationError('Payload must be a JSON object.');
|
|
}
|
|
|
|
const data = raw as Record<string, unknown>;
|
|
return {
|
|
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
|
bdPath: asOptionalString(data.bdPath),
|
|
actor: asOptionalString(data.actor),
|
|
};
|
|
}
|
|
|
|
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
|
const base = parseBasePayload(payload);
|
|
const data = payload as Record<string, unknown>;
|
|
|
|
if (operation === 'create') {
|
|
return {
|
|
...base,
|
|
title: asNonEmptyString(data.title, 'title'),
|
|
description: asOptionalString(data.description),
|
|
priority: asOptionalPriority(data.priority),
|
|
issueType: asOptionalString(data.issueType),
|
|
assignee: asOptionalString(data.assignee),
|
|
labels: asOptionalLabels(data.labels),
|
|
};
|
|
}
|
|
|
|
if (operation === 'update') {
|
|
const mapped: UpdateMutationPayload = {
|
|
...base,
|
|
id: asNonEmptyString(data.id, 'id'),
|
|
title: asOptionalString(data.title),
|
|
description: asOptionalString(data.description),
|
|
status: asOptionalStatus(data.status),
|
|
priority: asOptionalPriority(data.priority),
|
|
issueType: asOptionalString(data.issueType),
|
|
assignee: asOptionalString(data.assignee),
|
|
labels: asOptionalLabels(data.labels),
|
|
metadata: asOptionalMetadata(data.metadata),
|
|
};
|
|
|
|
if (
|
|
!mapped.title &&
|
|
!mapped.description &&
|
|
!mapped.status &&
|
|
mapped.priority === undefined &&
|
|
!mapped.issueType &&
|
|
!mapped.assignee &&
|
|
!mapped.labels &&
|
|
!mapped.metadata
|
|
) {
|
|
throw new MutationValidationError('At least one update field is required.');
|
|
}
|
|
|
|
return mapped;
|
|
}
|
|
|
|
if (operation === 'close') {
|
|
return {
|
|
...base,
|
|
id: asNonEmptyString(data.id, 'id'),
|
|
reason: asOptionalString(data.reason),
|
|
};
|
|
}
|
|
|
|
if (operation === 'reopen') {
|
|
return {
|
|
...base,
|
|
id: asNonEmptyString(data.id, 'id'),
|
|
reason: asOptionalString(data.reason),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
id: asNonEmptyString(data.id, 'id'),
|
|
text: asNonEmptyString(data.text, 'text'),
|
|
};
|
|
}
|
|
|
|
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
|
if (value) {
|
|
args.push(flag, value);
|
|
}
|
|
}
|
|
|
|
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
|
if (labels && labels.length > 0) {
|
|
args.push('--set-labels', labels.join(','));
|
|
}
|
|
}
|
|
|
|
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
|
if (operation === 'create') {
|
|
const data = payload as CreateMutationPayload;
|
|
const args = ['create', data.title];
|
|
pushOptionalArg(args, '-d', data.description);
|
|
if (data.priority !== undefined) {
|
|
args.push('-p', String(data.priority));
|
|
}
|
|
pushOptionalArg(args, '-t', data.issueType);
|
|
pushOptionalArg(args, '-a', data.assignee);
|
|
pushOptionalLabels(args, data.labels);
|
|
args.push('--json');
|
|
return args;
|
|
}
|
|
|
|
if (operation === 'update') {
|
|
const data = payload as UpdateMutationPayload;
|
|
const args = ['update', data.id];
|
|
pushOptionalArg(args, '--title', data.title);
|
|
pushOptionalArg(args, '-d', data.description);
|
|
pushOptionalArg(args, '-s', data.status);
|
|
if (data.priority !== undefined) {
|
|
args.push('-p', String(data.priority));
|
|
}
|
|
pushOptionalArg(args, '-t', data.issueType);
|
|
pushOptionalArg(args, '-a', data.assignee);
|
|
pushOptionalLabels(args, data.labels);
|
|
if (data.metadata) {
|
|
args.push(`--metadata=${JSON.stringify(data.metadata)}`);
|
|
}
|
|
args.push('--json');
|
|
return args;
|
|
}
|
|
|
|
if (operation === 'close') {
|
|
const data = payload as CloseMutationPayload;
|
|
const args = ['close', data.id];
|
|
pushOptionalArg(args, '-r', data.reason);
|
|
args.push('--json');
|
|
return args;
|
|
}
|
|
|
|
if (operation === 'reopen') {
|
|
const data = payload as ReopenMutationPayload;
|
|
const args = ['reopen', data.id];
|
|
pushOptionalArg(args, '-r', data.reason);
|
|
args.push('--json');
|
|
return args;
|
|
}
|
|
|
|
const data = payload as CommentMutationPayload;
|
|
return ['comments', 'add', data.id, data.text, '--json'];
|
|
}
|
|
|
|
interface ExecuteMutationDeps {
|
|
runBdCommand: typeof runBdCommand;
|
|
}
|
|
|
|
export async function executeMutation(
|
|
operation: MutationOperation,
|
|
payload: MutationPayload,
|
|
deps: Partial<ExecuteMutationDeps> = {},
|
|
): Promise<MutationResponse> {
|
|
const runner = deps.runBdCommand ?? runBdCommand;
|
|
const args = payload.actor
|
|
? ['--actor', payload.actor, ...buildBdMutationArgs(operation, payload)]
|
|
: buildBdMutationArgs(operation, payload);
|
|
const command = await runner({
|
|
projectRoot: payload.projectRoot,
|
|
args,
|
|
});
|
|
|
|
if (!command.success) {
|
|
return {
|
|
ok: false,
|
|
operation,
|
|
command,
|
|
error: {
|
|
classification: command.classification ?? 'unknown',
|
|
message: command.stderr || command.error || 'Mutation command failed.',
|
|
},
|
|
};
|
|
}
|
|
|
|
issuesEventBus.emit(payload.projectRoot, undefined, 'changed');
|
|
|
|
return {
|
|
ok: true,
|
|
operation,
|
|
command,
|
|
};
|
|
}
|