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.
- package/dist/api/chat.js +55 -2
- package/dist/api/cross-product.d.ts +8 -1
- package/dist/api/cross-product.js +44 -1
- package/dist/api/intelligence.js +98 -0
- package/dist/api/issues/get-issue.js +26 -0
- package/dist/api/issues/issue-utils.js +52 -0
- package/dist/api/issues/test-cases.js +89 -14
- package/dist/api/issues/update-issue.js +46 -8
- package/dist/api/issues/user-stories.js +89 -14
- package/dist/api/products/test-cases.d.ts +18 -0
- package/dist/api/products/test-cases.js +51 -0
- package/dist/api/products.js +21 -0
- package/dist/api/release-test-cases.js +38 -0
- package/dist/api/releases.js +86 -0
- package/dist/api/tasks.js +41 -4
- package/dist/api/test-reports.js +22 -4
- package/dist/api/user-psychology.d.ts +101 -0
- package/dist/api/user-psychology.js +143 -0
- package/dist/auth/auth-store.d.ts +33 -0
- package/dist/auth/auth-store.js +39 -0
- package/dist/commands/agent-workflow/chat-worker.js +187 -15
- package/dist/commands/agent-workflow/processor.d.ts +11 -0
- package/dist/commands/agent-workflow/processor.js +81 -2
- package/dist/commands/product-test-cases/index.d.ts +12 -0
- package/dist/commands/product-test-cases/index.js +40 -0
- package/dist/commands/screen-flow/index.d.ts +16 -0
- package/dist/commands/screen-flow/index.js +45 -0
- package/dist/commands/user-psychology/index.d.ts +7 -0
- package/dist/commands/user-psychology/index.js +51 -0
- package/dist/index.js +65 -0
- package/dist/phases/analyze-logs/index.js +27 -6
- package/dist/phases/bug-fixing/context-fetcher.js +26 -5
- package/dist/phases/find-features/index.js +53 -9
- package/dist/phases/find-shared/mcp.js +21 -0
- package/dist/phases/growth-analysis/context.d.ts +5 -3
- package/dist/phases/growth-analysis/context.js +52 -5
- package/dist/phases/output-contracts.js +129 -0
- package/dist/phases/pr-resolve/github-reply.d.ts +5 -2
- package/dist/phases/pr-resolve/github-reply.js +19 -3
- package/dist/phases/pr-resolve/index.js +19 -5
- package/dist/phases/pr-resolve/prompts.js +17 -18
- package/dist/phases/product-test-cases/index.d.ts +25 -0
- package/dist/phases/product-test-cases/index.js +174 -0
- package/dist/phases/product-test-cases/prompts.d.ts +24 -0
- package/dist/phases/product-test-cases/prompts.js +80 -0
- package/dist/phases/product-test-cases/types.d.ts +17 -0
- package/dist/phases/product-test-cases/types.js +27 -0
- package/dist/phases/screen-flow/index.d.ts +23 -0
- package/dist/phases/screen-flow/index.js +229 -0
- package/dist/phases/screen-flow/prompts.d.ts +19 -0
- package/dist/phases/screen-flow/prompts.js +39 -0
- package/dist/phases/screen-flow/theme.d.ts +19 -0
- package/dist/phases/screen-flow/theme.js +182 -0
- package/dist/phases/screen-flow/types.d.ts +130 -0
- package/dist/phases/screen-flow/types.js +66 -0
- package/dist/phases/user-psychology/agent.d.ts +16 -0
- package/dist/phases/user-psychology/agent.js +105 -0
- package/dist/phases/user-psychology/context.d.ts +10 -0
- package/dist/phases/user-psychology/context.js +65 -0
- package/dist/phases/user-psychology/index.d.ts +18 -0
- package/dist/phases/user-psychology/index.js +96 -0
- package/dist/phases/user-psychology/prompts.d.ts +2 -0
- package/dist/phases/user-psychology/prompts.js +41 -0
- package/dist/services/audit-logs.js +67 -9
- package/dist/services/branches.js +90 -14
- package/dist/services/phase-ratings.js +71 -9
- package/dist/services/product-logs.js +65 -5
- package/dist/services/pull-requests.js +74 -14
- package/dist/skills/phase/screen-flow/SKILL.md +78 -0
- package/dist/skills/phase/user-psychology/SKILL.md +135 -0
- package/dist/supabase/client.d.ts +23 -0
- package/dist/supabase/client.js +90 -0
- package/dist/system/session-manager.js +97 -24
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/logger.js +24 -4
- package/package.json +5 -4
- 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
|
*/
|
package/dist/auth/auth-store.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
189
|
-
* newly created channels
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
},
|
|
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>;
|