La cellule expose un bouton Redémarrer l'agent valable pour les trois modes de présentation (headless, TUI native, CLI custom), sans jamais prendre la présentation pour vérité runtime. - Application: use case RestartCellAgentRuntime validant le binding exact cellule/runtime via les snapshots live (RestartRuntimeNotFound, ConflictAgentVisibleElsewhere, AgentNotBoundToCell) puis déléguant au scheduler self-restart existant (schedule_cell); CellAgentPresentation purement informationnelle. - Backend/app-tauri/web-server: commande restart_cell_agent sur contrat DTO identique Tauri/HTTP, stop borné par timeout typé (RestartStopTimeout), relance préservant routage TUI natif, géométrie de cellule (node/rows/cols) et conversation; évenements domaine enrichis. - Frontend: bouton dans la barre de cellule avec confirmation, overlay d'état Redémarrage…/échec (libellés par code d'erreur, Fermer/ Réessayer), gardes d'identité de cellule et de restarts externes, gateway/mock/http adapters et ports alignés. - Tests: dto_agents, workstate_actions, contrats web-server, LayoutGrid.cellControlsLayering, adapters agent/mock/http. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
704 lines
24 KiB
TypeScript
704 lines
24 KiB
TypeScript
/**
|
|
* Stream + mixed gateways for the web transport — ticket #13, lot F1.
|
|
*
|
|
* These gateways combine HTTP request/response (via {@link HttpInvoker}) with the
|
|
* WebSocket live streams (via {@link WsLiveClient}), per the B0 contract:
|
|
* - {@link HttpSystemGateway}: `health` over HTTP; `onDomainEvent` over the WS
|
|
* `event.domain` stream; `pickFolder` is desktop-only ⇒ unsupported on web.
|
|
* - {@link HttpAgentGateway}: all agent request/response over HTTP; `launchAgent`
|
|
* / `reattach` over the WS PTY stream (**skeleton** — full round-trip in
|
|
* F3/B5/B6).
|
|
* - {@link HttpTicketGateway}: all `ticket_*`/`sprint_*` over HTTP; `sendTicketChat`
|
|
* over the WS `chat.*` stream (**skeleton**).
|
|
* - {@link HttpTerminalGateway}: PTY open/reattach/close over the WS terminal
|
|
* stream (**skeleton**).
|
|
*
|
|
* "Skeleton" means the frames are wired to the B0 contract and the handles are
|
|
* structurally correct against the ports, but a live end-to-end terminal needs
|
|
* the server (B3/B4) and the F3/B5 wiring. Lives in `src/adapters/**`; no
|
|
* `@tauri-apps/api`.
|
|
*/
|
|
|
|
import type {
|
|
Agent,
|
|
AgentContextDocument,
|
|
AppExitWorkGuardState,
|
|
DomainEvent,
|
|
EffortSelection,
|
|
ExecuteSlashCommandResult,
|
|
HealthReport,
|
|
ReplyChunk,
|
|
ResumableAgent,
|
|
SlashCommand,
|
|
Sprint,
|
|
TerminalSession,
|
|
Ticket,
|
|
TicketAttachmentContent,
|
|
TicketBulkResult,
|
|
TicketCarnet,
|
|
TicketChat,
|
|
TicketLinkKind,
|
|
TicketList,
|
|
Unsubscribe,
|
|
} from "@/domain";
|
|
import type {
|
|
AgentChatHandle,
|
|
AgentGateway,
|
|
ChatDraftScope,
|
|
ChatDraftSnapshot,
|
|
ConversationDetails,
|
|
CreateAgentInput,
|
|
CreateTicketInput,
|
|
LiveAgent,
|
|
OpenTerminalOptions,
|
|
ReattachAgentChatResult,
|
|
ReattachResult,
|
|
RestartCellAgentRuntimeInput,
|
|
RestartCellAgentRuntimeScheduled,
|
|
SendAgentChatOptions,
|
|
StoppedLiveAgent,
|
|
SystemGateway,
|
|
TerminalGateway,
|
|
TerminalHandle,
|
|
TicketGateway,
|
|
TicketListQuery,
|
|
UpdateTicketInput,
|
|
} from "@/ports";
|
|
import type { HttpInvoker } from "./httpInvoker";
|
|
import { WsLiveClient } from "./wsLiveClient";
|
|
import { type ChatAttachedPayload, type ChatOutputPayload } from "./frames";
|
|
import { unsupportedOnWeb } from "./unsupported";
|
|
|
|
/**
|
|
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
|
|
* output stream is delivered through the sink the gateway registered on the
|
|
* {@link WsLiveClient} for this `sessionId`.
|
|
*/
|
|
export function makeWsTerminalHandle(
|
|
sessionId: string,
|
|
ws: WsLiveClient,
|
|
assignedConversationId?: string,
|
|
): TerminalHandle {
|
|
return {
|
|
sessionId,
|
|
...(assignedConversationId ? { assignedConversationId } : {}),
|
|
async write(data: Uint8Array): Promise<void> {
|
|
await ws.sendFireAndForget("terminal.input", {
|
|
sessionId,
|
|
bytesBase64: WsLiveClient.encodeInput(data),
|
|
});
|
|
},
|
|
async resize(rows: number, cols: number): Promise<void> {
|
|
await ws.sendFireAndForget("terminal.resize", { sessionId, rows, cols });
|
|
},
|
|
detach(): void {
|
|
// Stop delivering output locally and tell the server the view is gone. The
|
|
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
|
|
// Untracking also disables reconnection re-attach for this session.
|
|
void ws.detachTerminal(sessionId);
|
|
},
|
|
async close(): Promise<void> {
|
|
await ws.closeTerminalSession(sessionId);
|
|
},
|
|
};
|
|
}
|
|
|
|
function chatSessionId(payload: ChatAttachedPayload): string {
|
|
const sessionId = payload.sessionId ?? payload.session?.sessionId;
|
|
if (!sessionId) {
|
|
throw { code: "INVALID_CHAT_ACK", message: "chat acknowledgement omitted sessionId" };
|
|
}
|
|
return sessionId;
|
|
}
|
|
|
|
export class HttpSystemGateway implements SystemGateway {
|
|
constructor(
|
|
private readonly http: HttpInvoker,
|
|
private readonly ws: WsLiveClient,
|
|
) {}
|
|
|
|
health(note?: string): Promise<HealthReport> {
|
|
return this.http.invoke<HealthReport>("health", {
|
|
request: note === undefined ? null : { note },
|
|
});
|
|
}
|
|
|
|
async onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe> {
|
|
// The low-frequency domain-event stream rides the same WS connection
|
|
// (`event.domain`), replacing Tauri `listen("domain://event")`.
|
|
await this.ws.ensureConnected();
|
|
this.ws.setDomainEventHandler(handler);
|
|
return () => this.ws.clearDomainEventHandler();
|
|
}
|
|
|
|
pickFolder(): Promise<string | null> {
|
|
// Desktop-only: the native OS dialog has no web equivalent. A server-side
|
|
// folder browser would be its own lot (flagged in the F1 report).
|
|
return unsupportedOnWeb("Native folder picker");
|
|
}
|
|
|
|
pickArchiveFile(): Promise<string | null> {
|
|
// Desktop-only, same rationale as `pickFolder`.
|
|
return unsupportedOnWeb("Native file picker");
|
|
}
|
|
|
|
pickFile(): Promise<string | null> {
|
|
// Desktop-only, same rationale as `pickFolder`.
|
|
return unsupportedOnWeb("Native file picker");
|
|
}
|
|
|
|
onAppExitWorkGuard(
|
|
_handler: (state: AppExitWorkGuardState) => void,
|
|
): Promise<Unsubscribe> {
|
|
// Desktop-only (ticket #83): there is no interceptable native window to
|
|
// guard on the web client, so this never fires — an inert unsubscribe,
|
|
// not a rejection, so callers can subscribe unconditionally.
|
|
return Promise.resolve(() => {});
|
|
}
|
|
|
|
confirmAppExit(): Promise<void> {
|
|
return unsupportedOnWeb("Confirming an app exit");
|
|
}
|
|
}
|
|
|
|
export class HttpTerminalGateway implements TerminalGateway {
|
|
constructor(private readonly ws: WsLiveClient) {}
|
|
|
|
async openTerminal(
|
|
options: OpenTerminalOptions,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<TerminalHandle> {
|
|
// B5: `terminal.open` carries no projectId; `cwd` is a server-validated path.
|
|
// A fresh open has empty scrollback (the view does not repaint on open).
|
|
const res = await this.ws.openTerminal({
|
|
cwd: options.cwd,
|
|
rows: options.rows,
|
|
cols: options.cols,
|
|
nodeId: options.nodeId ?? null,
|
|
onData,
|
|
});
|
|
return makeWsTerminalHandle(res.sessionId, this.ws);
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
// The view repaints the returned scrollback itself (TerminalView contract);
|
|
// the client does not also push it through `onData` on the initial attach.
|
|
const res = await this.ws.attachTerminal({ sessionId, onData });
|
|
return {
|
|
handle: makeWsTerminalHandle(res.sessionId, this.ws),
|
|
scrollback: res.scrollback,
|
|
};
|
|
}
|
|
|
|
async closeTerminal(sessionId: string): Promise<void> {
|
|
await this.ws.closeTerminalSession(sessionId);
|
|
}
|
|
}
|
|
|
|
export class HttpAgentGateway implements AgentGateway {
|
|
constructor(
|
|
private readonly http: HttpInvoker,
|
|
private readonly ws: WsLiveClient,
|
|
) {}
|
|
|
|
listAgents(projectId: string): Promise<Agent[]> {
|
|
return this.http.invoke<Agent[]>("list_agents", { projectId });
|
|
}
|
|
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
|
return this.http.invoke<LiveAgent[]>("list_live_agents", { projectId });
|
|
}
|
|
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
|
return this.http
|
|
.invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId })
|
|
.then((res) => res.resumable);
|
|
}
|
|
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
|
|
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
|
|
}
|
|
stopLiveAgent(projectId: string, agentId: string, reason?: string): Promise<StoppedLiveAgent> {
|
|
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", {
|
|
request: { projectId, agentId, ...(reason ? { reason } : {}) },
|
|
});
|
|
}
|
|
restartCellAgentRuntime(
|
|
input: RestartCellAgentRuntimeInput,
|
|
): Promise<RestartCellAgentRuntimeScheduled> {
|
|
return this.http.invoke<RestartCellAgentRuntimeScheduled>(
|
|
"restart_cell_agent_runtime",
|
|
{ request: input },
|
|
);
|
|
}
|
|
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
|
return this.http.invoke<Agent>("create_agent", {
|
|
request: {
|
|
projectId,
|
|
name: input.name,
|
|
profileId: input.profileId,
|
|
initialContent: input.initialContent ?? null,
|
|
},
|
|
});
|
|
}
|
|
changeAgentProfile(
|
|
projectId: string,
|
|
agentId: string,
|
|
profileId: string,
|
|
rows: number,
|
|
cols: number,
|
|
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
|
|
return this.http.invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>("change_agent_profile", {
|
|
request: { projectId, agentId, profileId, rows, cols },
|
|
});
|
|
}
|
|
updateAgentEffort(
|
|
projectId: string,
|
|
agentId: string,
|
|
effort: EffortSelection | null,
|
|
): Promise<Agent> {
|
|
return this.http.invoke<Agent>("update_agent_effort", {
|
|
request: { projectId, agentId, effort },
|
|
});
|
|
}
|
|
updateAgentContactable(
|
|
projectId: string,
|
|
agentId: string,
|
|
contactable: boolean,
|
|
): Promise<Agent> {
|
|
return this.http.invoke<Agent>("update_agent_contactable", {
|
|
request: { projectId, agentId, contactable },
|
|
});
|
|
}
|
|
updateProjectOrchestrator(
|
|
projectId: string,
|
|
agentId: string,
|
|
): Promise<{ orchestratorAgentId: string }> {
|
|
return this.http.invoke<{ orchestratorAgentId: string }>(
|
|
"update_project_orchestrator",
|
|
{ request: { projectId, agentId } },
|
|
);
|
|
}
|
|
readContext(projectId: string, agentId: string): Promise<string> {
|
|
return this.http
|
|
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
|
|
.then((res) => res.content);
|
|
}
|
|
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
|
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
|
}
|
|
async deleteAgent(projectId: string, agentId: string): Promise<void> {
|
|
await this.http.invoke("delete_agent", { projectId, agentId });
|
|
}
|
|
inspectConversation(
|
|
projectId: string,
|
|
agentId: string,
|
|
conversationId: string,
|
|
): Promise<ConversationDetails> {
|
|
return this.http.invoke<ConversationDetails>("inspect_conversation", {
|
|
request: { projectId, agentId, conversationId },
|
|
});
|
|
}
|
|
|
|
readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
|
|
return this.http.invoke<ChatDraftSnapshot>("read_chat_draft", { request: scope });
|
|
}
|
|
|
|
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
|
|
await this.http.invoke("save_chat_draft", { request: { ...scope, text } });
|
|
}
|
|
|
|
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
|
|
await this.http.invoke("clear_chat_draft", { request: scope });
|
|
}
|
|
|
|
async launchAgent(
|
|
projectId: string,
|
|
agentId: string,
|
|
options: OpenTerminalOptions,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<TerminalHandle> {
|
|
// B6: a raw-CLI agent streams over the same PTY channel as a terminal. The
|
|
// WS client tracks the session (reconnection/replay/status parity with F3);
|
|
// the unified `terminal.attached` ack yields the sessionId + the conversation
|
|
// id minted by this launch. Structured agents are refused server-side with
|
|
// `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error,
|
|
// no crash). A fresh launch has empty scrollback (no repaint on open).
|
|
const res = await this.ws.launchAgent({
|
|
projectId,
|
|
agentId,
|
|
nodeId: options.nodeId ?? null,
|
|
rows: options.rows,
|
|
cols: options.cols,
|
|
conversationId: options.conversationId ?? null,
|
|
freshStart: options.freshStart ?? false,
|
|
onData,
|
|
});
|
|
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
// Agent sessions re-attach through the same `terminal.attach` mechanics as a
|
|
// terminal — no relaunch, bounded scrollback replayed for the view to repaint.
|
|
const res = await this.ws.attachTerminal({ sessionId, onData });
|
|
return {
|
|
handle: makeWsTerminalHandle(res.sessionId, this.ws),
|
|
scrollback: res.scrollback,
|
|
};
|
|
}
|
|
|
|
async launchAgentChat(
|
|
projectId: string,
|
|
agentId: string,
|
|
options: OpenTerminalOptions,
|
|
): Promise<AgentChatHandle> {
|
|
const ack = await this.ws.send("agent.launch", {
|
|
projectId,
|
|
agentId,
|
|
nodeId: options.nodeId ?? null,
|
|
rows: options.rows,
|
|
cols: options.cols,
|
|
cellKind: "chat",
|
|
conversationId: options.conversationId ?? null,
|
|
freshStart: options.freshStart ?? false,
|
|
});
|
|
if (ack.kind !== "chat.attached") {
|
|
throw {
|
|
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
|
|
message: `custom CLI launch returned ${ack.kind}; expected chat.attached`,
|
|
};
|
|
}
|
|
const payload = ack.payload as unknown as ChatAttachedPayload;
|
|
if (payload.cellKind && payload.cellKind !== "chat") {
|
|
throw {
|
|
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
|
|
message: `custom CLI launch returned cellKind=${payload.cellKind}; expected chat`,
|
|
};
|
|
}
|
|
const sessionId = chatSessionId(payload);
|
|
return {
|
|
sessionId,
|
|
cellKind: "chat",
|
|
...(payload.assignedConversationId
|
|
? { assignedConversationId: payload.assignedConversationId }
|
|
: {}),
|
|
...(payload.transcriptUnavailableReason
|
|
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async reattachAgentChat(
|
|
sessionId: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
options: { projectId?: string; agentId?: string } = {},
|
|
): Promise<ReattachAgentChatResult> {
|
|
const ack = await this.ws.send("chat.attach", { sessionId, ...options });
|
|
if (ack.kind !== "chat.attached") {
|
|
throw {
|
|
code: "CHAT_ATTACH_FAILED",
|
|
message: `chat attach returned ${ack.kind}; expected chat.attached`,
|
|
};
|
|
}
|
|
const payload = ack.payload as unknown as ChatAttachedPayload;
|
|
const attachedSessionId = chatSessionId(payload);
|
|
this.ws.setChatSink(attachedSessionId, onChunk);
|
|
return {
|
|
sessionId: attachedSessionId,
|
|
...(payload.conversationId ?? payload.assignedConversationId ?? payload.engineSessionId
|
|
? {
|
|
conversationId:
|
|
payload.conversationId ??
|
|
payload.assignedConversationId ??
|
|
payload.engineSessionId,
|
|
}
|
|
: {}),
|
|
scrollback: payload.scrollback ?? [],
|
|
...(payload.transcriptUnavailableReason
|
|
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async detachAgentChat(sessionId: string): Promise<void> {
|
|
this.ws.removeChatSink(sessionId);
|
|
await this.ws.sendFireAndForget("chat.detach", { sessionId });
|
|
}
|
|
|
|
async sendAgentChat(
|
|
sessionId: string,
|
|
prompt: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
options: SendAgentChatOptions = {},
|
|
): Promise<void> {
|
|
this.ws.setChatSink(sessionId, onChunk);
|
|
const ack = await this.ws.send("chat.send", {
|
|
sessionId,
|
|
prompt,
|
|
attachments: options.attachments ?? [],
|
|
});
|
|
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
|
|
if (payload?.chunk) onChunk(payload.chunk);
|
|
}
|
|
|
|
listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
|
|
const request = prefix === undefined ? {} : { prefix };
|
|
return this.http
|
|
.invoke<{ commands: SlashCommand[] }>("list_slash_commands", { request })
|
|
.then((res) => res.commands);
|
|
}
|
|
|
|
executeSlashCommand(
|
|
name: string,
|
|
options: { sessionId?: string | null; arguments?: unknown[] } = {},
|
|
): Promise<ExecuteSlashCommandResult> {
|
|
return this.http.invoke<ExecuteSlashCommandResult>("execute_slash_command", {
|
|
request: {
|
|
name,
|
|
sessionId: options.sessionId ?? null,
|
|
arguments: options.arguments ?? [],
|
|
},
|
|
});
|
|
}
|
|
|
|
async closeAgentChat(sessionId: string): Promise<void> {
|
|
this.ws.removeChatSink(sessionId);
|
|
await this.http.invoke("close_agent_session", { sessionId });
|
|
}
|
|
|
|
async cancelAgentChat(sessionId: string): Promise<void> {
|
|
await this.http.invoke("cancel_agent_chat", { sessionId });
|
|
}
|
|
}
|
|
|
|
export class HttpTicketGateway implements TicketGateway {
|
|
constructor(
|
|
private readonly http: HttpInvoker,
|
|
private readonly ws: WsLiveClient,
|
|
) {}
|
|
|
|
private normalizeTicketActor(actor: unknown): Ticket["createdBy"] {
|
|
if (!actor || typeof actor !== "object") return { kind: "user" };
|
|
const rec = actor as Record<string, unknown>;
|
|
if (rec.kind === "user" || rec.kind === "system") return { kind: rec.kind };
|
|
if (rec.kind === "agent" && typeof rec.agentId === "string" && rec.agentId) {
|
|
return { kind: "agent", agentId: rec.agentId };
|
|
}
|
|
return { kind: "user" };
|
|
}
|
|
|
|
private normalizeTicket(ticket: Ticket): Ticket {
|
|
return {
|
|
...ticket,
|
|
links: Array.isArray(ticket.links) ? ticket.links : [],
|
|
assignedAgentIds: Array.isArray(ticket.assignedAgentIds)
|
|
? ticket.assignedAgentIds
|
|
: [],
|
|
attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [],
|
|
createdBy: this.normalizeTicketActor(ticket.createdBy),
|
|
updatedBy: this.normalizeTicketActor(ticket.updatedBy),
|
|
};
|
|
}
|
|
|
|
private normalizeTicketList(list: TicketList): TicketList {
|
|
return {
|
|
...list,
|
|
items: Array.isArray(list.items)
|
|
? list.items.map((item) => ({
|
|
...item,
|
|
assignedAgentIds: Array.isArray(item.assignedAgentIds)
|
|
? item.assignedAgentIds
|
|
: [],
|
|
createdBy: this.normalizeTicketActor(item.createdBy),
|
|
}))
|
|
: [],
|
|
};
|
|
}
|
|
|
|
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_create", { request: { projectId, ...input } })
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } })
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
|
const { statuses, priorities, ...rest } = query ?? {};
|
|
return this.http
|
|
.invoke<TicketList>("ticket_list", {
|
|
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
|
|
})
|
|
.then((list) => this.normalizeTicketList(list));
|
|
}
|
|
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } })
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
async delete(projectId: string, ref: string): Promise<void> {
|
|
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
|
}
|
|
bulkUpdateStatus(projectId: string, refs: string[], status: Ticket["status"]): Promise<TicketBulkResult> {
|
|
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_status", {
|
|
request: { projectId, refs, status },
|
|
});
|
|
}
|
|
bulkUpdatePriority(projectId: string, refs: string[], priority: Ticket["priority"]): Promise<TicketBulkResult> {
|
|
return this.http.invoke<TicketBulkResult>("ticket_bulk_update_priority", {
|
|
request: { projectId, refs, priority },
|
|
});
|
|
}
|
|
bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
|
|
return this.http.invoke<TicketBulkResult>("ticket_bulk_delete", {
|
|
request: { projectId, refs },
|
|
});
|
|
}
|
|
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
|
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
|
}
|
|
updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_update_carnet", {
|
|
request: { projectId, ref, carnet, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
addAttachment(
|
|
projectId: string,
|
|
ref: string,
|
|
path: string,
|
|
expectedVersion: number,
|
|
mime?: string | null,
|
|
): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_attachment_add", {
|
|
request: { projectId, ref, path, expectedVersion, mime },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
readAttachment(
|
|
projectId: string,
|
|
ref: string,
|
|
attachmentId: string,
|
|
): Promise<TicketAttachmentContent> {
|
|
return this.http.invoke<TicketAttachmentContent>("ticket_attachment_read", {
|
|
request: { projectId, ref, attachmentId },
|
|
});
|
|
}
|
|
markAttachmentSummarized(
|
|
projectId: string,
|
|
ref: string,
|
|
attachmentId: string,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_attachment_mark_summarized", {
|
|
request: { projectId, ref, attachmentId, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
link(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
kind: TicketLinkKind,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_link", {
|
|
request: { projectId, ref, targetRef, kind, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
unlink(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
expectedVersion: number,
|
|
kind?: TicketLinkKind,
|
|
): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_unlink", {
|
|
request: { projectId, ref, targetRef, expectedVersion, kind },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
assign(
|
|
projectId: string,
|
|
ref: string,
|
|
agentId: string,
|
|
assigned: boolean,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_assign", {
|
|
request: { projectId, ref, agentId, assigned, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
async listSprints(projectId: string): Promise<Sprint[]> {
|
|
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } });
|
|
return list.items;
|
|
}
|
|
setTicketSprint(
|
|
projectId: string,
|
|
ref: string,
|
|
sprintId: string | null,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
if (sprintId === null) {
|
|
return this.http
|
|
.invoke<Ticket>("ticket_unassign_sprint", {
|
|
request: { projectId, ref, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
return this.http
|
|
.invoke<Ticket>("ticket_assign_sprint", {
|
|
request: { projectId, ref, sprintId, expectedVersion },
|
|
})
|
|
.then((ticket) => this.normalizeTicket(ticket));
|
|
}
|
|
createSprint(projectId: string, name: string): Promise<Sprint> {
|
|
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });
|
|
}
|
|
renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise<Sprint> {
|
|
return this.http.invoke<Sprint>("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } });
|
|
}
|
|
async reorderSprints(projectId: string, orderedIds: string[]): Promise<Sprint[]> {
|
|
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", {
|
|
request: { projectId, orderedIds },
|
|
});
|
|
return list.items;
|
|
}
|
|
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
|
await this.http.invoke<void>("sprint_delete", { request: { projectId, sprintId } });
|
|
}
|
|
openTicketChat(projectId: string, issueRef: string, profileId: string): Promise<TicketChat> {
|
|
return this.http.invoke<TicketChat>("open_ticket_chat", { request: { projectId, issueRef, profileId } });
|
|
}
|
|
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
|
await this.http.invoke<void>("close_ticket_chat", { request: { projectId, issueRef } });
|
|
}
|
|
|
|
async sendTicketChat(
|
|
sessionId: string,
|
|
message: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
): Promise<void> {
|
|
// Structured assistant reply stream over WS (`chat.*`, B0). Skeleton: route
|
|
// this session's `chat.output` chunks to `onChunk` until the `final` chunk.
|
|
// TODO(F3/B5): the WsLiveClient currently routes PTY output + domain events;
|
|
// a per-session chat sink is added alongside the terminal sink in F3.
|
|
const ack = await this.ws.send("chat.send", { sessionId, prompt: message });
|
|
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
|
|
if (payload && payload.chunk) onChunk(payload.chunk as ReplyChunk);
|
|
}
|
|
}
|