astrocode-workflow 0.2.0 → 0.2.2

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/ui/inject.ts CHANGED
@@ -1,136 +1,129 @@
1
1
  // src/ui/inject.ts
2
- //
3
- // Deterministic chat injection:
4
- // - Always enqueue
5
- // - Process sequentially (per-process single worker)
6
- // - Retries with backoff
7
- // - flush() lets callers wait until injections are actually sent
8
- //
9
- // IMPORTANT: Callers who need reliability must `await injectChatPrompt(...)`
10
- // or `await flushChatPrompts()` after enqueueing.
11
-
12
- type QueueItem = {
2
+ type InjectionItem = {
13
3
  ctx: any;
14
4
  sessionId: string;
15
5
  text: string;
16
6
  agent?: string;
7
+ attempts: number;
8
+ resolve: () => void;
9
+ reject: (err: any) => void;
17
10
  };
18
11
 
19
- const injectionQueue: QueueItem[] = [];
12
+ const MAX_ATTEMPTS = 4;
13
+ const RETRY_DELAYS_MS = [250, 500, 1000, 2000]; // attempt 1..4
20
14
 
21
- let workerRunning = false;
22
-
23
- // Used to let callers await "queue drained"
24
- let drainWaiters: Array<() => void> = [];
15
+ // Per-session queues so one stuck session doesn't block others
16
+ const queues = new Map<string, InjectionItem[]>();
17
+ const running = new Set<string>();
25
18
 
26
19
  function sleep(ms: number) {
27
- return new Promise<void>((resolve) => setTimeout(resolve, ms));
28
- }
29
-
30
- function resolveDrainWaitersIfIdle() {
31
- if (workerRunning) return;
32
- if (injectionQueue.length !== 0) return;
33
-
34
- const waiters = drainWaiters;
35
- drainWaiters = [];
36
- for (const w of waiters) w();
20
+ return new Promise((r) => setTimeout(r, ms));
37
21
  }
38
22
 
39
- function getPromptApi(ctx: any) {
40
- const fn = ctx?.client?.session?.prompt;
41
- return typeof fn === "function" ? fn.bind(ctx.client.session) : null;
23
+ function getPromptInvoker(ctx: any): { session: any; prompt: Function } {
24
+ const session = ctx?.client?.session;
25
+ const prompt = session?.prompt;
26
+ if (!session || typeof prompt !== "function") {
27
+ throw new Error("API not available (ctx.client.session.prompt)");
28
+ }
29
+ return { session, prompt };
42
30
  }
43
31
 
44
- async function sendWithRetries(opts: QueueItem): Promise<void> {
45
- const { ctx, sessionId, text } = opts;
46
- const agent = opts.agent ?? "Astro";
32
+ async function tryInjectOnce(item: InjectionItem): Promise<void> {
33
+ const { ctx, sessionId, text, agent = "Astro" } = item;
47
34
  const prefixedText = `[${agent}]\n\n${text}`;
48
35
 
49
- if (!sessionId) {
50
- console.warn("[Astrocode] Injection skipped: missing sessionId");
51
- return;
52
- }
36
+ const { session, prompt } = getPromptInvoker(ctx);
53
37
 
54
- const prompt = getPromptApi(ctx);
55
- if (!prompt) {
56
- console.warn("[Astrocode] Injection skipped: ctx.client.session.prompt unavailable");
57
- return;
58
- }
59
-
60
- const maxAttempts = 3;
61
- let attempt = 0;
62
-
63
- while (attempt < maxAttempts) {
64
- attempt += 1;
65
- try {
66
- await prompt({
67
- path: { id: sessionId },
68
- body: {
69
- parts: [{ type: "text", text: prefixedText }],
70
- agent,
71
- },
72
- });
73
- return;
74
- } catch (err) {
75
- const msg = err instanceof Error ? err.message : String(err);
76
- const isLast = attempt >= maxAttempts;
77
-
78
- if (isLast) {
79
- console.warn(`[Astrocode] Injection failed (final): ${msg}`);
80
- return;
81
- }
82
-
83
- // Exponential backoff + jitter
84
- const base = 150 * Math.pow(2, attempt - 1); // 150, 300, 600
85
- const jitter = Math.floor(Math.random() * 120);
86
- await sleep(base + jitter);
87
- }
88
- }
38
+ // IMPORTANT: force correct `this` binding
39
+ await prompt.call(session, {
40
+ path: { id: sessionId },
41
+ body: {
42
+ parts: [{ type: "text", text: prefixedText }],
43
+ agent,
44
+ },
45
+ });
89
46
  }
90
47
 
91
- async function runWorkerLoop(): Promise<void> {
92
- if (workerRunning) return;
93
- workerRunning = true;
48
+ async function runSessionQueue(sessionId: string) {
49
+ if (running.has(sessionId)) return;
50
+ running.add(sessionId);
94
51
 
95
52
  try {
96
- // Drain sequentially to preserve ordering
97
- while (injectionQueue.length > 0) {
98
- const item = injectionQueue.shift();
99
- if (!item) continue;
100
- await sendWithRetries(item);
53
+ // eslint-disable-next-line no-constant-condition
54
+ while (true) {
55
+ const q = queues.get(sessionId);
56
+ if (!q || q.length === 0) break;
57
+
58
+ const item = q.shift()!;
59
+ try {
60
+ await tryInjectOnce(item);
61
+ item.resolve();
62
+ } catch (err) {
63
+ item.attempts += 1;
64
+
65
+ const msg = err instanceof Error ? err.message : String(err);
66
+ const delay = RETRY_DELAYS_MS[Math.min(item.attempts - 1, RETRY_DELAYS_MS.length - 1)] ?? 2000;
67
+
68
+ if (item.attempts >= MAX_ATTEMPTS) {
69
+ console.warn(
70
+ `[Astrocode] Injection failed permanently after ${item.attempts} attempts: ${msg}`
71
+ );
72
+ item.reject(err);
73
+ continue;
74
+ }
75
+
76
+ console.warn(
77
+ `[Astrocode] Injection attempt ${item.attempts}/${MAX_ATTEMPTS} failed: ${msg}; retrying in ${delay}ms`
78
+ );
79
+
80
+ await sleep(delay);
81
+
82
+ // Requeue at front to preserve order (and avoid starving later messages)
83
+ const q2 = queues.get(sessionId) ?? [];
84
+ q2.unshift(item);
85
+ queues.set(sessionId, q2);
86
+ }
101
87
  }
102
88
  } finally {
103
- workerRunning = false;
104
- resolveDrainWaitersIfIdle();
89
+ running.delete(sessionId);
105
90
  }
106
91
  }
107
92
 
108
93
  /**
109
- * Enqueue an injection and ensure the worker is running.
110
- * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
94
+ * Inject a visible prompt into the conversation.
95
+ * - Deterministic ordering per session
96
+ * - Correct SDK binding (prevents `this._client` undefined)
97
+ * - Awaitable: resolves when delivered, rejects after max retries
111
98
  */
112
- export function enqueueChatPrompt(opts: QueueItem) {
113
- injectionQueue.push(opts);
114
- // Kick worker
115
- void runWorkerLoop();
116
- }
99
+ export async function injectChatPrompt(opts: {
100
+ ctx: any;
101
+ sessionId?: string;
102
+ text: string;
103
+ agent?: string;
104
+ }): Promise<void> {
105
+ const sessionId = opts.sessionId ?? (opts.ctx as any)?.sessionID;
106
+ if (!sessionId) {
107
+ console.warn("[Astrocode] Skipping injection: No sessionId provided");
108
+ return;
109
+ }
117
110
 
118
- /**
119
- * Wait until all queued injections have been processed (sent or exhausted retries).
120
- */
121
- export function flushChatPrompts(): Promise<void> {
122
- if (!workerRunning && injectionQueue.length === 0) return Promise.resolve();
123
- return new Promise<void>((resolve) => {
124
- drainWaiters.push(resolve);
125
- // Ensure worker is running (in case someone enqueued without kick)
126
- void runWorkerLoop();
111
+ return new Promise<void>((resolve, reject) => {
112
+ const item: InjectionItem = {
113
+ ctx: opts.ctx,
114
+ sessionId,
115
+ text: opts.text,
116
+ agent: opts.agent,
117
+ attempts: 0,
118
+ resolve,
119
+ reject,
120
+ };
121
+
122
+ const q = queues.get(sessionId) ?? [];
123
+ q.push(item);
124
+ queues.set(sessionId, q);
125
+
126
+ // Fire worker (don't await here; caller awaits the returned Promise)
127
+ void runSessionQueue(sessionId);
127
128
  });
128
129
  }
129
-
130
- /**
131
- * Deterministic helper: enqueue + flush (recommended for stage boundaries).
132
- */
133
- export async function injectChatPrompt(opts: QueueItem): Promise<void> {
134
- enqueueChatPrompt(opts);
135
- await flushChatPrompts();
136
- }