edsger 0.56.2 → 0.57.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.
Files changed (77) hide show
  1. package/dist/api/chat.js +55 -2
  2. package/dist/api/cross-product.d.ts +8 -1
  3. package/dist/api/cross-product.js +44 -1
  4. package/dist/api/intelligence.js +98 -0
  5. package/dist/api/issues/get-issue.js +26 -0
  6. package/dist/api/issues/issue-utils.js +52 -0
  7. package/dist/api/issues/test-cases.js +89 -14
  8. package/dist/api/issues/update-issue.js +46 -8
  9. package/dist/api/issues/user-stories.js +89 -14
  10. package/dist/api/products/test-cases.d.ts +18 -0
  11. package/dist/api/products/test-cases.js +51 -0
  12. package/dist/api/products.js +21 -0
  13. package/dist/api/release-test-cases.js +38 -0
  14. package/dist/api/releases.js +86 -0
  15. package/dist/api/tasks.js +41 -4
  16. package/dist/api/test-reports.js +22 -4
  17. package/dist/api/user-psychology.d.ts +101 -0
  18. package/dist/api/user-psychology.js +143 -0
  19. package/dist/auth/auth-store.d.ts +33 -0
  20. package/dist/auth/auth-store.js +39 -0
  21. package/dist/commands/agent-workflow/chat-worker.js +187 -15
  22. package/dist/commands/agent-workflow/processor.d.ts +11 -0
  23. package/dist/commands/agent-workflow/processor.js +81 -2
  24. package/dist/commands/product-test-cases/index.d.ts +12 -0
  25. package/dist/commands/product-test-cases/index.js +40 -0
  26. package/dist/commands/screen-flow/index.d.ts +16 -0
  27. package/dist/commands/screen-flow/index.js +45 -0
  28. package/dist/commands/user-psychology/index.d.ts +7 -0
  29. package/dist/commands/user-psychology/index.js +51 -0
  30. package/dist/index.js +65 -0
  31. package/dist/phases/analyze-logs/index.js +27 -6
  32. package/dist/phases/bug-fixing/context-fetcher.js +26 -5
  33. package/dist/phases/find-features/index.js +53 -9
  34. package/dist/phases/find-shared/mcp.js +21 -0
  35. package/dist/phases/growth-analysis/context.d.ts +5 -3
  36. package/dist/phases/growth-analysis/context.js +52 -5
  37. package/dist/phases/output-contracts.js +129 -0
  38. package/dist/phases/pr-resolve/github-reply.d.ts +5 -2
  39. package/dist/phases/pr-resolve/github-reply.js +19 -3
  40. package/dist/phases/pr-resolve/index.js +19 -5
  41. package/dist/phases/pr-resolve/prompts.js +17 -18
  42. package/dist/phases/product-test-cases/index.d.ts +25 -0
  43. package/dist/phases/product-test-cases/index.js +174 -0
  44. package/dist/phases/product-test-cases/prompts.d.ts +24 -0
  45. package/dist/phases/product-test-cases/prompts.js +80 -0
  46. package/dist/phases/product-test-cases/types.d.ts +17 -0
  47. package/dist/phases/product-test-cases/types.js +27 -0
  48. package/dist/phases/screen-flow/index.d.ts +23 -0
  49. package/dist/phases/screen-flow/index.js +229 -0
  50. package/dist/phases/screen-flow/prompts.d.ts +19 -0
  51. package/dist/phases/screen-flow/prompts.js +39 -0
  52. package/dist/phases/screen-flow/theme.d.ts +19 -0
  53. package/dist/phases/screen-flow/theme.js +182 -0
  54. package/dist/phases/screen-flow/types.d.ts +130 -0
  55. package/dist/phases/screen-flow/types.js +66 -0
  56. package/dist/phases/user-psychology/agent.d.ts +16 -0
  57. package/dist/phases/user-psychology/agent.js +105 -0
  58. package/dist/phases/user-psychology/context.d.ts +10 -0
  59. package/dist/phases/user-psychology/context.js +65 -0
  60. package/dist/phases/user-psychology/index.d.ts +18 -0
  61. package/dist/phases/user-psychology/index.js +96 -0
  62. package/dist/phases/user-psychology/prompts.d.ts +2 -0
  63. package/dist/phases/user-psychology/prompts.js +41 -0
  64. package/dist/services/audit-logs.js +67 -9
  65. package/dist/services/branches.js +90 -14
  66. package/dist/services/phase-ratings.js +71 -9
  67. package/dist/services/product-logs.js +65 -5
  68. package/dist/services/pull-requests.js +74 -14
  69. package/dist/skills/phase/screen-flow/SKILL.md +78 -0
  70. package/dist/skills/phase/user-psychology/SKILL.md +135 -0
  71. package/dist/supabase/client.d.ts +23 -0
  72. package/dist/supabase/client.js +90 -0
  73. package/dist/system/session-manager.js +97 -24
  74. package/dist/types/index.d.ts +3 -0
  75. package/dist/utils/logger.js +24 -4
  76. package/package.json +5 -4
  77. package/vitest.config.ts +1 -0
