Skip to content
Draft
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
321 changes: 321 additions & 0 deletions apps/discord-bot/DESIGN-thread-restore.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/discord-bot/docs/agent-turn-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Discord turn rules (client overlay)

**Layer:** Discord overlay on top of global T3 product rules.

Global product policy lives in `apps/server/docs/t3-agent-rules.md` and is
injected by the T3 server on session start (and again after compaction). This
file is **only** Discord-specific policy. Do not restate global rules here.

You = Discord bot. Final reply posts in-thread. "you" = you unless another person is named.
Don't mix up requester vs thread starter vs others.

**Style:** lead with answer; concise; no status recaps.

**PR:** always open for commits/landable work; draft until lint/typecheck/tests/`vp check`; then mark ready. No abandoned drafts.
**PR lifecycle:** before push/handoff, check the linked PR is still **open** (`gh pr view --json state` or equivalent). If **merged** or **closed**, do **not** keep committing on that branch — branch fresh from the correct base (`fork/discord` overlay / `fork/changes` / etc.), re-apply unmerged work, open a **new** PR (draft if still iterating). One merged PR is not a free ticket for later commits.

**cab (commits):** bot already resolved the identity map — do **not** re-lookup or invent emails.
Turn field `cab: Name <email> | Name2 <email2>` (deduped). For each entry, append a git trailer:
`Co-authored-by: Name <email>` (blank line before first). Keep default bot author/committer.
Check: `git log -1 --format=%B`. PR co-author list: `[@login](https://github.com/login)` only — never bare `@login`.
`unmapped:` means no trailer for that Discord user.

**PR footer** from turn `pr` + `t3` fields (paste at PR body end; bot may re-append):
`opened by [{name}](https://discord.com/users/{uid}) in chat thread **Discord** · [{title}](https://discord.com/channels/{g}/{c}/{m}) · [T3]({t3url})`
URL forms only; never bare snowflakes.
**t3url:** private GitHub repo → turn `t3 full=…`; public repo → turn `t3 short=…` (host is always just `t3vm`). Prefer short when unsure (don't leak internal hosts on public PRs).

**jira:** put turn keys in PR body (prefer primary in title/branch).

**ref:** referenced msg is primary context when present.

**Sentry:** parse starter/ref → Sentry for trace id → Honeycomb link first (turn tpl) → then user ask. Don't invent data; report tool failures.
35 changes: 35 additions & 0 deletions apps/discord-bot/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@t3tools/discord-bot",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"browser-profile": "node --import tsx src/browserProfileCli.ts",
"dev": "node --watch --import tsx src/main.ts",
"dev:teams": "node --watch --import tsx src/teams-main.ts",
"start": "node --import tsx src/main.ts",
"start:teams": "node --import tsx src/teams-main.ts",
"typecheck": "tsgo --noEmit",
"test": "vp test run"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@microsoft/teams.apps": "2.0.14",
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"dfx": "catalog:",
"effect": "catalog:",
"playwright-core": "1.60.0",
"yaml": "catalog:"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@types/node": "catalog:",
"tsx": "^4.20.5",
"vite-plus": "catalog:"
},
"engines": {
"node": "^22.16 || ^23.11 || >=24.10"
}
}
80 changes: 80 additions & 0 deletions apps/discord-bot/src/alertProcessRules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

import {
loadAlertProcessRulesFromFileSync,
parseAlertProcessRulesDocument,
} from "./alertProcessRules.ts";

describe("parseAlertProcessRulesDocument", () => {
it("parses structured rules with size and duration shorthands", () => {
const rules = parseAlertProcessRulesDocument({
rules: [
{
id: "jaeger-linux",
match: "jaeger-linux",
rss: "4gb",
duration: "5m",
},
],
});

expect(rules).toEqual([
{
id: "jaeger-linux",
match: "jaeger-linux",
rssMbThreshold: 4096,
sustainedForMs: 5 * 60_000,
},
]);
});

it("accepts cpu-only rules", () => {
const rules = parseAlertProcessRulesDocument([
{
id: "cpu-hot",
match: "worker",
cpuPercentThreshold: 90,
sustainedFor: "2m",
},
]);

expect(rules[0]).toEqual({
id: "cpu-hot",
match: "worker",
cpuPercentThreshold: 90,
sustainedForMs: 2 * 60_000,
});
});
});

describe("loadAlertProcessRulesFromFileSync", () => {
it("loads yaml files", async () => {
const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bot-alert-rules-"));
const yamlPath = NodePath.join(dir, "alert-rules.yaml");
await NodeFSP.writeFile(
yamlPath,
[
"rules:",
" - id: jaeger-linux",
" match: jaeger-linux",
" rss: 4gb",
" duration: 5m",
"",
].join("\n"),
"utf8",
);

expect(loadAlertProcessRulesFromFileSync(yamlPath)).toEqual([
{
id: "jaeger-linux",
match: "jaeger-linux",
rssMbThreshold: 4096,
sustainedForMs: 5 * 60_000,
},
]);
});
});
162 changes: 162 additions & 0 deletions apps/discord-bot/src/alertProcessRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import { parse as parseYaml } from "yaml";

import { expandHomePath } from "./projectAliases.ts";

export interface AlertProcessRule {
readonly id: string;
readonly match: string;
readonly rssMbThreshold?: number;
readonly cpuPercentThreshold?: number;
readonly sustainedForMs: number;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function normalizeNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}

