@arungeorgesaji/assembly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.js ADDED
@@ -0,0 +1,46 @@
1
+ export function createFinalReport({ request, plan, state, events }) {
2
+ const completedEvents = events.filter((event) =>
3
+ ["task.complete", "task.blocked", "task.failed"].includes(event.type),
4
+ );
5
+ const resultByTaskId = new Map(completedEvents.map((event) => [event.taskId, event.data]));
6
+ const risks = completedEvents.flatMap((event) => event.data?.risks ?? []);
7
+
8
+ return [
9
+ "# Assembly Run Report",
10
+ "",
11
+ `Run: ${state.runId}`,
12
+ `Status: ${state.status}`,
13
+ `Request: ${request.request}`,
14
+ "",
15
+ "## Summary",
16
+ "",
17
+ plan.summary,
18
+ "",
19
+ "## Tasks",
20
+ "",
21
+ ...plan.tasks.flatMap((task) => {
22
+ const taskState = state.tasks[task.id];
23
+ const result = resultByTaskId.get(task.id);
24
+ const profile = plan.agentProfiles?.find((candidate) => candidate.id === task.agentProfileId);
25
+ return [
26
+ `### ${task.title}`,
27
+ "",
28
+ `- ID: ${task.id}`,
29
+ `- Owner: ${task.owner}`,
30
+ `- Agent profile: ${profile?.label ?? task.agentProfileId ?? "none"}`,
31
+ `- Status: ${taskState?.status ?? "unknown"}`,
32
+ `- Summary: ${result?.summary ?? "No result produced."}`,
33
+ `- Artifacts: ${(result?.artifacts ?? []).join(", ") || "none"}`,
34
+ "",
35
+ ];
36
+ }),
37
+ "## Risks",
38
+ "",
39
+ ...(risks.length > 0 ? risks.map((risk) => `- ${risk}`) : ["- None reported."]),
40
+ "",
41
+ "## Verification",
42
+ "",
43
+ ...plan.verification.map((step) => `- ${step}`),
44
+ "",
45
+ ].join("\n");
46
+ }
@@ -0,0 +1,55 @@
1
+ import { validateFileUpdatesForTask } from "./file-updates.js";
2
+ import { validateChangedFilesWithinScope } from "./scope.js";
3
+ import { validatePatchForTask } from "./patch.js";
4
+
5
+ const TERMINAL_STATUSES = new Set(["complete", "blocked", "failed"]);
6
+
7
+ export function validateTaskResult(task, result) {
8
+ const errors = [];
9
+
10
+ if (!isObject(result)) {
11
+ return [`task ${task.id} returned a non-object result`];
12
+ }
13
+
14
+ if (result.taskId !== task.id) {
15
+ errors.push(`result taskId must match dispatched task ${task.id}`);
16
+ }
17
+ if (!TERMINAL_STATUSES.has(result.status)) {
18
+ errors.push(`task ${task.id} result status must be complete, blocked, or failed`);
19
+ }
20
+ if (!isNonEmptyString(result.summary)) {
21
+ errors.push(`task ${task.id} result must include a summary`);
22
+ }
23
+ if (!Array.isArray(result.changedFiles)) {
24
+ errors.push(`task ${task.id} result changedFiles must be an array`);
25
+ }
26
+ if (!Array.isArray(result.artifacts)) {
27
+ errors.push(`task ${task.id} result artifacts must be an array`);
28
+ }
29
+ if (!Array.isArray(result.risks)) {
30
+ errors.push(`task ${task.id} result risks must be an array`);
31
+ }
32
+ if (result.status === "complete" && Array.isArray(result.artifacts) && result.artifacts.length === 0) {
33
+ errors.push(`completed task ${task.id} must include at least one artifact`);
34
+ }
35
+ if (Array.isArray(result.changedFiles)) {
36
+ errors.push(...validateChangedFilesWithinScope(task, result.changedFiles));
37
+ }
38
+ if (result.patch !== undefined && typeof result.patch !== "string") {
39
+ errors.push(`task ${task.id} result patch must be a string when provided`);
40
+ }
41
+ if (typeof result.patch === "string") {
42
+ errors.push(...validatePatchForTask(task, result));
43
+ }
44
+ errors.push(...validateFileUpdatesForTask(task, result));
45
+
46
+ return errors;
47
+ }
48
+
49
+ function isObject(value) {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function isNonEmptyString(value) {
54
+ return typeof value === "string" && value.trim().length > 0;
55
+ }
@@ -0,0 +1,127 @@
1
+ import { getOpenAIConfig } from "./config.js";
2
+ import { buildTaskContext } from "./context-builder.js";
3
+ import { readRun } from "./run-store.js";
4
+
5
+ const REVIEW_SCHEMA = {
6
+ type: "object",
7
+ additionalProperties: false,
8
+ required: ["taskId", "status", "summary", "changedFiles", "artifacts", "risks", "patch", "fileUpdates"],
9
+ properties: {
10
+ taskId: { type: "string" },
11
+ status: { type: "string", enum: ["complete", "blocked", "failed"] },
12
+ summary: { type: "string" },
13
+ changedFiles: {
14
+ type: "array",
15
+ items: { type: "string" },
16
+ },
17
+ artifacts: {
18
+ type: "array",
19
+ items: { type: "string" },
20
+ },
21
+ risks: {
22
+ type: "array",
23
+ items: { type: "string" },
24
+ },
25
+ patch: { type: "string" },
26
+ fileUpdates: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ required: ["path", "content"],
32
+ properties: {
33
+ path: { type: "string" },
34
+ content: { type: "string" },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export function createOpenAIReviewRunner(config = getOpenAIConfig()) {
42
+ return async function runOpenAIReview(task, runContext = {}) {
43
+ const rootDir = runContext.rootDir ?? process.cwd();
44
+ const taskContext = await buildTaskContext(task, rootDir);
45
+ const run = await readRun(runContext.runId, rootDir);
46
+ const agentProfile = run.plan.agentProfiles?.find((profile) => profile.id === task.agentProfileId);
47
+
48
+ const response = await fetch(`${config.baseUrl}/responses`, {
49
+ method: "POST",
50
+ headers: {
51
+ Authorization: `Bearer ${config.apiKey}`,
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({
55
+ model: config.model,
56
+ input: [
57
+ {
58
+ role: "developer",
59
+ content: [
60
+ {
61
+ type: "input_text",
62
+ text:
63
+ "You are an Assembly review agent. Return only JSON matching the schema. " +
64
+ "Review the completed task results, changed files, artifacts, and verification output. " +
65
+ "If task.agentProfileId is present, use the matching agentProfile to check ownership boundaries. " +
66
+ "Return status complete when the work is acceptable. Return blocked for human input needed. " +
67
+ "Return failed for concrete correctness, scope, security, or verification problems. " +
68
+ "Do not edit files; patch must be empty and fileUpdates must be empty.",
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ role: "user",
74
+ content: [
75
+ {
76
+ type: "input_text",
77
+ text: JSON.stringify(
78
+ {
79
+ request: run.plan.request,
80
+ task,
81
+ agentProfile,
82
+ state: run.state,
83
+ events: run.events,
84
+ scopedFiles: taskContext.files,
85
+ },
86
+ null,
87
+ 2,
88
+ ),
89
+ },
90
+ ],
91
+ },
92
+ ],
93
+ text: {
94
+ format: {
95
+ type: "json_schema",
96
+ name: "assembly_review_result",
97
+ strict: true,
98
+ schema: REVIEW_SCHEMA,
99
+ },
100
+ },
101
+ }),
102
+ });
103
+
104
+ const payload = await response.json().catch(() => null);
105
+ if (!response.ok) {
106
+ const message = payload?.error?.message ?? "unknown OpenAI API error";
107
+ throw new Error(`OpenAI API request failed with status ${response.status}: ${message}`);
108
+ }
109
+
110
+ const outputText = payload?.output_text ?? findOutputText(payload);
111
+ if (!outputText) {
112
+ throw new Error("OpenAI review response did not include structured output text");
113
+ }
114
+ return JSON.parse(outputText);
115
+ };
116
+ }
117
+
118
+ function findOutputText(payload) {
119
+ for (const item of payload?.output ?? []) {
120
+ for (const content of item.content ?? []) {
121
+ if (content.type === "output_text" && typeof content.text === "string") {
122
+ return content.text;
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
package/src/root.js ADDED
@@ -0,0 +1,51 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export async function resolveRootDir({ cwd = process.cwd(), repo, env = process.env, exec = execFileAsync } = {}) {
8
+ const explicitRepo = repo || env.ASSEMBLY_REPO;
9
+ if (explicitRepo) {
10
+ return path.resolve(cwd, explicitRepo);
11
+ }
12
+
13
+ try {
14
+ const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"], { cwd });
15
+ const gitRoot = stdout.trim();
16
+ return gitRoot ? path.resolve(gitRoot) : cwd;
17
+ } catch {
18
+ return cwd;
19
+ }
20
+ }
21
+
22
+ export function parseGlobalOptions(argv) {
23
+ const args = [];
24
+ const errors = [];
25
+ let repo;
26
+
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const arg = argv[index];
29
+ if (arg === "--repo") {
30
+ if (!argv[index + 1] || argv[index + 1].startsWith("--")) {
31
+ errors.push("--repo requires a path");
32
+ continue;
33
+ }
34
+ repo = argv[index + 1];
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (arg.startsWith("--repo=")) {
39
+ const value = arg.slice("--repo=".length);
40
+ if (!value) {
41
+ errors.push("--repo requires a path");
42
+ continue;
43
+ }
44
+ repo = value;
45
+ continue;
46
+ }
47
+ args.push(arg);
48
+ }
49
+
50
+ return { args, repo, errors };
51
+ }
@@ -0,0 +1,104 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const ROOT_DIR = ".assembly";
5
+ const RUNS_DIR = "runs";
6
+
7
+ export function createRunId(date = new Date()) {
8
+ const timestamp = date.toISOString().replaceAll(":", "").replace(/\.\d{3}Z$/, "Z");
9
+ const suffix = Math.random().toString(36).slice(2, 8);
10
+ return `${timestamp}-${suffix}`;
11
+ }
12
+
13
+ export function getRunDir(runId, rootDir = process.cwd()) {
14
+ return path.join(rootDir, ROOT_DIR, RUNS_DIR, runId);
15
+ }
16
+
17
+ export async function initializeRun({ runId, request, plan, metadata = {} }, rootDir = process.cwd()) {
18
+ const runDir = getRunDir(runId, rootDir);
19
+ await mkdir(path.join(runDir, "artifacts"), { recursive: true });
20
+
21
+ const state = {
22
+ runId,
23
+ status: "created",
24
+ currentTaskId: null,
25
+ tasks: Object.fromEntries(
26
+ plan.tasks.map((task) => [
27
+ task.id,
28
+ {
29
+ status: "pending",
30
+ owner: task.owner,
31
+ agentProfileId: task.agentProfileId,
32
+ title: task.title,
33
+ },
34
+ ]),
35
+ ),
36
+ };
37
+
38
+ await writeJson(path.join(runDir, "request.json"), {
39
+ id: runId,
40
+ request,
41
+ createdAt: new Date().toISOString(),
42
+ ...metadata,
43
+ });
44
+ await writeJson(path.join(runDir, "plan.json"), plan);
45
+ await writeJson(path.join(runDir, "state.json"), state);
46
+ await appendEvent(runId, { type: "run.created", data: { request, metadata } }, rootDir);
47
+
48
+ return state;
49
+ }
50
+
51
+ export async function readRun(runId, rootDir = process.cwd()) {
52
+ const runDir = getRunDir(runId, rootDir);
53
+ const [request, plan, state, eventsText] = await Promise.all([
54
+ readJson(path.join(runDir, "request.json")),
55
+ readJson(path.join(runDir, "plan.json")),
56
+ readJson(path.join(runDir, "state.json")),
57
+ readFile(path.join(runDir, "events.jsonl"), "utf8").catch(() => ""),
58
+ ]);
59
+
60
+ return {
61
+ request,
62
+ plan,
63
+ state,
64
+ events: eventsText
65
+ .split("\n")
66
+ .filter(Boolean)
67
+ .map((line) => JSON.parse(line)),
68
+ };
69
+ }
70
+
71
+ export async function writeState(runId, state, rootDir = process.cwd()) {
72
+ await writeJson(path.join(getRunDir(runId, rootDir), "state.json"), state);
73
+ }
74
+
75
+ export async function writeArtifact(runId, taskId, name, data, rootDir = process.cwd()) {
76
+ const artifactDir = path.join(getRunDir(runId, rootDir), "artifacts", taskId);
77
+ await mkdir(artifactDir, { recursive: true });
78
+ await writeJson(path.join(artifactDir, name), data);
79
+ }
80
+
81
+ export async function writeRunFile(runId, name, content, rootDir = process.cwd()) {
82
+ await writeFile(path.join(getRunDir(runId, rootDir), name), content);
83
+ }
84
+
85
+ export async function appendEvent(runId, event, rootDir = process.cwd()) {
86
+ const enrichedEvent = {
87
+ runId,
88
+ timestamp: new Date().toISOString(),
89
+ ...event,
90
+ };
91
+ await writeFile(
92
+ path.join(getRunDir(runId, rootDir), "events.jsonl"),
93
+ `${JSON.stringify(enrichedEvent)}\n`,
94
+ { flag: "a" },
95
+ );
96
+ }
97
+
98
+ async function readJson(filePath) {
99
+ return JSON.parse(await readFile(filePath, "utf8"));
100
+ }
101
+
102
+ async function writeJson(filePath, data) {
103
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`);
104
+ }
package/src/scope.js ADDED
@@ -0,0 +1,107 @@
1
+ import path from "node:path";
2
+
3
+ export function createScope({ paths = [], allowlist = [], denylist = [] } = {}) {
4
+ return {
5
+ paths,
6
+ allowlist,
7
+ denylist,
8
+ };
9
+ }
10
+
11
+ export function validateChangedFilesWithinScope(task, changedFiles) {
12
+ const errors = [];
13
+ const scope = task.scope ?? createScope();
14
+
15
+ for (const changedFile of changedFiles) {
16
+ const normalized = normalizeRelativePath(changedFile);
17
+ if (!normalized) {
18
+ errors.push(`task ${task.id} changed file ${changedFile} is not a safe relative path`);
19
+ continue;
20
+ }
21
+
22
+ if (matchesAny(normalized, scope.denylist)) {
23
+ errors.push(`task ${task.id} changed denied path ${changedFile}`);
24
+ continue;
25
+ }
26
+
27
+ if (matchesAny(normalized, scope.allowlist)) {
28
+ continue;
29
+ }
30
+
31
+ if (!matchesAny(normalized, scope.paths)) {
32
+ errors.push(`task ${task.id} changed file ${changedFile} outside assigned scope`);
33
+ }
34
+ }
35
+
36
+ return errors;
37
+ }
38
+
39
+ export function validateScope(scope, taskId) {
40
+ const errors = [];
41
+
42
+ if (!scope || typeof scope !== "object" || Array.isArray(scope)) {
43
+ return [`task ${taskId} must include a scope object`];
44
+ }
45
+
46
+ for (const key of ["paths", "allowlist", "denylist"]) {
47
+ if (!Array.isArray(scope[key])) {
48
+ errors.push(`task ${taskId} scope.${key} must be an array`);
49
+ continue;
50
+ }
51
+ for (const scopePath of scope[key]) {
52
+ if (!normalizeRelativePath(scopePath)) {
53
+ errors.push(`task ${taskId} scope.${key} contains unsafe path ${scopePath}`);
54
+ }
55
+ }
56
+ }
57
+
58
+ if (scope.paths.length === 0 && scope.allowlist.length === 0) {
59
+ errors.push(`task ${taskId} scope must include at least one path or allowlist entry`);
60
+ }
61
+
62
+ return errors;
63
+ }
64
+
65
+ function matchesAny(changedFile, scopePaths) {
66
+ return scopePaths.some((scopePath) => matchesScopePath(changedFile, scopePath));
67
+ }
68
+
69
+ function matchesScopePath(changedFile, scopePath) {
70
+ const normalizedScopePath = normalizeRelativePath(scopePath);
71
+ if (!normalizedScopePath) {
72
+ return false;
73
+ }
74
+
75
+ if (normalizedScopePath.includes("*")) {
76
+ const pattern = new RegExp(`^${normalizedScopePath.split("*").map(escapeRegex).join("[^/]*")}$`);
77
+ return pattern.test(changedFile);
78
+ }
79
+
80
+ if (normalizedScopePath.endsWith("/")) {
81
+ return changedFile.startsWith(normalizedScopePath);
82
+ }
83
+
84
+ return changedFile === normalizedScopePath || changedFile.startsWith(`${normalizedScopePath}/`);
85
+ }
86
+
87
+ function normalizeRelativePath(value) {
88
+ if (typeof value !== "string" || value.trim() === "") {
89
+ return null;
90
+ }
91
+
92
+ const normalized = value.replaceAll("\\", "/");
93
+ if (path.posix.isAbsolute(normalized)) {
94
+ return null;
95
+ }
96
+
97
+ const collapsed = path.posix.normalize(normalized);
98
+ if (collapsed === "." || collapsed === ".." || collapsed.startsWith("../")) {
99
+ return null;
100
+ }
101
+
102
+ return normalized.endsWith("/") && !collapsed.endsWith("/") ? `${collapsed}/` : collapsed;
103
+ }
104
+
105
+ function escapeRegex(value) {
106
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
107
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const THREADS_DIR = ".assembly/slack-threads";
5
+
6
+ export async function readSlackThreadState({ teamId, channel, threadTs }, rootDir = process.cwd()) {
7
+ try {
8
+ return JSON.parse(await readFile(getThreadPath({ teamId, channel, threadTs }, rootDir), "utf8"));
9
+ } catch (error) {
10
+ if (error.code === "ENOENT") {
11
+ return null;
12
+ }
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export async function writeSlackThreadState(state, rootDir = process.cwd()) {
18
+ await mkdir(path.join(rootDir, THREADS_DIR), { recursive: true });
19
+ const updatedState = {
20
+ ...state,
21
+ updatedAt: new Date().toISOString(),
22
+ };
23
+ await writeFile(getThreadPath(state, rootDir), `${JSON.stringify(updatedState, null, 2)}\n`);
24
+ return updatedState;
25
+ }
26
+
27
+ function getThreadPath({ teamId, channel, threadTs }, rootDir) {
28
+ const key = [teamId || "unknown-team", channel, threadTs].map(sanitizeKeyPart).join("__");
29
+ return path.join(rootDir, THREADS_DIR, `${key}.json`);
30
+ }
31
+
32
+ function sanitizeKeyPart(value) {
33
+ return String(value ?? "").replace(/[^a-zA-Z0-9._-]+/g, "_");
34
+ }
@@ -0,0 +1,120 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ import { getSlackSigningSecret } from "./config.js";
4
+ import { enqueueJob, findJobByDelivery } from "./job-store.js";
5
+ import { postSlackMessage } from "./slack.js";
6
+
7
+ const FIVE_MINUTES_SECONDS = 60 * 5;
8
+
9
+ export function verifySlackSignature(
10
+ rawBody,
11
+ signature,
12
+ timestamp,
13
+ secret = getSlackSigningSecret(),
14
+ nowSeconds = Math.floor(Date.now() / 1000),
15
+ ) {
16
+ if (!secret) {
17
+ throw new Error("SLACK_SIGNING_SECRET is required for webhook verification");
18
+ }
19
+ if (!signature?.startsWith("v0=") || !timestamp) {
20
+ return false;
21
+ }
22
+ if (Math.abs(nowSeconds - Number(timestamp)) > FIVE_MINUTES_SECONDS) {
23
+ return false;
24
+ }
25
+
26
+ const base = `v0:${timestamp}:${rawBody}`;
27
+ const expected = `v0=${createHmac("sha256", secret).update(base).digest("hex")}`;
28
+ const expectedBuffer = Buffer.from(expected);
29
+ const actualBuffer = Buffer.from(signature);
30
+ return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
31
+ }
32
+
33
+ export async function handleSlackWebhook({ signature, timestamp, rawBody }, rootDir = process.cwd()) {
34
+ if (!verifySlackSignature(rawBody, signature, timestamp)) {
35
+ return { status: 401, body: { error: "invalid signature" } };
36
+ }
37
+
38
+ let payload;
39
+ try {
40
+ payload = JSON.parse(rawBody);
41
+ } catch {
42
+ return { status: 400, body: { error: "invalid JSON payload" } };
43
+ }
44
+
45
+ if (payload.type === "url_verification") {
46
+ return { status: 200, body: { challenge: payload.challenge } };
47
+ }
48
+
49
+ const normalized = normalizeSlackWebhook(payload);
50
+ if (normalized.ignored) {
51
+ return { status: 202, body: normalized };
52
+ }
53
+
54
+ const existingJob = await findJobByDelivery(normalized.delivery, rootDir);
55
+ if (existingJob) {
56
+ return { status: 202, body: { queued: false, duplicate: true, jobId: existingJob.id } };
57
+ }
58
+
59
+ const job = await enqueueJob({
60
+ type: normalized.type,
61
+ delivery: normalized.delivery,
62
+ payload: normalized.payload,
63
+ }, rootDir);
64
+ await acknowledgeSlackJob(job);
65
+
66
+ return { status: 202, body: { queued: true, jobId: job.id } };
67
+ }
68
+
69
+ export function normalizeSlackWebhook(payload) {
70
+ if (payload.type !== "event_callback") {
71
+ return { ignored: true, reason: "unsupported slack payload type" };
72
+ }
73
+
74
+ const event = payload.event ?? {};
75
+ if (event.bot_id || event.subtype === "bot_message") {
76
+ return { ignored: true, reason: "bot messages are ignored" };
77
+ }
78
+ if (!["app_mention", "message"].includes(event.type)) {
79
+ return { ignored: true, reason: "unsupported slack event type" };
80
+ }
81
+ if (event.type === "message" && event.channel_type !== "im") {
82
+ return { ignored: true, reason: "non-DM messages must mention the app" };
83
+ }
84
+
85
+ const text = stripSlackAppMention(event.text ?? "").trim();
86
+ if (!text) {
87
+ return { ignored: true, reason: "slack event does not include a request" };
88
+ }
89
+
90
+ return {
91
+ type: "slack.request",
92
+ delivery: payload.event_id,
93
+ payload: {
94
+ kind: event.type,
95
+ teamId: payload.team_id,
96
+ eventId: payload.event_id,
97
+ channel: event.channel,
98
+ user: event.user,
99
+ text,
100
+ threadTs: event.thread_ts ?? event.ts,
101
+ ts: event.ts,
102
+ },
103
+ };
104
+ }
105
+
106
+ function stripSlackAppMention(text) {
107
+ return String(text).replace(/<@[A-Z0-9]+>\s*/gi, "");
108
+ }
109
+
110
+ async function acknowledgeSlackJob(job) {
111
+ try {
112
+ await postSlackMessage({
113
+ channel: job.payload.channel,
114
+ threadTs: job.payload.threadTs,
115
+ text: `On it. Queued Assembly job ${job.id}.`,
116
+ });
117
+ } catch {
118
+ // The queued job remains the source of truth even if the fast ack fails.
119
+ }
120
+ }
package/src/slack.js ADDED
@@ -0,0 +1,31 @@
1
+ import { getSlackBotToken } from "./config.js";
2
+
3
+ export async function postSlackMessage(
4
+ { channel, text, threadTs },
5
+ { token = getSlackBotToken(), fetchImpl = globalThis.fetch } = {},
6
+ ) {
7
+ if (!token) {
8
+ throw new Error("SLACK_BOT_TOKEN is required to post Slack messages");
9
+ }
10
+ if (!fetchImpl) {
11
+ throw new Error("fetch is required to post Slack messages");
12
+ }
13
+
14
+ const response = await fetchImpl("https://slack.com/api/chat.postMessage", {
15
+ method: "POST",
16
+ headers: {
17
+ Authorization: `Bearer ${token}`,
18
+ "Content-Type": "application/json; charset=utf-8",
19
+ },
20
+ body: JSON.stringify({
21
+ channel,
22
+ text,
23
+ thread_ts: threadTs,
24
+ }),
25
+ });
26
+ const result = await response.json();
27
+ if (!result.ok) {
28
+ throw new Error(`Slack chat.postMessage failed: ${result.error ?? "unknown_error"}`);
29
+ }
30
+ return result;
31
+ }