Keep every hook call's response, searchable by the user and the bot
A hook call used to record only how it was taken; its run's answer went to the chat alone and was lost to the call once the job was pruned. Now `bot_hook_events` keeps `response`, `response_message_id` and `responded_at`, written on every call a run took in when that run ends (completed, failed, lost, stopped or cancelled) by `recordHookCallResponse` in the job helpers, and calls turn done or failed. The Calls view searches what calls sent and what runs answered, filters by status and pages; Show all loads one call whole. Bots get `search_hook_calls` and `read_hook_call` for their own hooks (never in a hook call's own run), with payloads always marked as outside data. Calls are kept for the app setting `hook_events_keep_days` (Settings > General, default now 30 days). The caller still only gets 202 Queued. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { HookEventsKeepDaysSettingKey } from "@/src/dict/bot-hook-dict";
|
||||
import type { HookCallRetentionOverview } from "@/src/types";
|
||||
import readStepLimit from "@/src/utils/read-step-limit";
|
||||
import grabAppSetting from "./grab-app-setting";
|
||||
|
||||
/**
|
||||
* How many days hook calls (and their responses) are kept: the install's
|
||||
* own number (`hook_events_keep_days`, Settings > General), else
|
||||
* `AppData.BotHookEventsKeepDays`.
|
||||
*/
|
||||
export async function grabHookEventsKeepDays() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: HookEventsKeepDaysSettingKey }),
|
||||
fallback: AppData.BotHookEventsKeepDays,
|
||||
max: AppData.BotHookEventsKeepDaysMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The number with its default and ceiling, for the settings page.
|
||||
*/
|
||||
export default async function grabHookCallRetentionOverview() {
|
||||
const overview: HookCallRetentionOverview = {
|
||||
keep_days: await grabHookEventsKeepDays(),
|
||||
default_days: AppData.BotHookEventsKeepDays,
|
||||
max: AppData.BotHookEventsKeepDaysMax,
|
||||
};
|
||||
|
||||
return overview;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotHookCall } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
event_id: number | string;
|
||||
/**
|
||||
* Only a call to this bot's hooks.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One call to one of the user's hooks, whole: what it sent and what its
|
||||
* run answered. Throws when it isn't theirs (or that bot's).
|
||||
*/
|
||||
export default async function grabBotHookEvent({ user_id, event_id, bot_id }: Params) {
|
||||
const res = await BunSQLite.sql<BotHookCall>({
|
||||
sql: `SELECT e.*, h.title AS hook_title, h.bot_id, h.workspace_id
|
||||
FROM bot_hook_events e JOIN bot_hooks h ON h.id = e.hook_id
|
||||
WHERE e.id = ? AND h.user_id = ? AND (? IS NULL OR h.bot_id = ?)`,
|
||||
values: [Number(event_id), user_id, bot_id ? Number(bot_id) : null, bot_id ? Number(bot_id) : null],
|
||||
});
|
||||
|
||||
const call = res.payload?.[0];
|
||||
|
||||
if (!call?.id) {
|
||||
throw new AppError(`Hook call #${event_id} not found.`);
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
@@ -1,26 +1,14 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS } from "@/db/types/db";
|
||||
import grabUserBotHook from "./grab-user-bot-hook";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
hook_id: number | string;
|
||||
limit?: number;
|
||||
};
|
||||
import type { BotHookCall } from "@/src/types";
|
||||
import searchHookCallsSql, { type SearchHookCallsParams } from "./search-hook-calls-sql";
|
||||
|
||||
/**
|
||||
* One of the user's hooks' latest calls, newest first, each payload cut
|
||||
* to a preview in SQL.
|
||||
* Calls to the user's hooks, newest first: all of them, one hook's or one
|
||||
* bot's, found by words in what they sent or what their run answered.
|
||||
* Each payload and response is cut to a preview in SQL.
|
||||
*/
|
||||
export default async function grabBotHookEvents({ user_id, hook_id, limit = 20 }: Params) {
|
||||
const hook = await grabUserBotHook({ user_id, hook_id });
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS>({
|
||||
sql: `SELECT id, created_at, hook_id, job_id, substr(payload, 1, 400) AS payload, content_type,
|
||||
source_ip, idempotency_key, status, error
|
||||
FROM bot_hook_events WHERE hook_id = ? ORDER BY id DESC LIMIT ?`,
|
||||
values: [hook.id!, Math.min(Math.max(limit, 1), 100)],
|
||||
});
|
||||
export default async function grabBotHookEvents(params: SearchHookCallsParams) {
|
||||
const res = await BunSQLite.sql<BotHookCall>(searchHookCallsSql(params));
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import recordHookCallResponseSql from "./record-hook-call-response-sql";
|
||||
|
||||
/**
|
||||
* A run's response lands on every call it took in, and on nothing else.
|
||||
* Run against real SQLite, since a wrong condition would either miss the
|
||||
* calls merged into the run or write over calls already answered.
|
||||
*/
|
||||
const db = new Database(":memory:");
|
||||
|
||||
db.run(`CREATE TABLE bot_hook_events (id INTEGER PRIMARY KEY, hook_id INTEGER, job_id INTEGER, status TEXT,
|
||||
response TEXT, response_message_id INTEGER, responded_at INTEGER, updated_at INTEGER)`);
|
||||
db.run(`INSERT INTO bot_hook_events (id, hook_id, job_id, status) VALUES
|
||||
(1, 1, 10, 'queued'),
|
||||
(2, 1, 10, 'merged'),
|
||||
(3, 1, 11, 'queued'),
|
||||
(4, 2, 10, 'queued'),
|
||||
(5, 1, 10, 'rejected'),
|
||||
(6, 1, 12, 'done')`);
|
||||
|
||||
function record(params: Parameters<typeof recordHookCallResponseSql>[0]) {
|
||||
const { sql, values } = recordHookCallResponseSql(params);
|
||||
|
||||
return (db.query(sql).all(...values) as { id: number }[]).map((row) => row.id);
|
||||
}
|
||||
|
||||
function row(id: number) {
|
||||
return db.query(`SELECT * FROM bot_hook_events WHERE id = ?`).get(id) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("recordHookCallResponseSql", () => {
|
||||
test("answers every call the run took in", () => {
|
||||
expect(record({ hook_id: 1, job_id: 10, status: "done", response: "Done it", message_id: 7, now: 42 })).toEqual([1, 2]);
|
||||
expect(row(1)).toMatchObject({ status: "done", response: "Done it", response_message_id: 7, responded_at: 42 });
|
||||
expect(row(2)).toMatchObject({ status: "done", response: "Done it" });
|
||||
});
|
||||
|
||||
test("leaves other runs, other hooks and settled calls alone", () => {
|
||||
expect(row(3)).toMatchObject({ status: "queued", response: null });
|
||||
expect(row(4)).toMatchObject({ status: "queued", response: null });
|
||||
expect(row(5)).toMatchObject({ status: "rejected", response: null });
|
||||
expect(record({ hook_id: 1, job_id: 12, status: "failed", response: "Nope", now: 43 })).toEqual([]);
|
||||
expect(row(6)).toMatchObject({ status: "done", response: null });
|
||||
});
|
||||
|
||||
test("a failed run keeps why", () => {
|
||||
expect(record({ hook_id: 1, job_id: 11, status: "failed", response: "Timed out", now: 44 })).toEqual([3]);
|
||||
expect(row(3)).toMatchObject({ status: "failed", response: "Timed out", response_message_id: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
type Params = {
|
||||
hook_id: number;
|
||||
/**
|
||||
* The run that ended.
|
||||
*/
|
||||
job_id: number;
|
||||
status: "done" | "failed";
|
||||
response: string;
|
||||
message_id?: number | null;
|
||||
now: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a run's end onto every call it took in (the one that queued it
|
||||
* and any merged into it): answered or failed, with its response. Only
|
||||
* calls still waiting on it change, so a call already answered never is
|
||||
* written over.
|
||||
*/
|
||||
export default function recordHookCallResponseSql({ hook_id, job_id, status, response, message_id, now }: Params) {
|
||||
return {
|
||||
sql: `UPDATE bot_hook_events SET status = ?, response = ?, response_message_id = ?, responded_at = ?, updated_at = ?
|
||||
WHERE hook_id = ? AND job_id = ? AND status IN ('queued', 'merged') RETURNING id`,
|
||||
values: [status, response, message_id ?? null, now, now, hook_id, job_id] as (string | number | null)[],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import recordHookCallResponseSql from "./record-hook-call-response-sql";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The job that ended. Only a hook call's run (`params.hook_id`) has
|
||||
* calls to answer; any other job is left alone.
|
||||
*/
|
||||
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
||||
status: "done" | "failed";
|
||||
/**
|
||||
* What the run answered, or why it failed. Cut to
|
||||
* `AppData.BotHookMaxPayloadBytes`.
|
||||
*/
|
||||
response: string;
|
||||
/**
|
||||
* The chat message the run's end posted, if it posted one.
|
||||
*/
|
||||
message_id?: number | string | null;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps a hook run's end on the calls it took in, so they can be found
|
||||
* later by what was sent or answered, after the job itself is pruned.
|
||||
* Called wherever a job ends (completed, failed for good, lost, stopped,
|
||||
* cancelled). Never throws: the job has ended either way.
|
||||
*/
|
||||
export default async function recordHookCallResponse({ job, status, response, message_id, now = Date.now() }: Params) {
|
||||
const hook_id = Number(parseJobJson({ text: job.params })?.hook_id) || 0;
|
||||
|
||||
if (!hook_id || !job.id) return;
|
||||
|
||||
await BunSQLite.sql(
|
||||
recordHookCallResponseSql({
|
||||
hook_id,
|
||||
job_id: Number(job.id),
|
||||
status,
|
||||
response: response.slice(0, AppData.BotHookMaxPayloadBytes),
|
||||
message_id: Number(message_id) || null,
|
||||
now,
|
||||
}),
|
||||
).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import searchHookCallsSql, { type SearchHookCallsParams } from "./search-hook-calls-sql";
|
||||
|
||||
/**
|
||||
* Searching hook calls only ever reads the user's own hooks, and finds a
|
||||
* call by what it sent or what its run answered. Run against real SQLite.
|
||||
*/
|
||||
const db = new Database(":memory:");
|
||||
|
||||
db.run(`CREATE TABLE bot_hooks (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER, workspace_id INTEGER, title TEXT)`);
|
||||
db.run(`CREATE TABLE bot_hook_events (id INTEGER PRIMARY KEY, created_at INTEGER, hook_id INTEGER, job_id INTEGER,
|
||||
payload TEXT, content_type TEXT, source_ip TEXT, idempotency_key TEXT, status TEXT, error TEXT, response TEXT,
|
||||
response_message_id INTEGER, responded_at INTEGER)`);
|
||||
db.run(`INSERT INTO bot_hooks VALUES (1, 1, 10, NULL, 'Orders'), (2, 1, 11, NULL, 'Builds'), (3, 2, 10, NULL, 'Theirs')`);
|
||||
db.run(`INSERT INTO bot_hook_events (id, hook_id, payload, status, response, error) VALUES
|
||||
(1, 1, '{"order":"A-100"}', 'done', 'Shipped A-100', NULL),
|
||||
(2, 1, '{"order":"B-200"}', 'failed', 'Timed out', NULL),
|
||||
(3, 2, '{"build":"green"}', 'done', 'All 100% passing', NULL),
|
||||
(4, 3, '{"order":"A-100"}', 'done', 'Not yours', NULL),
|
||||
(5, 1, 'bad', 'rejected', NULL, 'Bad or missing signature')`);
|
||||
|
||||
function ids(params: Partial<SearchHookCallsParams>) {
|
||||
const { sql, values } = searchHookCallsSql({ user_id: 1, ...params });
|
||||
|
||||
return (db.query(sql).all(...values) as { id: number }[]).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("searchHookCallsSql", () => {
|
||||
test("lists only the user's calls, newest first", () => {
|
||||
expect(ids({})).toEqual([5, 3, 2, 1]);
|
||||
expect(ids({ user_id: 2 })).toEqual([4]);
|
||||
});
|
||||
|
||||
test("finds a call by its payload, response or error", () => {
|
||||
expect(ids({ query: "A-100" })).toEqual([1]);
|
||||
expect(ids({ query: "timed OUT" })).toEqual([2]);
|
||||
expect(ids({ query: "signature" })).toEqual([5]);
|
||||
});
|
||||
|
||||
test("matches % and _ literally", () => {
|
||||
expect(ids({ query: "100%" })).toEqual([3]);
|
||||
expect(ids({ query: "_" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("filters by hook, bot and status, and pages", () => {
|
||||
expect(ids({ hook_id: 2 })).toEqual([3]);
|
||||
expect(ids({ bot_id: 10 })).toEqual([5, 2, 1]);
|
||||
expect(ids({ status: "done" })).toEqual([3, 1]);
|
||||
expect(ids({ before_id: 3, limit: 1 })).toEqual([2]);
|
||||
});
|
||||
|
||||
test("cuts payloads and responses to previews", () => {
|
||||
const { sql, values } = searchHookCallsSql({ user_id: 1, hook_id: 2, preview_chars: 3 });
|
||||
const [row] = db.query(sql).all(...values) as { payload: string; response: string; hook_title: string }[];
|
||||
|
||||
expect(row).toMatchObject({ payload: '{"b', response: "All", hook_title: "Builds" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotHookEventStatus } from "@/src/dict/bot-hook-dict";
|
||||
|
||||
export type SearchHookCallsParams = {
|
||||
/**
|
||||
* Whose hooks: only calls to hooks this user owns are ever read.
|
||||
*/
|
||||
user_id: number | string;
|
||||
hook_id?: number | string | null;
|
||||
bot_id?: number | string | null;
|
||||
/**
|
||||
* Words to find in what a call sent or what its run answered.
|
||||
*/
|
||||
query?: string | null;
|
||||
status?: BotHookEventStatus | null;
|
||||
/**
|
||||
* Only calls older than this one (paging).
|
||||
*/
|
||||
before_id?: number | string | null;
|
||||
limit?: number;
|
||||
/**
|
||||
* How much of each payload and response comes back.
|
||||
*/
|
||||
preview_chars?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds calls to the user's hooks, newest first, with each one's hook name
|
||||
* and bot. Payloads and responses are cut in SQL, so a list never carries
|
||||
* whole bodies. `%` and `_` in the query match only themselves.
|
||||
*/
|
||||
export default function searchHookCallsSql({
|
||||
user_id,
|
||||
hook_id,
|
||||
bot_id,
|
||||
query,
|
||||
status,
|
||||
before_id,
|
||||
limit = 20,
|
||||
preview_chars = AppData.BotHookCallPreviewChars,
|
||||
}: SearchHookCallsParams) {
|
||||
const where = [`h.user_id = ?`];
|
||||
const values: (string | number)[] = [preview_chars, preview_chars, user_id];
|
||||
|
||||
if (hook_id) {
|
||||
where.push(`e.hook_id = ?`);
|
||||
values.push(Number(hook_id));
|
||||
}
|
||||
|
||||
if (bot_id) {
|
||||
where.push(`h.bot_id = ?`);
|
||||
values.push(Number(bot_id));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.push(`e.status = ?`);
|
||||
values.push(status);
|
||||
}
|
||||
|
||||
if (before_id) {
|
||||
where.push(`e.id < ?`);
|
||||
values.push(Number(before_id));
|
||||
}
|
||||
|
||||
const search = query?.trim();
|
||||
|
||||
if (search) {
|
||||
const like = `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
|
||||
where.push(`(e.payload LIKE ? ESCAPE '\\' OR e.response LIKE ? ESCAPE '\\' OR e.error LIKE ? ESCAPE '\\')`);
|
||||
values.push(like, like, like);
|
||||
}
|
||||
|
||||
values.push(Math.min(Math.max(Math.floor(limit) || 20, 1), 100));
|
||||
|
||||
return {
|
||||
sql: `SELECT e.id, e.created_at, e.hook_id, e.job_id, substr(e.payload, 1, ?) AS payload, e.content_type,
|
||||
e.source_ip, e.idempotency_key, e.status, e.error, substr(e.response, 1, ?) AS response,
|
||||
e.response_message_id, e.responded_at, h.title AS hook_title, h.bot_id, h.workspace_id
|
||||
FROM bot_hook_events e JOIN bot_hooks h ON h.id = e.hook_id
|
||||
WHERE ${where.join(" AND ")}
|
||||
ORDER BY e.id DESC LIMIT ?`,
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import { BotJobCancelledError } from "@/src/dict/bot-queue-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import recordHookCallResponse from "../bot-hooks/record-hook-call-response";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
@@ -37,5 +38,7 @@ export default async function cancelBotJob({ user_id, job_id }: Params) {
|
||||
});
|
||||
}
|
||||
|
||||
await recordHookCallResponse({ job, status: "failed", response: BotJobCancelledError, now });
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import setBotFace from "../bots/set-bot-face";
|
||||
import countOpenBotJobs from "./count-open-bot-jobs";
|
||||
import grabLockedBotJob from "./grab-locked-bot-job";
|
||||
import relayJobToRequester from "./relay-job-to-requester";
|
||||
import recordHookCallResponse from "../bot-hooks/record-hook-call-response";
|
||||
import noteTrackerTask from "./note-tracker-task";
|
||||
import jobSessionId from "./job-session-id";
|
||||
import grabJobUserId from "./grab-job-user-id";
|
||||
@@ -46,7 +47,8 @@ type Params = {
|
||||
* after a task and idle after a reply; with others open, it shows the one
|
||||
* still running (a reply in another session mustn't hide a task's row).
|
||||
* Nobody else's face changes. A handed-over job's result also goes to the bot that handed
|
||||
* it over (`relay`), and a handed-over task left open says so (`noteTrackerTask`). Throws if this handler no longer holds the job.
|
||||
* it over (`relay`), and a handed-over task left open says so (`noteTrackerTask`). A hook
|
||||
* run's result is kept on the calls it answered (`recordHookCallResponse`). Throws if this handler no longer holds the job.
|
||||
*/
|
||||
export default async function completeBotJob({
|
||||
job_id,
|
||||
@@ -139,6 +141,8 @@ export default async function completeBotJob({
|
||||
|
||||
await noteTrackerTask({ job });
|
||||
|
||||
await recordHookCallResponse({ job, status: "done", response: result, message_id: inserted?.id });
|
||||
|
||||
const relay = silent ? null : await relayJobToRequester({ job, text: result });
|
||||
|
||||
return { message, bot, relay };
|
||||
|
||||
@@ -4,6 +4,7 @@ import insertBotMessage from "../bots/insert-bot-message";
|
||||
import setBotFace from "../bots/set-bot-face";
|
||||
import relayJobToRequester from "./relay-job-to-requester";
|
||||
import noteTrackerTask from "./note-tracker-task";
|
||||
import recordHookCallResponse from "../bot-hooks/record-hook-call-response";
|
||||
import jobSessionId from "./job-session-id";
|
||||
import grabJobUserId from "./grab-job-user-id";
|
||||
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
@@ -18,7 +19,8 @@ type Params = {
|
||||
* reported once), tells the user in the bot's conversation and sets the
|
||||
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
|
||||
* handed-over job's failure also goes to the bot that handed it over, and
|
||||
* blocks the handed-over task it worked on (`noteTrackerTask`).
|
||||
* blocks the handed-over task it worked on (`noteTrackerTask`). A hook run's
|
||||
* calls keep why it failed (`recordHookCallResponse`).
|
||||
*/
|
||||
export default async function notifyBotJobFailed({
|
||||
job,
|
||||
@@ -63,6 +65,13 @@ export default async function notifyBotJobFailed({
|
||||
|
||||
await noteTrackerTask({ job, failure: failure_message || job.last_error || "" });
|
||||
|
||||
await recordHookCallResponse({
|
||||
job,
|
||||
status: "failed",
|
||||
response: job.last_error || failure_message || "The run failed.",
|
||||
message_id: message?.id,
|
||||
});
|
||||
|
||||
const relay = await relayJobToRequester({
|
||||
job,
|
||||
text: failure_message || job.last_error || "",
|
||||
|
||||
@@ -5,6 +5,7 @@ import insertBotMessage from "../bots/insert-bot-message";
|
||||
import setBotFace, { type BotFaceChange } from "../bots/set-bot-face";
|
||||
import { abortRunningJob } from "@/src/bots-queue/job-abort-registry";
|
||||
import insertBotJobUpdate from "./insert-bot-job-update";
|
||||
import recordHookCallResponse from "../bot-hooks/record-hook-call-response";
|
||||
import openBotJobsSql from "./open-bot-jobs-sql";
|
||||
import stoppedJobReport, { pickReportedJobs } from "./stopped-job-report";
|
||||
|
||||
@@ -91,6 +92,8 @@ export default async function stopBotJobs({ bot_id, user_id }: Params): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
await recordHookCallResponse({ job: stopped, status: "failed", response: BotJobStoppedError, now });
|
||||
|
||||
/**
|
||||
* Recorded as the job's last step, so the working row says why it
|
||||
* went quiet.
|
||||
|
||||
Reference in New Issue
Block a user