function parseCpuPercent(value: unknown, field: string): number | undefined {
if (value === undefined) return undefined;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative number.`);
}
return value;
}

function parseSizeToMb(value: unknown, field: string): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value !== "string") {
throw new Error(`${field} must be a non-negative number or size string.`);
}
const trimmed = value.trim().toLowerCase();
const match = /^(?<amount>\d+(?:\.\d+)?)\s*(?<unit>b|kb|kib|mb|mib|gb|gib|tb|tib)?$/u.exec(
trimmed,
);
if (!match?.groups) {
throw new Error(`${field} must be a size like 4096, 4gb, or 512mb.`);
}
const amount = Number(match.groups.amount);
const unit = match.groups.unit ?? "mb";
const multiplier =
unit === "b"
? 1 / (1024 * 1024)
: unit === "kb" || unit === "kib"
? 1 / 1024
: unit === "mb" || unit === "mib"
? 1
: unit === "gb" || unit === "gib"
? 1024
: unit === "tb" || unit === "tib"
? 1024 * 1024
: null;
if (multiplier === null) {
throw new Error(`${field} has an unsupported unit.`);
}
return amount * multiplier;
}

function parseDurationToMs(value: unknown, field: string): number {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value !== "string") {
throw new Error(`${field} must be a non-negative number of milliseconds or duration string.`);
}
const trimmed = value.trim().toLowerCase();
const match = /^(?<amount>\d+(?:\.\d+)?)\s*(?<unit>ms|s|m|h|d)?$/u.exec(trimmed);
if (!match?.groups) {
throw new Error(`${field} must be a duration like 5m, 30s, or 60000.`);
}
const amount = Number(match.groups.amount);
const unit = match.groups.unit ?? "ms";
const multiplier =
unit === "ms"
? 1
: unit === "s"
? 1_000
: unit === "m"
? 60_000
: unit === "h"
? 60 * 60_000
: unit === "d"
? 24 * 60 * 60_000
: null;
if (multiplier === null) {
throw new Error(`${field} has an unsupported unit.`);
}
return amount * multiplier;
}

function parseRule(value: unknown, index: number): AlertProcessRule {
if (!isRecord(value)) {
throw new Error(`Process alert rule #${index + 1} must be an object.`);
}
const id = normalizeNonEmptyString(value.id);
if (id === null) {
throw new Error(`Process alert rule #${index + 1} must include a non-empty id.`);
}
const match = normalizeNonEmptyString(value.match);
if (match === null) {
throw new Error(`Process alert rule '${id}' must include a non-empty match string.`);
}
const rssMbThreshold = parseSizeToMb(
value.rssMbThreshold ?? value.rssMb ?? value.rss,
`Process alert rule '${id}' rss`,
);
const cpuPercentThreshold = parseCpuPercent(
value.cpuPercentThreshold ?? value.cpuPercent ?? value.cpu,
`Process alert rule '${id}' cpu`,
);
if (rssMbThreshold === undefined && cpuPercentThreshold === undefined) {
throw new Error(`Process alert rule '${id}' must set rss and/or cpu thresholds.`);
}
const sustainedForMs = parseDurationToMs(
value.sustainedForMs ?? value.sustainedFor ?? value.duration,
`Process alert rule '${id}' duration`,
);
if (sustainedForMs < 0) {
throw new Error(`Process alert rule '${id}' duration must be non-negative.`);
}
return {
id,
match,
...(rssMbThreshold === undefined ? {} : { rssMbThreshold }),
...(cpuPercentThreshold === undefined ? {} : { cpuPercentThreshold }),
sustainedForMs,
};
}

export function parseAlertProcessRulesDocument(document: unknown): ReadonlyArray<AlertProcessRule> {
const source = Array.isArray(document)
? document
: isRecord(document) && Array.isArray(document.rules)
? document.rules
: null;
if (source === null) {
throw new Error("Alert process rules file must be an array or an object with a rules array.");
}
return source.map((entry, index) => parseRule(entry, index));
}

export function loadAlertProcessRulesFromFileSync(
filePath: string | undefined,
): ReadonlyArray<AlertProcessRule> {
if (filePath === undefined || filePath.trim() === "") return [];
const resolvedPath = NodePath.resolve(expandHomePath(filePath.trim()));
if (!NodeFS.existsSync(resolvedPath)) {
throw new Error(`Alert process rules file not found: ${resolvedPath}`);
}
const raw = NodeFS.readFileSync(resolvedPath, "utf8").trim();
if (raw.length === 0) return [];
const document = resolvedPath.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
return parseAlertProcessRulesDocument(document);
}
37 changes: 37 additions & 0 deletions apps/discord-bot/src/browser/BrowserAutomationHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vite-plus/test";

import {
browserOperationDeadlineMs,
BrowserOperationTimeoutError,
withBrowserOperationDeadline,
} from "./BrowserAutomationHost.ts";

describe("browser automation host deadline", () => {
it("reserves time for delivering the response to the broker", () => {
expect(browserOperationDeadlineMs(15_000)).toBe(14_000);
expect(browserOperationDeadlineMs(500)).toBe(450);
});

it("rejects a stalled operation before the broker timeout", async () => {
const stalled = new Promise<never>(() => {});
let interrupted = false;

await expect(
withBrowserOperationDeadline(stalled, 10, () => {
interrupted = true;
}),
).rejects.toBeInstanceOf(BrowserOperationTimeoutError);
expect(interrupted).toBe(true);
});

it("does not interrupt an operation that completes before its deadline", async () => {
let interrupted = false;

await expect(
withBrowserOperationDeadline(Promise.resolve("complete"), 100, () => {
interrupted = true;
}),
).resolves.toBe("complete");
expect(interrupted).toBe(false);
});
});
Loading
Loading