Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@octokit/rest": "^22.0.1",
"@t3-oss/env-core": "^0.13.10",
"@upstash/redis": "^1.37.0",
"@vercel/functions": "^3.5.0",
"@vercel/sandbox": "^1.8.1",
"chat": "^4.20.2",
"h3": "^1",
Expand Down
29 changes: 19 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { createVCS } from "./create-vcs.js";
import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js";
import type { VCSAdapter } from "../adapters/vcs/types.js";
import type { MessagingAdapter } from "../adapters/messaging/types.js";
import type { RunRegistryAdapter } from "../adapters/run-registry/types.js";
import type {
RunRegistryAdapter,
ThreadStore,
} from "../adapters/run-registry/types.js";

export interface Adapters {
issueTracker: IssueTrackerAdapter;
vcs: VCSAdapter;
messaging: MessagingAdapter;
runRegistry: RunRegistryAdapter;
runRegistry: RunRegistryAdapter & ThreadStore;
}

export function createAdapters(): Adapters {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/cancel-run.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { getRun } from "workflow/api";
import { logger } from "./logger.js";
import type { RunRegistryAdapter } from "../adapters/run-registry/types.js";
import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js";
import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js";

/**
* Cancel a workflow run and unregister it from the registry.
* Idempotent: safe to call multiple times for the same ticket.
* Returns true if cancel succeeded, false if it errored (still unregisters).
*
* If `issueTracker` and `targetColumn` are provided, also transitions the
* ticket out of its current column. Without this, the cron sees the ticket
* still in COLUMN_AI on the next tick and re-dispatches a fresh run.
*/
export async function cancelRun(
ticketKey: string,
runId: string,
runRegistry: RunRegistryAdapter,
issueTracker?: IssueTrackerAdapter,
targetColumn?: string,
): Promise<boolean> {
let cancelled = false;
try {
Expand All @@ -32,5 +39,17 @@ export async function cancelRun(
const sandboxId = await runRegistry.getSandboxId(ticketKey).catch(() => null);
await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {});
await runRegistry.unregister(ticketKey).catch(() => {});

if (issueTracker && targetColumn) {
try {
await issueTracker.moveTicket(ticketKey, targetColumn);
} catch (err) {
logger.warn(
{ ticketKey, targetColumn, error: (err as Error).message },
"cancel_run_move_ticket_failed",
);
}
}

return cancelled;
}
6 changes: 6 additions & 0 deletions src/lib/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ function makeAdapters(
isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false),
listAllFailed: vi.fn().mockResolvedValue([]),
clearFailedMark: vi.fn().mockResolvedValue(undefined),
getParent: vi.fn().mockResolvedValue(null),
setParent: vi.fn().mockResolvedValue(undefined),
clearParent: vi.fn().mockResolvedValue(undefined),
},
};
}
Expand Down Expand Up @@ -451,6 +454,9 @@ describe("failed-ticket safeguard full loop", () => {
clearFailedMark: vi.fn().mockImplementation(async (key: string) => {
failedMarkers.delete(key);
}),
getParent: vi.fn().mockResolvedValue(null),
setParent: vi.fn().mockResolvedValue(undefined),
clearParent: vi.fn().mockResolvedValue(undefined),
};

const adapters = makeAdapters();
Expand Down
10 changes: 9 additions & 1 deletion src/lib/slack/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type ParsedCommand =
| { kind: "list" }
| { kind: "status"; ticketKey: string }
| { kind: "cancel"; ticketKey: string }
| { kind: "inspect"; ticketKey: string | null }
| { kind: "reset"; ticketKey: string }
| { kind: "help" }
| { kind: "unknown"; raw: string };

Expand All @@ -18,12 +20,18 @@ export function parseCommand(text: string): ParsedCommand {
if (verb === "help") return { kind: "help" };
if (verb === "list") return { kind: "list" };

if (verb === "status" || verb === "cancel") {
if (verb === "status" || verb === "cancel" || verb === "reset") {
if (arg && TICKET_KEY_RE.test(arg)) {
return { kind: verb, ticketKey: arg };
}
return { kind: "unknown", raw: trimmed };
}

if (verb === "inspect") {
if (!arg) return { kind: "inspect", ticketKey: null };
if (TICKET_KEY_RE.test(arg)) return { kind: "inspect", ticketKey: arg };
return { kind: "unknown", raw: trimmed };
}

return { kind: "unknown", raw: trimmed };
}
58 changes: 57 additions & 1 deletion src/lib/slack/format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { FailedTicketMeta } from "../../adapters/run-registry/types.js";

export interface RunRow {
ticketKey: string;
runId: string;
Expand All @@ -8,6 +10,14 @@ export interface RunStatusSnapshot {
sandboxId: string | null;
}

export interface InspectTicketSnapshot {
runId: string | null;
sandboxId: string | null;
entryCreatedAt: number | null;
threadParent: string | null;
isFailed: boolean;
}

export function formatRunList(rows: RunRow[], jiraBaseUrl: string): string {
if (rows.length === 0) return "No active workflows.";
return rows
Expand All @@ -26,11 +36,57 @@ export function formatRunStatus(
return `${link}: runId \`${snapshot.runId}\`, sandbox: ${sandbox}`;
}

export function formatInspectTicket(
ticketKey: string,
jiraBaseUrl: string,
snap: InspectTicketSnapshot,
): string {
const link = jiraLink(ticketKey, jiraBaseUrl);
const lines: string[] = [`*Inspect ${link}*`];
lines.push(`• runId: ${snap.runId ? `\`${snap.runId}\`` : "_none_"}`);
lines.push(`• sandboxId: ${snap.sandboxId ? `\`${snap.sandboxId}\`` : "_none_"}`);
lines.push(
`• entryCreatedAt: ${snap.entryCreatedAt ? new Date(snap.entryCreatedAt).toISOString() : "_none_"}`,
);
lines.push(`• threadParent: ${snap.threadParent ? `\`${snap.threadParent}\`` : "_none_"}`);
lines.push(`• failed: ${snap.isFailed ? "yes" : "no"}`);
return lines.join("\n");
}

export function formatInspectAll(
active: RunRow[],
failed: Array<{ ticketKey: string; meta: FailedTicketMeta }>,
jiraBaseUrl: string,
): string {
const lines: string[] = ["*Redis snapshot*"];
lines.push(`*Active runs (${active.length}):*`);
if (active.length === 0) {
lines.push("• _none_");
} else {
for (const { ticketKey, runId } of active) {
lines.push(`• ${jiraLink(ticketKey, jiraBaseUrl)} — \`${runId}\``);
}
}
lines.push(`*Failed markers (${failed.length}):*`);
if (failed.length === 0) {
lines.push("• _none_");
} else {
for (const { ticketKey, meta } of failed) {
lines.push(
`• ${jiraLink(ticketKey, jiraBaseUrl)} — \`${meta.runId}\` (${meta.failedAt})`,
);
}
}
return lines.join("\n");
}

export const HELP_TEXT = [
"*Blazebot commands*",
"• `/ai-workflow list` — show every tracked workflow",
"• `/ai-workflow status <KEY>` — show the run + sandbox tied to a ticket",
"• `/ai-workflow cancel <KEY>` — cancel the workflow run for a ticket",
"• `/ai-workflow cancel <KEY>` — cancel the workflow run + move ticket to backlog",
"• `/ai-workflow inspect [KEY]` — dump Redis state for a ticket, or summary across all hashes",
"• `/ai-workflow reset <KEY>` — clear Redis entries for a ticket (does NOT cancel the run)",
].join("\n");

function jiraLink(ticketKey: string, jiraBaseUrl: string): string {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/slack/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ describe("handleCancel", () => {
cancelRunFn,
stopSandboxes,
);
expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", registry);
expect(cancelRunFn).toHaveBeenCalledWith(
"AWT-1",
"run_a",
registry,
undefined,
undefined,
);
expect(out).toContain("Cancelled");
expect(out).toContain("AWT-1");
});
Expand Down
Loading
Loading