@@ -0,0 +1,143 @@
1
+ import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
2
+ import { logError, logInfo } from '../utils/logger.js';
3
+ import { callMcpEndpoint } from './mcp-client.js';
4
+ /**
5
+ * Update an existing psychology analysis row with the AI-produced result.
6
+ * The CLI always reserves the row via the desktop UI first (status='pending')
7
+ * and then fills it in here — so this path expects a real analysisId.
8
+ */
9
+ export async function updateUserPsychologyAnalysis(analysisId, updates, verbose) {
10
+ if (!hasSupabaseSession()) {
11
+ logError('Cannot save psychology analysis: no Supabase session. Sign in to the desktop app.');
12
+ return null;
13
+ }
14
+ if (verbose) {
15
+ logInfo(`Updating user psychology analysis: ${analysisId}`);
16
+ }
17
+ try {
18
+ const { data, error } = await getSupabase()
19
+ .from('user_psychology_analyses')
20
+ .update(updates)
21
+ .eq('id', analysisId)
22
+ .select()
23
+ .single();
24
+ if (error) {
25
+ throw new Error(error.message);
26
+ }
27
+ return data;
28
+ }
29
+ catch (error) {
30
+ logError(`Failed to update user psychology analysis: ${error instanceof Error ? error.message : String(error)}`);
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Fetch the most recent completed psychology profile for a product.
36
+ * Used by the growth-analysis phase to ground content in real personas.
37
+ * Tries direct Supabase first; falls back to the MCP endpoint when the
38
+ * CLI is running without a synced session.
39
+ */
40
+ export async function getLatestUserPsychologyAnalysis(productId, verbose) {
41
+ if (verbose) {
42
+ logInfo(`Fetching latest psychology profile for product: ${productId}`);
43
+ }
44
+ if (hasSupabaseSession()) {
45
+ try {
46
+ const { data, error } = await getSupabase()
47
+ .from('user_psychology_analyses')
48
+ .select('*')
49
+ .eq('product_id', productId)
50
+ .eq('status', 'completed')
51
+ .order('created_at', { ascending: false })
52
+ .limit(1)
53
+ .maybeSingle();
54
+ if (error) {
55
+ throw new Error(error.message);
56
+ }
57
+ return data ?? null;
58
+ }
59
+ catch (error) {
60
+ if (verbose) {
61
+ logError(`Direct Supabase fetch failed, falling back to MCP: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ }
64
+ }
65
+ try {
66
+ const result = (await callMcpEndpoint('user_psychology/latest', {
67
+ product_id: productId,
68
+ }));
69
+ const text = result.content?.[0]?.text || 'null';
70
+ try {
71
+ const parsed = JSON.parse(text);
72
+ return parsed ?? null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (verbose) {
80
+ logError(`Failed to fetch latest psychology via MCP: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ return null;
83
+ }
84
+ }
85
+ export function classifyPsychologyFailure(reason) {
86
+ const r = reason.toLowerCase();
87
+ if (r.includes('parse') || r.includes('json')) {
88
+ return 'parse';
89
+ }
90
+ if (r.includes('no analysis results') || r.includes('no result')) {
91
+ return 'no_result';
92
+ }
93
+ if (r.includes('quota') ||
94
+ r.includes('rate limit') ||
95
+ r.includes('insufficient_quota')) {
96
+ return 'ai_quota';
97
+ }
98
+ if (r.includes('unauthorized') ||
99
+ r.includes('401') ||
100
+ r.includes('api key')) {
101
+ return 'ai_auth';
102
+ }
103
+ if (r.includes('econn') ||
104
+ r.includes('etimedout') ||
105
+ r.includes('network') ||
106
+ r.includes('fetch')) {
107
+ return 'network';
108
+ }
109
+ if (r.includes('clone') || r.includes('git')) {
110
+ return 'repo_clone';
111
+ }
112
+ if (r.includes('context fetch')) {
113
+ return 'context_fetch';
114
+ }
115
+ return 'unknown';
116
+ }
117
+ /**
118
+ * Mark an analysis as failed. Lets the desktop UI surface the error state
119
+ * instead of leaving a stuck "pending" row. The failure code is embedded
120
+ * as a leading tag in analysis_content so the UI can render it without a
121
+ * schema migration.
122
+ */
123
+ export async function markUserPsychologyAnalysisFailed(analysisId, reason, verbose) {
124
+ if (!hasSupabaseSession()) {
125
+ return;
126
+ }
127
+ const code = classifyPsychologyFailure(reason);
128
+ const body = `[${code}] ${reason}`.slice(0, 4000);
129
+ try {
130
+ await getSupabase()
131
+ .from('user_psychology_analyses')
132
+ .update({
133
+ status: 'failed',
134
+ analysis_content: body,
135
+ })
136
+ .eq('id', analysisId);
137
+ }
138
+ catch (error) {
139
+ if (verbose) {
140
+ logError(`Failed to mark psychology analysis failed: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
143
+ }
@@ -13,6 +13,18 @@ export interface AuthConfig {
13
13
  loggedInAt: string;
14
14
  /** Edsger.ai base URL */
15
15
  edsgerBaseUrl?: string;
16
+ /** Supabase project URL (e.g. https://xxx.supabase.co). Written by the
17
+ * desktop app so the CLI can talk to Supabase directly (RLS-gated) for
18
+ * endpoints that have been migrated off the MCP edge function. */
19
+ supabaseUrl?: string;
20
+ /** Supabase anon (publishable) key for the same project. */
21
+ supabaseAnonKey?: string;
22
+ /** Supabase user JWT. Rotated by desktop-app on TOKEN_REFRESHED. */
23
+ accessToken?: string;
24
+ /** Supabase refresh token paired with accessToken. */
25
+ refreshToken?: string;
26
+ /** Supabase auth user ID. */
27
+ userId?: string;
16
28
  }
17
29
  /**
18
30
  * Save auth configuration to ~/.edsger/auth.json
@@ -24,6 +36,13 @@ export declare function saveAuth(config: AuthConfig): void;
24
36
  * Results are cached in memory to avoid repeated disk reads.
25
37
  */
26
38
  export declare function loadAuth(): AuthConfig | null;
39
+ /**
40
+ * Invalidate the in-memory auth cache so the next loadAuth() re-reads from
41
+ * disk. Useful when an external process (typically the desktop-app) has
42
+ * rotated tokens in auth.json and we want subsequent getters to see the
43
+ * fresh values.
44
+ */
45
+ export declare function invalidateAuthCache(): void;
27
46
  /**
28
47
  * Clear stored auth (logout)
29
48
  */
@@ -42,6 +61,20 @@ export declare function getMcpServerUrl(): string | undefined;
42
61
  * Environment variable takes precedence
43
62
  */
44
63
  export declare function getMcpToken(): string | undefined;
64
+ /**
65
+ * Get the Supabase project URL. Returns undefined when the desktop-app has
66
+ * not synced a session — callers MUST handle the missing case (typically by
67
+ * falling back to the MCP edge function path).
68
+ */
69
+ export declare function getSupabaseUrl(): string | undefined;
70
+ /** Get the Supabase anon (publishable) key. */
71
+ export declare function getSupabaseAnonKey(): string | undefined;
72
+ /** Get the Supabase user JWT (access token). */
73
+ export declare function getAccessToken(): string | undefined;
74
+ /** Get the Supabase refresh token. */
75
+ export declare function getRefreshToken(): string | undefined;
76
+ /** Get the Supabase auth user ID. */
77
+ export declare function getUserId(): string | undefined;
45
78
  /**
46
79
  * Get the Edsger base URL (defaults to https://edsger.ai)
47
80
  */
@@ -58,6 +58,15 @@ export function loadAuth() {
58
58
  return null;
59
59
  }
60
60
  }
61
+ /**
62
+ * Invalidate the in-memory auth cache so the next loadAuth() re-reads from
63
+ * disk. Useful when an external process (typically the desktop-app) has
64
+ * rotated tokens in auth.json and we want subsequent getters to see the
65
+ * fresh values.
66
+ */
67
+ export function invalidateAuthCache() {
68
+ _authCache = undefined;
69
+ }
61
70
  /**
62
71
  * Clear stored auth (logout)
63
72
  */
@@ -102,6 +111,36 @@ export function getMcpToken() {
102
111
  const auth = loadAuth();
103
112
  return auth?.mcpToken;
104
113
  }
114
+ /**
115
+ * Get the Supabase project URL. Returns undefined when the desktop-app has
116
+ * not synced a session — callers MUST handle the missing case (typically by
117
+ * falling back to the MCP edge function path).
118
+ */
119
+ export function getSupabaseUrl() {
120
+ if (process.env.EDSGER_SUPABASE_URL) {
121
+ return process.env.EDSGER_SUPABASE_URL;
122
+ }
123
+ return loadAuth()?.supabaseUrl;
124
+ }
125
+ /** Get the Supabase anon (publishable) key. */
126
+ export function getSupabaseAnonKey() {
127
+ if (process.env.EDSGER_SUPABASE_ANON_KEY) {
128
+ return process.env.EDSGER_SUPABASE_ANON_KEY;
129
+ }
130
+ return loadAuth()?.supabaseAnonKey;
131
+ }
132
+ /** Get the Supabase user JWT (access token). */
133
+ export function getAccessToken() {
134
+ return loadAuth()?.accessToken;
135
+ }
136
+ /** Get the Supabase refresh token. */
137
+ export function getRefreshToken() {
138
+ return loadAuth()?.refreshToken;
139
+ }
140
+ /** Get the Supabase auth user ID. */
141
+ export function getUserId() {
142
+ return loadAuth()?.userId;
143
+ }
105
144
  /**
106
145
  * Get the Edsger base URL (defaults to https://edsger.ai)
107
146
  */
@@ -16,6 +16,7 @@
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { claimPendingMessages, getIssueChannel, listChannels, sendSystemMessage, } from '../../api/chat.js';
18
18
  import { processHumanMessages, processPhaseCompletion, processProductHumanMessages, } from '../../phases/chat-processor/index.js';
19
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
19
20
  function sendMessage(msg) {
20
21
  if (process.send) {
21
22
  process.send(msg);
@@ -37,11 +38,17 @@ const WORKER_ID = `chat-worker-${process.pid}-${randomUUID().slice(0, 8)}`;
37
38
  const activeChannels = new Map();
38
39
  // Track active product channels (productId -> channelId)
39
40
  const activeProductChannels = new Map();
41
+ const channelMetaById = new Map();
40
42
  // Track issue repo paths (issueId -> repoPath) for setting cwd on AI agent
41
43
  const issueRepoPaths = new Map();
42
- // Poll interval in ms
44
+ // Active Realtime channel — set when the Supabase session is available.
45
+ let realtimeChannel = null;
46
+ // Fallback poll interval (legacy path when no Supabase session — desktop-app
47
+ // not yet rolled out / standalone CLI). Realtime path needs no timer.
43
48
  const POLL_INTERVAL = 5000;
44
- // Refresh channel list every N polls (~30s at 5s intervals)
49
+ // In the legacy poll path: refresh the channel list every N polls (~30s).
50
+ // The Realtime path discovers new channels via the chat_channels
51
+ // subscription, so this constant is unused there.
45
52
  const CHANNEL_REFRESH_INTERVAL = 6;
46
53
  let pollCount = 0;
47
54
  // ============================================================
@@ -91,7 +98,7 @@ function startPolling() {
91
98
  return;
92
99
  }
93
100
  isRunning = true;
94
- log('info', 'Chat worker started polling');
101
+ log('info', 'Chat worker started polling (MCP fallback path)');
95
102
  // Initial poll
96
103
  pollForMessages().catch((error) => {
97
104
  log('error', `Initial poll error: ${error instanceof Error ? error.message : String(error)}`);
@@ -109,7 +116,8 @@ function stopPolling() {
109
116
  clearInterval(pollTimer);
110
117
  pollTimer = null;
111
118
  }
112
- log('info', 'Chat worker stopped polling');
119
+ stopRealtime();
120
+ log('info', 'Chat worker stopped');
113
121
  }
114
122
  // ============================================================
115
123
  // Event Handlers
@@ -119,8 +127,21 @@ async function handleInit(msg) {
119
127
  ({ config } = msg);
120
128
  verbose = msg.verbose ?? false;
121
129
  log('info', `Chat worker initialized (id: ${WORKER_ID})`);
122
- // Load existing channels before starting the poll loop
130
+ // Seed the channel cache before subscriptions/polling so dispatch knows
131
+ // which kind (issue vs product) a chat_messages event belongs to.
123
132
  await refreshChannels();
133
+ // Drain any messages that landed while we were offline.
134
+ isRunning = true;
135
+ for (const channelId of channelMetaById.keys()) {
136
+ void claimAndProcess(channelId);
137
+ }
138
+ // Prefer Realtime over polling when the Supabase session is available —
139
+ // eliminates the 5s/N-channel polling load entirely.
140
+ if (startRealtime()) {
141
+ log('info', 'Chat worker running in Realtime mode');
142
+ return;
143
+ }
144
+ log('info', 'No Supabase session — chat worker falling back to MCP polling mode');
124
145
  startPolling();
125
146
  }
126
147
  async function handlePhaseCompleted(msg) {
@@ -185,8 +206,10 @@ async function handleIssueDone(msg) {
185
206
  // ============================================================
186
207
  /**
187
208
  * Refresh the active channels list from the server.
188
- * Called on init and periodically during polling to discover
189
- * newly created channels (e.g., when a user opens an issue chat on the web).
209
+ * Called on init (and, in the legacy poll path, periodically) to discover
210
+ * newly created channels. In the Realtime path the chat_channels INSERT
211
+ * subscription keeps the map fresh; refreshChannels is still called once at
212
+ * startup to seed it.
190
213
  */
191
214
  async function refreshChannels() {
192
215
  try {
@@ -197,17 +220,27 @@ async function refreshChannels() {
197
220
  ]);
198
221
  let added = 0;
199
222
  for (const channel of issueChannels) {
200
- if (channel.channel_ref_id &&
201
- !activeChannels.has(channel.channel_ref_id)) {
202
- activeChannels.set(channel.channel_ref_id, channel.id);
203
- added++;
223
+ if (channel.channel_ref_id) {
224
+ if (!activeChannels.has(channel.channel_ref_id)) {
225
+ activeChannels.set(channel.channel_ref_id, channel.id);
226
+ added++;
227
+ }
228
+ channelMetaById.set(channel.id, {
229
+ kind: 'issue',
230
+ refId: channel.channel_ref_id,
231
+ });
204
232
  }
205
233
  }
206
234
  for (const channel of productChannels) {
207
- if (channel.channel_ref_id &&
208
- !activeProductChannels.has(channel.channel_ref_id)) {
209
- activeProductChannels.set(channel.channel_ref_id, channel.id);
210
- added++;
235
+ if (channel.channel_ref_id) {
236
+ if (!activeProductChannels.has(channel.channel_ref_id)) {
237
+ activeProductChannels.set(channel.channel_ref_id, channel.id);
238
+ added++;
239
+ }
240
+ channelMetaById.set(channel.id, {
241
+ kind: 'product',
242
+ refId: channel.channel_ref_id,
243
+ });
211
244
  }
212
245
  }
213
246
  if (added > 0) {
@@ -219,6 +252,144 @@ async function refreshChannels() {
219
252
  log('error', `Failed to refresh channels: ${msg}`);
220
253
  }
221
254
  }
255
+ /** Register a newly-discovered channel (typically from a Realtime event). */
256
+ function registerChannel(channel) {
257
+ if (!channel.channel_ref_id) {
258
+ return;
259
+ }
260
+ if (channel.channel_type === 'issue') {
261
+ if (!activeChannels.has(channel.channel_ref_id)) {
262
+ activeChannels.set(channel.channel_ref_id, channel.id);
263
+ log('info', `New issue channel discovered: ${channel.channel_ref_id} (${channel.id})`);
264
+ }
265
+ channelMetaById.set(channel.id, {
266
+ kind: 'issue',
267
+ refId: channel.channel_ref_id,
268
+ });
269
+ }
270
+ else if (channel.channel_type === 'product') {
271
+ if (!activeProductChannels.has(channel.channel_ref_id)) {
272
+ activeProductChannels.set(channel.channel_ref_id, channel.id);
273
+ log('info', `New product channel discovered: ${channel.channel_ref_id} (${channel.id})`);
274
+ }
275
+ channelMetaById.set(channel.id, {
276
+ kind: 'product',
277
+ refId: channel.channel_ref_id,
278
+ });
279
+ }
280
+ }
281
+ /**
282
+ * Dispatch claim+process for a single channel. Called both from Realtime
283
+ * INSERT events on chat_messages and (in the legacy path) from the poll loop.
284
+ */
285
+ async function claimAndProcess(channelId) {
286
+ if (!config) {
287
+ return;
288
+ }
289
+ const meta = channelMetaById.get(channelId);
290
+ if (!meta) {
291
+ // Unknown channel — could be a fresh channel we haven't seeded yet.
292
+ // Skip rather than fetch synchronously; the chat_channels INSERT
293
+ // subscription will populate it momentarily and the next message event
294
+ // will pick it up.
295
+ return;
296
+ }
297
+ try {
298
+ const claimed = await claimPendingMessages(channelId, WORKER_ID);
299
+ if (claimed.length === 0) {
300
+ return;
301
+ }
302
+ if (meta.kind === 'issue') {
303
+ log('info', `Claimed ${claimed.length} message(s) for issue ${meta.refId} (worker: ${WORKER_ID})`);
304
+ const repoPath = issueRepoPaths.get(meta.refId);
305
+ await processHumanMessages(claimed, meta.refId, config, verbose, repoPath);
306
+ }
307
+ else {
308
+ log('info', `Claimed ${claimed.length} message(s) for product ${meta.refId} (worker: ${WORKER_ID})`);
309
+ await processProductHumanMessages(claimed, meta.refId, config, verbose);
310
+ }
311
+ }
312
+ catch (error) {
313
+ const msg = error instanceof Error ? error.message : String(error);
314
+ log('error', `Error processing channel ${channelId}: ${msg}`);
315
+ }
316
+ }
317
+ /**
318
+ * Bring up the Realtime subscription. Listens for:
319
+ * - chat_messages INSERT (sender_type=human) → claim+process on that channel
320
+ * - chat_channels INSERT → register newly-created channels
321
+ *
322
+ * Returns true if the subscription was successfully started.
323
+ */
324
+ function startRealtime() {
325
+ if (realtimeChannel) {
326
+ return true;
327
+ }
328
+ if (!hasSupabaseSession()) {
329
+ return false;
330
+ }
331
+ try {
332
+ const supabase = getSupabase();
333
+ realtimeChannel = supabase
334
+ .channel('chat-worker', { config: { broadcast: { ack: false } } })
335
+ .on('postgres_changes', {
336
+ event: 'INSERT',
337
+ schema: 'public',
338
+ table: 'chat_messages',
339
+ filter: 'sender_type=eq.human',
340
+ }, (payload) => {
341
+ // RLS filters out events from channels the user isn't in, so any
342
+ // event reaching us belongs to a channel we have access to.
343
+ const row = payload.new;
344
+ if (!row.channel_id || row.is_processed) {
345
+ return;
346
+ }
347
+ void claimAndProcess(row.channel_id);
348
+ })
349
+ .on('postgres_changes', {
350
+ event: 'INSERT',
351
+ schema: 'public',
352
+ table: 'chat_channels',
353
+ }, (payload) => {
354
+ registerChannel(payload.new);
355
+ // A channel created with messages already in it (unlikely but
356
+ // possible if INSERTs race) deserves an immediate sweep.
357
+ const id = payload.new.id;
358
+ if (id) {
359
+ void claimAndProcess(id);
360
+ }
361
+ })
362
+ .subscribe((status) => {
363
+ if (status === 'SUBSCRIBED') {
364
+ log('info', 'Realtime subscription active (chat_messages, chat_channels)');
365
+ }
366
+ else if (status === 'CHANNEL_ERROR' ||
367
+ status === 'TIMED_OUT' ||
368
+ status === 'CLOSED') {
369
+ log('warning', `Realtime channel status: ${status}`);
370
+ // supabase-js auto-reconnects; no manual reschedule needed here.
371
+ }
372
+ });
373
+ return true;
374
+ }
375
+ catch (error) {
376
+ const msg = error instanceof Error ? error.message : String(error);
377
+ log('error', `Failed to start Realtime subscription: ${msg}`);
378
+ return false;
379
+ }
380
+ }
381
+ function stopRealtime() {
382
+ if (!realtimeChannel) {
383
+ return;
384
+ }
385
+ try {
386
+ void getSupabase().removeChannel(realtimeChannel);
387
+ }
388
+ catch {
389
+ // Best-effort; supabase may already be torn down.
390
+ }
391
+ realtimeChannel = null;
392
+ }
222
393
  async function ensureIssueChannel(issueId) {
223
394
  const cachedChannel = activeChannels.get(issueId);
224
395
  if (cachedChannel) {
@@ -227,6 +398,7 @@ async function ensureIssueChannel(issueId) {
227
398
  try {
228
399
  const channel = await getIssueChannel(issueId);
229
400
  activeChannels.set(issueId, channel.id);
401
+ channelMetaById.set(channel.id, { kind: 'issue', refId: issueId });
230
402
  log('info', `Registered channel ${channel.id} for issue ${issueId}`);
231
403
  return channel.id;
232
404
  }
@@ -35,8 +35,19 @@ export declare class AgentWorkflowProcessor {
35
35
  private activeWorkers;
36
36
  /** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
37
37
  private chatWorker?;
38
+ /** Realtime subscription on `issues` — wakes processNextIssues when an
39
+ * issue flips to ready_for_ai, so the 30s poll can be relaxed. */
40
+ private issuesRealtimeChannel;
38
41
  constructor(options: AgentWorkflowOptions, config: EdsgerConfig);
39
42
  start(): Promise<void>;
43
+ /**
44
+ * Subscribe to `issues` UPDATE/INSERT events and trigger processNextIssues
45
+ * when a row arrives with `status='ready_for_ai'` assigned to this user.
46
+ *
47
+ * Returns true if the subscription was successfully started. RLS on `issues`
48
+ * already constrains delivery to products the user has access to.
49
+ */
50
+ private startIssuesRealtime;
40
51
  private startChatWorker;
41
52
  /** Send a message to the chat worker via IPC */
42
53
  private notifyChatWorker;
@@ -14,7 +14,9 @@ import { listAllReadyIssues, } from '../../api/cross-product.js';
14
14
  import { getGitHubConfig } from '../../api/github.js';
15
15
  import { claimNextIssue, getIssue } from '../../api/issues/index.js';
16
16
  import { callMcpEndpoint } from '../../api/mcp-client.js';
17
+ import { getUserId } from '../../auth/auth-store.js';
17
18
  import { WorkerTimeoutError } from '../../errors/index.js';
19
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
18
20
  import { sendHeartbeat, shouldProcess } from '../../system/session-manager.js';
19
21
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
20
22
  import { cleanupIssueRepo, cloneIssueRepo, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
@@ -45,6 +47,9 @@ export class AgentWorkflowProcessor {
45
47
  activeWorkers = new Map();
46
48
  /** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
47
49
  chatWorker;
50
+ /** Realtime subscription on `issues` — wakes processNextIssues when an
51
+ * issue flips to ready_for_ai, so the 30s poll can be relaxed. */
52
+ issuesRealtimeChannel = null;
48
53
  constructor(options, config) {
49
54
  const wf = config.workflow;
50
55
  this.options = {
@@ -67,12 +72,75 @@ export class AgentWorkflowProcessor {
67
72
  this.startChatWorker();
68
73
  // Initial issue check
69
74
  await this.processNextIssues();
70
- // Set up polling
75
+ // Set up Realtime subscription on `issues` so a status flip to
76
+ // 'ready_for_ai' triggers an immediate processing pass instead of
77
+ // waiting for the next poll tick. Falls back to the timer-only path
78
+ // when the Supabase session isn't available yet.
79
+ const realtimeOk = this.startIssuesRealtime();
80
+ // Keep the timer as a safety net (Realtime can drop events on
81
+ // reconnect / publication lag). When Realtime is active we stretch the
82
+ // interval substantially — the timer is purely a backstop now.
83
+ const safetyNetInterval = realtimeOk
84
+ ? Math.max(this.options.pollInterval, 5 * 60_000)
85
+ : this.options.pollInterval;
71
86
  this.pollTimer = setInterval(() => {
72
87
  this.processNextIssues().catch((error) => {
73
88
  logError(`Polling error: ${error instanceof Error ? error.message : String(error)}`);
74
89
  });
75
- }, this.options.pollInterval);
90
+ }, safetyNetInterval);
91
+ }
92
+ /**
93
+ * Subscribe to `issues` UPDATE/INSERT events and trigger processNextIssues
94
+ * when a row arrives with `status='ready_for_ai'` assigned to this user.
95
+ *
96
+ * Returns true if the subscription was successfully started. RLS on `issues`
97
+ * already constrains delivery to products the user has access to.
98
+ */
99
+ startIssuesRealtime() {
100
+ if (this.issuesRealtimeChannel) {
101
+ return true;
102
+ }
103
+ const userId = getUserId();
104
+ if (!hasSupabaseSession() || !userId) {
105
+ return false;
106
+ }
107
+ try {
108
+ const supabase = getSupabase();
109
+ const onChange = (payload) => {
110
+ const row = payload.new;
111
+ if (!row) {
112
+ return;
113
+ }
114
+ // Realtime postgres_changes filter syntax only supports a single
115
+ // predicate, so filter the rest in-process.
116
+ if (row.status !== 'ready_for_ai' ||
117
+ (row.developer_id && row.developer_id !== userId)) {
118
+ return;
119
+ }
120
+ this.processNextIssues().catch((error) => {
121
+ logError(`Realtime-triggered processNextIssues failed: ${error instanceof Error ? error.message : String(error)}`);
122
+ });
123
+ };
124
+ this.issuesRealtimeChannel = supabase
125
+ .channel('agent-workflow-issues')
126
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'issues' }, onChange)
127
+ .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'issues' }, onChange)
128
+ .subscribe((status) => {
129
+ if (status === 'SUBSCRIBED') {
130
+ logInfo('Realtime subscription on issues active');
131
+ }
132
+ else if (status === 'CHANNEL_ERROR' ||
133
+ status === 'TIMED_OUT' ||
134
+ status === 'CLOSED') {
135
+ logWarning(`Issues Realtime channel status: ${status}`);
136
+ }
137
+ });
138
+ return true;
139
+ }
140
+ catch (error) {
141
+ logWarning(`Failed to start issues Realtime subscription: ${error instanceof Error ? error.message : String(error)}`);
142
+ return false;
143
+ }
76
144
  }
77
145
  startChatWorker() {
78
146
  try {
@@ -155,6 +223,17 @@ export class AgentWorkflowProcessor {
155
223
  clearInterval(this.pollTimer);
156
224
  this.pollTimer = undefined;
157
225
  }
226
+ // Tear down Realtime first so a late event doesn't kick off new work
227
+ // while we're shutting down workers.
228
+ if (this.issuesRealtimeChannel) {
229
+ try {
230
+ void getSupabase().removeChannel(this.issuesRealtimeChannel);
231
+ }
232
+ catch {
233
+ // Best-effort
234
+ }
235
+ this.issuesRealtimeChannel = null;
236
+ }
158
237
  // Kill chat worker
159
238
  if (this.chatWorker) {
160
239
  try {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CLI command: edsger product-test-cases <productId>
3
+ *
4
+ * Clones the product's repo, asks Claude to draft a product-level regression
5
+ * suite (deduped against existing approved + unapproved cases), saves the new
6
+ * ones as drafts, and cleans up the workspace.
7
+ */
8
+ export interface ProductTestCasesCliOptions {
9
+ branch?: string;
10
+ verbose?: boolean;
11
+ }
12
+ export declare function runProductTestCases(productId: string, options: ProductTestCasesCliOptions): Promise<void>;