@openpalm/slack-portal 0.12.7

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.
@@ -0,0 +1,157 @@
1
+ export interface RawEvent {
2
+ type: string;
3
+ properties?: Record<string, unknown>;
4
+ }
5
+
6
+ export function asRaw(ev: unknown): RawEvent {
7
+ const e = ev as RawEvent;
8
+ return {
9
+ type: typeof e?.type === 'string' ? e.type : '',
10
+ properties: (e?.properties ?? {}) as Record<string, unknown>,
11
+ };
12
+ }
13
+
14
+ function propStr(props: Record<string, unknown> | undefined, key: string): string | undefined {
15
+ const value = props?.[key];
16
+ return typeof value === 'string' ? value : undefined;
17
+ }
18
+
19
+ export function partSnapshotType(e: RawEvent): { partID: string; type: string } | null {
20
+ if (e.type !== 'message.part.updated') return null;
21
+ const part = e.properties?.part as { id?: unknown; type?: unknown } | undefined;
22
+ if (part && typeof part.id === 'string' && typeof part.type === 'string') {
23
+ return { partID: part.id, type: part.type };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function extractTextDelta(e: RawEvent, sessionId: string, reasoningPartIds?: ReadonlySet<string>): string | null {
29
+ const props = e.properties ?? {};
30
+ if (propStr(props, 'sessionID') !== sessionId) return null;
31
+
32
+ if (e.type === 'session.next.text.delta') {
33
+ return propStr(props, 'delta') ?? propStr(props, 'text') ?? null;
34
+ }
35
+ if (e.type === 'message.part.delta') {
36
+ if (propStr(props, 'field') && propStr(props, 'field') !== 'text') return null;
37
+ const partID = propStr(props, 'partID');
38
+ if (partID && reasoningPartIds?.has(partID)) return null;
39
+ return propStr(props, 'delta') ?? null;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export const TURN_IDLE_STATUSES: ReadonlySet<string> = new Set(['idle']);
45
+
46
+ export function statusName(status: unknown): string | undefined {
47
+ if (typeof status === 'string') return status;
48
+ if (status && typeof status === 'object' && typeof (status as { type?: unknown }).type === 'string') {
49
+ return (status as { type: string }).type;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ export function isTurnEnd(e: RawEvent, sessionId: string): boolean {
55
+ if (propStr(e.properties, 'sessionID') !== sessionId) return false;
56
+ if (e.type === 'session.idle') return true;
57
+ if (e.type === 'session.status') {
58
+ const name = statusName(e.properties?.status);
59
+ return name !== undefined && TURN_IDLE_STATUSES.has(name);
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export interface ToolUpdate {
65
+ callID: string;
66
+ tool: string;
67
+ status: string;
68
+ title?: string;
69
+ error?: string;
70
+ }
71
+
72
+ export function extractToolUpdate(e: RawEvent, sessionId: string): ToolUpdate | null {
73
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
74
+ const part = (e.properties?.part ?? e.properties?.tool) as Record<string, unknown> | undefined;
75
+ if (e.type === 'message.part.updated' && part && (part.type === 'tool' || part.state)) {
76
+ const state = (part.state ?? {}) as Record<string, unknown>;
77
+ return {
78
+ callID: String(part.callID ?? part.id ?? ''),
79
+ tool: String(part.tool ?? 'tool'),
80
+ status: String(state.status ?? 'running'),
81
+ title: typeof state.title === 'string' ? state.title : undefined,
82
+ error: typeof state.error === 'string' ? state.error : undefined,
83
+ };
84
+ }
85
+ if (e.type.startsWith('session.next.tool.')) {
86
+ return {
87
+ callID: propStr(e.properties, 'callID') ?? '',
88
+ tool: propStr(e.properties, 'tool') ?? 'tool',
89
+ status: e.type === 'session.next.tool.called' ? 'running' : (propStr(e.properties, 'status') ?? 'running'),
90
+ title: propStr(e.properties, 'title'),
91
+ };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ export interface PermissionAsk {
97
+ requestID: string;
98
+ permission: string;
99
+ patterns: string[];
100
+ }
101
+
102
+ export function extractPermissionAsk(e: RawEvent, sessionId: string): PermissionAsk | null {
103
+ if (e.type !== 'permission.asked') return null;
104
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
105
+ const id = propStr(e.properties, 'id');
106
+ if (!id) return null;
107
+ const patterns = Array.isArray(e.properties?.patterns)
108
+ ? (e.properties.patterns as unknown[]).filter((pattern): pattern is string => typeof pattern === 'string')
109
+ : [];
110
+ return { requestID: id, permission: propStr(e.properties, 'permission') ?? 'tool', patterns };
111
+ }
112
+
113
+ export function isSessionError(e: RawEvent, sessionId: string): boolean {
114
+ return e.type === 'session.error' && propStr(e.properties, 'sessionID') === sessionId;
115
+ }
116
+
117
+ export interface QuestionOption {
118
+ label: string;
119
+ description: string;
120
+ }
121
+
122
+ export interface QuestionInfo {
123
+ question: string;
124
+ header: string;
125
+ options: QuestionOption[];
126
+ }
127
+
128
+ export interface QuestionAsk {
129
+ requestID: string;
130
+ questions: QuestionInfo[];
131
+ }
132
+
133
+ export function extractQuestionAsk(e: RawEvent, sessionId: string): QuestionAsk | null {
134
+ if (e.type !== 'question.asked') return null;
135
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
136
+ const id = propStr(e.properties, 'id');
137
+ if (!id) return null;
138
+ const rawQuestions = Array.isArray(e.properties?.questions) ? (e.properties.questions as unknown[]) : [];
139
+ const questions: QuestionInfo[] = [];
140
+ for (const rawQuestion of rawQuestions) {
141
+ const questionData = rawQuestion as { question?: unknown; header?: unknown; options?: unknown };
142
+ const question = typeof questionData.question === 'string' ? questionData.question : '';
143
+ const header = typeof questionData.header === 'string' ? questionData.header : '';
144
+ const options: QuestionOption[] = Array.isArray(questionData.options)
145
+ ? (questionData.options as unknown[])
146
+ .map((option) => option as { label?: unknown; description?: unknown })
147
+ .filter((option) => typeof option.label === 'string')
148
+ .map((option) => ({
149
+ label: option.label as string,
150
+ description: typeof option.description === 'string' ? option.description : '',
151
+ }))
152
+ : [];
153
+ questions.push({ question, header, options });
154
+ }
155
+ if (questions.length === 0) return null;
156
+ return { requestID: id, questions };
157
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Regression test for the 0.12.0 "portal stopped working" bug (Slack mirror of
3
+ * the Discord case — both adapters share the same OcClient shape).
4
+ *
5
+ * The @opencode-ai/sdk client (1.17.x) resolves every session.* call to a
6
+ * { data, error } envelope. The adapter used to read the session object off the
7
+ * envelope directly, so `createSession().id` was `undefined`; the subsequent
8
+ * `prompt({ path: { id: undefined } })` left the path template un-substituted and
9
+ * sent the LITERAL `/session/{id}/message`. The guardian denied it with
10
+ * `no_route` (403), so every Discord message silently failed.
11
+ *
12
+ * This test drives the REAL SDK through a fake transport (the `fetch` the adapter
13
+ * accepts) and asserts the prompt request carries the actual session id — i.e.
14
+ * the path is `/session/<real-id>/message`, never the literal `{id}` / `%7Bid%7D`.
15
+ */
16
+ import { describe, it, expect } from 'bun:test';
17
+ import { OcClient } from './opencode.js';
18
+
19
+ const REAL_SESSION_ID = 'ses_real_abc123';
20
+
21
+ /** A fake transport standing in for guardian → OpenCode. Records every request
22
+ * URL and answers session.create / session.prompt with realistic envelopes. */
23
+ function makeFakeTransport() {
24
+ const urls: string[] = [];
25
+ const fetchFn = (async (input: Request | string | URL): Promise<Response> => {
26
+ const url = input instanceof Request ? input.url : String(input);
27
+ urls.push(url);
28
+ if (/\/session$/.test(new URL(url).pathname) || /\/session$/.test(url)) {
29
+ // POST /session → the created session lives in the JSON body.
30
+ return new Response(JSON.stringify({ id: REAL_SESSION_ID, title: 'chat' }), {
31
+ status: 200,
32
+ headers: { 'content-type': 'application/json' },
33
+ });
34
+ }
35
+ // POST /session/<id>/message → accept and echo an empty assistant message.
36
+ return new Response(JSON.stringify({ info: {}, parts: [] }), {
37
+ status: 200,
38
+ headers: { 'content-type': 'application/json' },
39
+ });
40
+ }) as unknown as typeof fetch;
41
+ return { urls, fetchFn };
42
+ }
43
+
44
+ describe('OcClient (slack) — SDK envelope handling', () => {
45
+ it('createSession returns the real session id from the { data } envelope', async () => {
46
+ const { fetchFn } = makeFakeTransport();
47
+ const client = new OcClient({ principalId: 'slack', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
48
+
49
+ const session = await client.createSession('slack:U123');
50
+ // Pre-fix this was `undefined` (read off the envelope instead of `.data`).
51
+ expect(session.id).toBe(REAL_SESSION_ID);
52
+ });
53
+
54
+ it('prompt substitutes the session id into the path (never the literal {id})', async () => {
55
+ const { urls, fetchFn } = makeFakeTransport();
56
+ const client = new OcClient({ principalId: 'slack', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
57
+
58
+ const session = await client.createSession('slack:U123');
59
+ await client.prompt('slack:U123', session.id, 'hello');
60
+
61
+ const promptUrl = urls.find((u) => u.includes('/message'));
62
+ expect(promptUrl, 'a /message request must have been made').toBeDefined();
63
+ // The exact prod failure: the un-substituted template reaching the guardian.
64
+ expect(promptUrl).not.toContain('%7Bid%7D');
65
+ expect(promptUrl).not.toContain('{id}');
66
+ expect(promptUrl).toContain(`/session/${REAL_SESSION_ID}/message`);
67
+ });
68
+
69
+ it('createSession throws when the SDK returns an error envelope', async () => {
70
+ const fetchFn = (async () =>
71
+ new Response(JSON.stringify({ message: 'denied' }), {
72
+ status: 403,
73
+ headers: { 'content-type': 'application/json' },
74
+ })) as unknown as typeof fetch;
75
+ const client = new OcClient({ principalId: 'slack', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
76
+
77
+ await expect(client.createSession('slack:U123')).rejects.toThrow(/createSession failed/);
78
+ });
79
+ });
@@ -0,0 +1,130 @@
1
+ import type { Event } from '@opencode-ai/sdk';
2
+ import { createOpencodeClient } from '@opencode-ai/sdk';
3
+
4
+ const DEFAULT_OPENCODE_BASE_URL = 'http://guardian:8080/oc';
5
+ const H_USER = 'x-openpalm-user';
6
+ const H_SESSION_KEY = 'x-openpalm-session-key';
7
+
8
+ export interface OcClientOptions {
9
+ principalId: string;
10
+ secret: string;
11
+ baseUrl?: string;
12
+ fetch?: typeof fetch;
13
+ }
14
+
15
+ export interface OcSession {
16
+ id: string;
17
+ title?: string;
18
+ }
19
+
20
+ export class OcClient {
21
+ private readonly principalId: string;
22
+ private readonly secret: string;
23
+ private readonly base: string;
24
+ private readonly fetchFn: typeof fetch;
25
+ private readonly client: ReturnType<typeof createOpencodeClient>;
26
+
27
+ constructor(opts: OcClientOptions) {
28
+ this.principalId = opts.principalId;
29
+ this.secret = opts.secret;
30
+ this.base = opts.baseUrl ?? Bun.env.OPENCODE_BASE_URL ?? DEFAULT_OPENCODE_BASE_URL;
31
+ this.fetchFn = opts.fetch ?? globalThis.fetch;
32
+ this.client = createOpencodeClient({ baseUrl: this.base, fetch: this.fetchFn });
33
+ }
34
+
35
+ private headers(userId: string, extra?: Record<string, string>): Record<string, string> {
36
+ const credentials = Buffer.from(`${this.principalId}:${this.secret}`, 'utf-8').toString('base64');
37
+ return {
38
+ [H_USER]: userId,
39
+ authorization: `Basic ${credentials}`,
40
+ ...(extra ?? {}),
41
+ };
42
+ }
43
+
44
+ async createSession(userId: string, sessionKey?: string): Promise<OcSession> {
45
+ // The @opencode-ai/sdk client resolves to a { data, error } envelope
46
+ // (ThrowOnError defaults to false). The session lives in `.data` — reading
47
+ // the envelope directly yields an undefined id, which then renders the
48
+ // prompt path as the literal `/session/{id}/message`, and the guardian
49
+ // denies it with no_route. Always pull the session out of `.data`.
50
+ const { data, error } = await this.client.session.create({
51
+ body: {},
52
+ headers: this.headers(userId, sessionKey ? { [H_SESSION_KEY]: sessionKey } : undefined),
53
+ });
54
+ if (error || !data?.id) {
55
+ throw new Error(`createSession failed: ${error ? JSON.stringify(error) : 'no session id in response'}`);
56
+ }
57
+ return data as OcSession;
58
+ }
59
+
60
+ async prompt(userId: string, sessionId: string, text: string): Promise<void> {
61
+ // ThrowOnError is false, so a denied/failed prompt surfaces as `.error`
62
+ // rather than a throw — check it so failures aren't silently swallowed.
63
+ const { error } = await this.client.session.prompt({
64
+ path: { id: sessionId },
65
+ body: { parts: [{ type: 'text', text }] },
66
+ headers: this.headers(userId),
67
+ });
68
+ if (error) throw new Error(`prompt failed: ${JSON.stringify(error)}`);
69
+ }
70
+
71
+ async replyPermission(userId: string, requestID: string, reply: 'once' | 'always' | 'reject', message?: string): Promise<boolean> {
72
+ const body: Record<string, unknown> = { reply };
73
+ if (message) body.message = message;
74
+ const response = await this.fetchFn(`${this.base}/permission/${requestID}/reply`, {
75
+ method: 'POST',
76
+ headers: {
77
+ ...this.headers(userId),
78
+ 'content-type': 'application/json',
79
+ },
80
+ body: JSON.stringify(body),
81
+ });
82
+ if (!response.ok) throw new Error(`replyPermission failed: ${response.status}`);
83
+ return true;
84
+ }
85
+
86
+ async replyQuestion(userId: string, requestID: string, answers: string[][]): Promise<boolean> {
87
+ const response = await this.fetchFn(`${this.base}/question/${requestID}/reply`, {
88
+ method: 'POST',
89
+ headers: {
90
+ ...this.headers(userId),
91
+ 'content-type': 'application/json',
92
+ },
93
+ body: JSON.stringify({ answers }),
94
+ });
95
+ if (!response.ok) throw new Error(`replyQuestion failed: ${response.status}`);
96
+ return true;
97
+ }
98
+
99
+ async rejectQuestion(userId: string, requestID: string): Promise<void> {
100
+ const response = await this.fetchFn(`${this.base}/question/${requestID}/reject`, {
101
+ method: 'POST',
102
+ headers: {
103
+ ...this.headers(userId),
104
+ 'content-type': 'application/json',
105
+ },
106
+ body: '{}',
107
+ });
108
+ if (!response.ok) throw new Error(`rejectQuestion failed: ${response.status}`);
109
+ }
110
+
111
+ async abort(userId: string, sessionId: string): Promise<void> {
112
+ await this.client.session.abort({
113
+ path: { id: sessionId },
114
+ headers: this.headers(userId),
115
+ });
116
+ }
117
+
118
+ async *events(userId: string, signal: AbortSignal): AsyncGenerator<Event> {
119
+ const subscription = await this.client.event.subscribe({
120
+ headers: {
121
+ ...this.headers(userId),
122
+ accept: 'text/event-stream',
123
+ },
124
+ signal,
125
+ });
126
+ for await (const event of subscription.stream as AsyncIterable<Event>) {
127
+ yield event;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,46 @@
1
+ import { createLogger, parseIdList } from './runtime.ts';
2
+ import type { PermissionResult } from './types.ts';
3
+ import type { PermissionConfig, UserInfo } from "./types.ts";
4
+
5
+ export { parseIdList };
6
+
7
+ const log = createLogger("channel-slack");
8
+
9
+ export function loadPermissionConfig(env: Record<string, string | undefined> = Bun.env): PermissionConfig {
10
+ const config: PermissionConfig = {
11
+ allowedChannels: parseIdList(env.SLACK_ALLOWED_CHANNELS),
12
+ allowedUsers: parseIdList(env.SLACK_ALLOWED_USERS),
13
+ blockedUsers: parseIdList(env.SLACK_BLOCKED_USERS),
14
+ };
15
+
16
+ log.info("permissions_loaded", {
17
+ allowedChannels: config.allowedChannels.size || "unrestricted",
18
+ allowedUsers: config.allowedUsers.size || "unrestricted",
19
+ blockedUsers: config.blockedUsers.size || "none",
20
+ });
21
+
22
+ return config;
23
+ }
24
+
25
+ export function checkPermissions(config: PermissionConfig, user: UserInfo): PermissionResult {
26
+ const { userId, channelId, username } = user;
27
+
28
+ if (userId && config.blockedUsers.has(userId)) {
29
+ log.warn("permission_denied", { userId, username, reason: "blocked_user" });
30
+ return { allowed: false, reason: "user_blocked" };
31
+ }
32
+
33
+ if (config.allowedUsers.size > 0) {
34
+ if (!userId || !config.allowedUsers.has(userId)) {
35
+ return { allowed: false, reason: "user_not_allowed" };
36
+ }
37
+ }
38
+
39
+ if (config.allowedChannels.size > 0) {
40
+ if (!channelId || !config.allowedChannels.has(channelId)) {
41
+ return { allowed: false, reason: "channel_not_allowed" };
42
+ }
43
+ }
44
+
45
+ return { allowed: true };
46
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ export { OcClient, type OcClientOptions, type OcSession } from './opencode.ts';
4
+ export {
5
+ asRaw,
6
+ extractPermissionAsk,
7
+ extractQuestionAsk,
8
+ extractTextDelta,
9
+ extractToolUpdate,
10
+ isSessionError,
11
+ isTurnEnd,
12
+ partSnapshotType,
13
+ statusName,
14
+ TURN_IDLE_STATUSES,
15
+ type PermissionAsk,
16
+ type QuestionAsk,
17
+ type QuestionInfo,
18
+ type QuestionOption,
19
+ type RawEvent,
20
+ type ToolUpdate,
21
+ } from './oc-events.ts';
22
+
23
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
24
+
25
+ export function createLogger(service: string) {
26
+ function log(level: LogLevel, msg: string, extra?: Record<string, unknown>): void {
27
+ const entry = { ts: new Date().toISOString(), level, service, msg, ...(extra ? { extra } : {}) };
28
+ (level === 'error' || level === 'warn' ? console.error : console.log)(JSON.stringify(entry));
29
+ }
30
+
31
+ return {
32
+ info: (msg: string, extra?: Record<string, unknown>) => log('info', msg, extra),
33
+ warn: (msg: string, extra?: Record<string, unknown>) => log('warn', msg, extra),
34
+ error: (msg: string, extra?: Record<string, unknown>) => log('error', msg, extra),
35
+ debug: (msg: string, extra?: Record<string, unknown>) => log('debug', msg, extra),
36
+ };
37
+ }
38
+
39
+ export class SecretFileError extends Error {
40
+ constructor(public readonly envKey: string, reason: string) {
41
+ super(`${envKey}: ${reason}`);
42
+ this.name = 'SecretFileError';
43
+ }
44
+ }
45
+
46
+ function stripTrailingNewline(value: string): string {
47
+ return value.replace(/[\r\n]+$/, '');
48
+ }
49
+
50
+ export function readRequiredSecretFile(envKey: string, env: Record<string, string | undefined> = Bun.env): string {
51
+ const path = env[envKey]?.trim();
52
+ if (!path) {
53
+ throw new SecretFileError(envKey, 'secret file env var is not set');
54
+ }
55
+
56
+ let value: string;
57
+ try {
58
+ value = stripTrailingNewline(readFileSync(path, 'utf8'));
59
+ } catch {
60
+ throw new SecretFileError(envKey, 'secret file is unreadable');
61
+ }
62
+
63
+ if (!value) {
64
+ throw new SecretFileError(envKey, 'secret file is empty');
65
+ }
66
+
67
+ return value;
68
+ }
69
+
70
+ export function parseIdList(raw: string | undefined): Set<string> {
71
+ if (!raw) return new Set();
72
+ return new Set(
73
+ raw
74
+ .split(',')
75
+ .map((s) => s.trim())
76
+ .filter(Boolean),
77
+ );
78
+ }
79
+
80
+ export function splitMessage(content: string, maxLength: number): string[] {
81
+ if (!content) return [];
82
+ if (content.length <= maxLength) return [content];
83
+
84
+ const chunks: string[] = [];
85
+ let remaining = content;
86
+
87
+ while (remaining.length > 0) {
88
+ if (remaining.length <= maxLength) {
89
+ chunks.push(remaining);
90
+ break;
91
+ }
92
+
93
+ let splitIndex = maxLength;
94
+ const beforeSplit = remaining.slice(0, splitIndex);
95
+ const codeBlockStarts = (beforeSplit.match(/```/g) || []).length;
96
+ const inCodeBlock = codeBlockStarts % 2 === 1;
97
+
98
+ if (inCodeBlock) {
99
+ const newlineIndex = remaining.lastIndexOf('\n', splitIndex);
100
+ if (newlineIndex > maxLength / 2) splitIndex = newlineIndex;
101
+ } else {
102
+ const doubleNewline = remaining.lastIndexOf('\n\n', splitIndex);
103
+ const singleNewline = remaining.lastIndexOf('\n', splitIndex);
104
+ if (doubleNewline > maxLength / 2) splitIndex = doubleNewline + 2;
105
+ else if (singleNewline > maxLength / 2) splitIndex = singleNewline + 1;
106
+ }
107
+
108
+ let chunk = remaining.slice(0, splitIndex);
109
+ remaining = remaining.slice(splitIndex);
110
+
111
+ const chunkCodeBlocks = (chunk.match(/```/g) || []).length;
112
+ if (chunkCodeBlocks % 2 === 1) {
113
+ chunk += '\n```';
114
+ const match = chunk.match(/```(\w+)?/);
115
+ const lang = match?.[1] || '';
116
+ remaining = '```' + lang + '\n' + remaining;
117
+ }
118
+
119
+ chunks.push(chunk.trim());
120
+ }
121
+
122
+ return chunks.filter((c) => c.length > 0);
123
+ }
124
+
125
+ type SessionTask = {
126
+ run: () => Promise<void>;
127
+ onQueued?: () => Promise<void>;
128
+ };
129
+
130
+ type SessionState = {
131
+ processing: boolean;
132
+ queue: SessionTask[];
133
+ };
134
+
135
+ export class ConversationQueue {
136
+ private states = new Map<string, SessionState>();
137
+
138
+ isProcessing(sessionKey: string): boolean {
139
+ return this.states.get(sessionKey)?.processing ?? false;
140
+ }
141
+
142
+ queuedCount(sessionKey: string): number {
143
+ return this.states.get(sessionKey)?.queue.length ?? 0;
144
+ }
145
+
146
+ clear(sessionKey: string): number {
147
+ const state = this.states.get(sessionKey);
148
+ if (!state) return 0;
149
+
150
+ const dropped = state.queue.length;
151
+ state.queue.length = 0;
152
+
153
+ if (!state.processing) {
154
+ this.states.delete(sessionKey);
155
+ }
156
+
157
+ return dropped;
158
+ }
159
+
160
+ async runOrQueue(sessionKey: string, task: SessionTask): Promise<'started' | 'queued'> {
161
+ const state = this.states.get(sessionKey) ?? { processing: false, queue: [] };
162
+ this.states.set(sessionKey, state);
163
+
164
+ if (state.processing) {
165
+ state.queue.push(task);
166
+ try {
167
+ await task.onQueued?.();
168
+ } catch {
169
+ }
170
+ return 'queued';
171
+ }
172
+
173
+ state.processing = true;
174
+ try {
175
+ await task.run();
176
+ } finally {
177
+ state.processing = false;
178
+ if (state.queue.length > 0) {
179
+ void this.drain(sessionKey);
180
+ } else {
181
+ this.states.delete(sessionKey);
182
+ }
183
+ }
184
+
185
+ return 'started';
186
+ }
187
+
188
+ private async drain(sessionKey: string): Promise<void> {
189
+ const state = this.states.get(sessionKey);
190
+ if (!state || state.processing) return;
191
+
192
+ const next = state.queue.shift();
193
+ if (!next) {
194
+ this.states.delete(sessionKey);
195
+ return;
196
+ }
197
+
198
+ state.processing = true;
199
+ try {
200
+ await next.run();
201
+ } catch {
202
+ } finally {
203
+ state.processing = false;
204
+ if (state.queue.length > 0) {
205
+ void this.drain(sessionKey);
206
+ } else {
207
+ this.states.delete(sessionKey);
208
+ }
209
+ }
210
+ }
211
+ }