Files
Codeman/src/cli-notify.ts
T
Devvyn 1ab54b9e9b fix(discord): report a refused post as a failure, bound the file probe, drop threads of a previous channel
- codeman notify exits 1 when the route answers 200 with data.ok false
- notify request times out at 30 s instead of hanging
- fileProblem probes the file with the bounded probe before realpath
- the thread map records its parent channel; a new channel starts afresh
- threadFor returns its failure instead of sharing this.last
2026-10-30 04:58:17 +08:00

120 lines
4.5 KiB
TypeScript

/**
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
*
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
* print and the exit code instead of exiting, so a test can drive it.
*
* @module cli-notify
*/
import http from 'node:http';
import https from 'node:https';
import { isAbsolute, join } from 'node:path';
import { readCodemanCredentials } from './codeman-credentials.js';
const REQUEST_TIMEOUT_MS = 30_000;
export interface NotifyOptions {
message: string;
urgent?: boolean;
file?: string;
session?: string;
url?: string;
}
export interface NotifyResult {
code: number;
out?: string;
err?: string;
}
export type JsonPost = (
apiUrl: string,
apiPath: string,
payload: unknown
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
export const postJson: JsonPost = (apiUrl, apiPath, payload) => {
const { username, password } = readCodemanCredentials();
const url = new URL(apiPath, apiUrl);
const body = JSON.stringify(payload);
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: REQUEST_TIMEOUT_MS,
},
(res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let error: string | undefined;
let delivery: { ok: boolean; error?: string } | undefined;
try {
const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
error = body.error;
// A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
} catch {
/* not JSON */
}
resolve({ status: res.statusCode ?? 0, error, delivery });
});
}
);
// A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0 }));
req.write(body);
req.end();
});
};
export async function runNotify(
opts: NotifyOptions,
env: NodeJS.ProcessEnv,
cwd: string,
post: JsonPost = postJson
): Promise<NotifyResult> {
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
const apiUrl = opts.url || env.CODEMAN_API_URL;
if (!sessionId || !apiUrl) {
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
}
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
if (opts.urgent) payload.urgent = true;
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
const { status, error, delivery } = await post(
apiUrl,
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
payload
);
if (status >= 200 && status < 300) {
if (delivery?.ok === false) {
return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
}
return { code: 0, out: 'Posted to Discord' };
}
// The bridge being off is not a failure: the caller just says it in the terminal.
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
if (status === 429)
return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
}