edsger 0.66.0 → 0.68.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.d.ts +2 -1
- package/dist/api/chat.js +2 -1
- package/dist/commands/agent-workflow/processor.d.ts +7 -5
- package/dist/commands/agent-workflow/processor.js +17 -101
- package/dist/commands/chat-serve/chat-server.d.ts +114 -0
- package/dist/commands/chat-serve/chat-server.js +339 -0
- package/dist/commands/chat-serve/index.d.ts +20 -0
- package/dist/commands/chat-serve/index.js +58 -0
- package/dist/commands/sync-org-repos/index.d.ts +3 -2
- package/dist/commands/sync-org-repos/index.js +3 -2
- package/dist/index.js +18 -1
- package/dist/phases/sync-org-repos/index.d.ts +9 -6
- package/dist/phases/sync-org-repos/index.js +22 -34
- package/dist/types/index.d.ts +0 -31
- package/package.json +2 -2
package/dist/api/chat.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP client wrappers for chat operations.
|
|
3
|
-
* Used by the chat-
|
|
3
|
+
* Used by the `chat-serve` daemon and the agent workflow to interact with chat
|
|
4
|
+
* channels and messages.
|
|
4
5
|
*/
|
|
5
6
|
import type { ChatChannel, ChatChannelType, ChatMessage, ChatMessageType, ChatMode } from '../types/index.js';
|
|
6
7
|
export declare function getOrCreateChannel(channelType: ChatChannelType, channelRefId: string | null, chatMode?: ChatMode, name?: string, verbose?: boolean): Promise<{
|
package/dist/api/chat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP client wrappers for chat operations.
|
|
3
|
-
* Used by the chat-
|
|
3
|
+
* Used by the `chat-serve` daemon and the agent workflow to interact with chat
|
|
4
|
+
* channels and messages.
|
|
4
5
|
*/
|
|
5
6
|
import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
|
|
6
7
|
import { logError, logInfo } from '../utils/logger.js';
|
|
@@ -33,8 +33,6 @@ export declare class AgentWorkflowProcessor {
|
|
|
33
33
|
private pollTimer?;
|
|
34
34
|
/** Currently active worker processes, keyed by issueId */
|
|
35
35
|
private activeWorkers;
|
|
36
|
-
/** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
|
|
37
|
-
private chatWorker?;
|
|
38
36
|
/** Realtime subscription on `issues` — wakes processNextIssues when an
|
|
39
37
|
* issue flips to ready_for_ai, so the 30s poll can be relaxed. */
|
|
40
38
|
private issuesRealtimeChannel;
|
|
@@ -48,9 +46,13 @@ export declare class AgentWorkflowProcessor {
|
|
|
48
46
|
* already constrains delivery to products the user has access to.
|
|
49
47
|
*/
|
|
50
48
|
private startIssuesRealtime;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Post a best-effort workflow status update into the issue's chat channel.
|
|
51
|
+
* Chat itself is served by the standalone `edsger chat-serve` daemon; the
|
|
52
|
+
* workflow only narrates lifecycle milestones here. Fire-and-forget — a chat
|
|
53
|
+
* failure must never affect issue processing.
|
|
54
|
+
*/
|
|
55
|
+
private narrateToIssueChat;
|
|
54
56
|
stop(): void;
|
|
55
57
|
private processNextIssues;
|
|
56
58
|
/**
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { fork } from 'child_process';
|
|
11
11
|
import { dirname, join } from 'path';
|
|
12
12
|
import { fileURLToPath } from 'url';
|
|
13
|
+
import { sendIssueSystemMessage } from '../../api/chat.js';
|
|
13
14
|
import { listAllReadyIssues, } from '../../api/cross-product.js';
|
|
14
15
|
import { getGitHubConfig } from '../../api/github.js';
|
|
15
16
|
import { claimNextIssue, getIssue } from '../../api/issues/index.js';
|
|
@@ -36,7 +37,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
36
37
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- ESM __filename/__dirname polyfill
|
|
37
38
|
const __dirname = dirname(__filename);
|
|
38
39
|
const WORKER_SCRIPT = join(__dirname, 'issue-worker.js');
|
|
39
|
-
const CHAT_WORKER_SCRIPT = join(__dirname, 'chat-worker.js');
|
|
40
40
|
export class AgentWorkflowProcessor {
|
|
41
41
|
options;
|
|
42
42
|
config;
|
|
@@ -45,8 +45,6 @@ export class AgentWorkflowProcessor {
|
|
|
45
45
|
pollTimer;
|
|
46
46
|
/** Currently active worker processes, keyed by issueId */
|
|
47
47
|
activeWorkers = new Map();
|
|
48
|
-
/** Chat worker subprocess — runs in parallel, handles chat messages and phase events */
|
|
49
|
-
chatWorker;
|
|
50
48
|
/** Realtime subscription on `issues` — wakes processNextIssues when an
|
|
51
49
|
* issue flips to ready_for_ai, so the 30s poll can be relaxed. */
|
|
52
50
|
issuesRealtimeChannel = null;
|
|
@@ -68,8 +66,6 @@ export class AgentWorkflowProcessor {
|
|
|
68
66
|
}
|
|
69
67
|
this.isRunning = true;
|
|
70
68
|
logInfo(`Concurrent processing: up to ${this.options.maxConcurrent} issue(s)`);
|
|
71
|
-
// Start chat worker subprocess
|
|
72
|
-
this.startChatWorker();
|
|
73
69
|
// Initial issue check
|
|
74
70
|
await this.processNextIssues();
|
|
75
71
|
// Set up Realtime subscription on `issues` so a status flip to
|
|
@@ -142,77 +138,16 @@ export class AgentWorkflowProcessor {
|
|
|
142
138
|
return false;
|
|
143
139
|
}
|
|
144
140
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (line) {
|
|
156
|
-
logInfo(` [chat] ${line}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
this.chatWorker.stderr?.on('data', (data) => {
|
|
161
|
-
const lines = data.toString().trim().split('\n');
|
|
162
|
-
for (const line of lines) {
|
|
163
|
-
if (line) {
|
|
164
|
-
logError(` [chat] ${line}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
this.chatWorker.on('message', (msg) => {
|
|
169
|
-
if (msg.type === 'log') {
|
|
170
|
-
const prefix = '[chat]';
|
|
171
|
-
switch (msg.level) {
|
|
172
|
-
case 'info':
|
|
173
|
-
logInfo(` ${prefix} ${msg.message}`);
|
|
174
|
-
break;
|
|
175
|
-
case 'warning':
|
|
176
|
-
logWarning(` ${prefix} ${msg.message}`);
|
|
177
|
-
break;
|
|
178
|
-
case 'error':
|
|
179
|
-
logError(` ${prefix} ${msg.message}`);
|
|
180
|
-
break;
|
|
181
|
-
case 'success':
|
|
182
|
-
logSuccess(` ${prefix} ${msg.message}`);
|
|
183
|
-
break;
|
|
184
|
-
case undefined:
|
|
185
|
-
default:
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
this.chatWorker.on('exit', (code) => {
|
|
191
|
-
logWarning(`Chat worker exited with code ${code}`);
|
|
192
|
-
this.chatWorker = undefined;
|
|
193
|
-
});
|
|
194
|
-
// Initialize the chat worker with config
|
|
195
|
-
this.chatWorker.send({
|
|
196
|
-
type: 'init',
|
|
197
|
-
config: this.config,
|
|
198
|
-
verbose: this.options.verbose,
|
|
199
|
-
});
|
|
200
|
-
logInfo('Chat worker started');
|
|
201
|
-
}
|
|
202
|
-
catch (error) {
|
|
203
|
-
logError(`Failed to start chat worker: ${error instanceof Error ? error.message : String(error)}`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
/** Send a message to the chat worker via IPC */
|
|
207
|
-
notifyChatWorker(msg) {
|
|
208
|
-
if (this.chatWorker && this.chatWorker.connected) {
|
|
209
|
-
try {
|
|
210
|
-
this.chatWorker.send(msg);
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
// Chat worker may have died — non-critical
|
|
214
|
-
}
|
|
215
|
-
}
|
|
141
|
+
/**
|
|
142
|
+
* Post a best-effort workflow status update into the issue's chat channel.
|
|
143
|
+
* Chat itself is served by the standalone `edsger chat-serve` daemon; the
|
|
144
|
+
* workflow only narrates lifecycle milestones here. Fire-and-forget — a chat
|
|
145
|
+
* failure must never affect issue processing.
|
|
146
|
+
*/
|
|
147
|
+
narrateToIssueChat(issueId, content, metadata) {
|
|
148
|
+
void sendIssueSystemMessage(issueId, content, metadata).catch((error) => {
|
|
149
|
+
logWarning(`Failed to post chat update for issue ${issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
|
+
});
|
|
216
151
|
}
|
|
217
152
|
stop() {
|
|
218
153
|
if (!this.isRunning) {
|
|
@@ -234,16 +169,6 @@ export class AgentWorkflowProcessor {
|
|
|
234
169
|
}
|
|
235
170
|
this.issuesRealtimeChannel = null;
|
|
236
171
|
}
|
|
237
|
-
// Kill chat worker
|
|
238
|
-
if (this.chatWorker) {
|
|
239
|
-
try {
|
|
240
|
-
this.chatWorker.kill('SIGTERM');
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
// Ignore kill errors
|
|
244
|
-
}
|
|
245
|
-
this.chatWorker = undefined;
|
|
246
|
-
}
|
|
247
172
|
// Kill all active issue workers gracefully
|
|
248
173
|
for (const [issueId, worker] of this.activeWorkers) {
|
|
249
174
|
logInfo(`Stopping worker for issue: ${issueId}`);
|
|
@@ -506,12 +431,6 @@ export class AgentWorkflowProcessor {
|
|
|
506
431
|
verbose: this.options.verbose,
|
|
507
432
|
config: this.config,
|
|
508
433
|
});
|
|
509
|
-
// Notify chat worker that an issue has started processing
|
|
510
|
-
this.notifyChatWorker({
|
|
511
|
-
type: 'event:issue_started',
|
|
512
|
-
issueId,
|
|
513
|
-
repoPath,
|
|
514
|
-
});
|
|
515
434
|
}
|
|
516
435
|
catch (error) {
|
|
517
436
|
this.activeWorkers.delete(issueId);
|
|
@@ -559,8 +478,10 @@ export class AgentWorkflowProcessor {
|
|
|
559
478
|
if (success) {
|
|
560
479
|
this.processedIssues = updateIssueState(this.processedIssues, issueId, (currentState) => createCompletedState(issueId, currentState));
|
|
561
480
|
logSuccess(`Issue completed: ${issueName}`);
|
|
562
|
-
//
|
|
563
|
-
this.
|
|
481
|
+
// Narrate completion into the issue chat (served by `edsger chat-serve`).
|
|
482
|
+
this.narrateToIssueChat(issueId, 'All workflow phases completed.', {
|
|
483
|
+
status: 'issue_done',
|
|
484
|
+
});
|
|
564
485
|
// Clean up the per-issue clone now that the workflow is finished.
|
|
565
486
|
// Failures intentionally leave the workspace so the user (or a retry)
|
|
566
487
|
// can inspect the partial state.
|
|
@@ -578,15 +499,10 @@ export class AgentWorkflowProcessor {
|
|
|
578
499
|
// without doing so leaves the issue at assigned_to_ai, which is a
|
|
579
500
|
// separate, pre-existing orphan class outside the scope of the
|
|
580
501
|
// pre-fork cleanup that releaseClaim handles.
|
|
581
|
-
// Only
|
|
502
|
+
// Only narrate on first failure to avoid flooding with duplicate messages.
|
|
582
503
|
const failedState = this.processedIssues.get(issueId);
|
|
583
504
|
if (failedState && failedState.retryCount <= 1) {
|
|
584
|
-
this.
|
|
585
|
-
type: 'event:phase_failed',
|
|
586
|
-
issueId,
|
|
587
|
-
phase: 'workflow',
|
|
588
|
-
error: error || 'Unknown error',
|
|
589
|
-
});
|
|
505
|
+
this.narrateToIssueChat(issueId, `Phase "workflow" failed: ${error || 'Unknown error'}`, { phase: 'workflow', status: 'failed', error: error || 'Unknown error' });
|
|
590
506
|
}
|
|
591
507
|
}
|
|
592
508
|
// Clear heartbeat issue info
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Server — the engine behind the standalone `edsger chat-serve` daemon.
|
|
3
|
+
*
|
|
4
|
+
* A single long-lived process that answers human messages on **issue** and
|
|
5
|
+
* **product group** chat channels (the chat surfaces in the issue- and
|
|
6
|
+
* product-detail pages). It is deliberately independent of the autonomous
|
|
7
|
+
* agent workflow: unlike the previous `chat-worker`, it is not forked by
|
|
8
|
+
* `AgentWorkflowProcessor` and needs no IPC from it — so chat keeps working
|
|
9
|
+
* whether or not the workflow is running.
|
|
10
|
+
*
|
|
11
|
+
* Two channel kinds are served here:
|
|
12
|
+
* - `issue` — `channel_type='issue'`
|
|
13
|
+
* - `product` — `channel_type='product'` AND `chat_mode='group'`
|
|
14
|
+
*
|
|
15
|
+
* Session chats (`chat_mode='ai_assistant'`) are deliberately excluded — they
|
|
16
|
+
* are owned by the per-session `edsger session-serve` daemon. Letting both
|
|
17
|
+
* claim the same messages would produce duplicate replies.
|
|
18
|
+
*
|
|
19
|
+
* Message intake prefers Supabase Realtime (a `chat_messages` INSERT wakes the
|
|
20
|
+
* claim+process path immediately); when no Supabase session is available it
|
|
21
|
+
* falls back to MCP polling. Either way, messages are claimed atomically
|
|
22
|
+
* (`claimPendingMessages`) so multiple workers never process the same message.
|
|
23
|
+
*
|
|
24
|
+
* Issue chats can give the AI read access to the issue's cloned repo. The repo
|
|
25
|
+
* path is derived locally from the workspace root + issue id (the same pure
|
|
26
|
+
* mapping the workflow uses), so no IPC hand-off is required; when the clone
|
|
27
|
+
* isn't present the chat simply runs without code context.
|
|
28
|
+
*
|
|
29
|
+
* Side-effectful collaborators are injected via {@link ChatServerDeps} (with
|
|
30
|
+
* real implementations as the default) so the routing/repo logic is unit
|
|
31
|
+
* testable without a live Supabase or filesystem.
|
|
32
|
+
*/
|
|
33
|
+
import { claimPendingMessages, listChannels } from '../../api/chat.js';
|
|
34
|
+
import { processHumanMessages, processProductHumanMessages } from '../../phases/chat-processor/index.js';
|
|
35
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
36
|
+
import type { ChatChannel, EdsgerConfig } from '../../types/index.js';
|
|
37
|
+
/** Channel kinds this server is responsible for. */
|
|
38
|
+
export type ServedChannelKind = 'issue' | 'product';
|
|
39
|
+
/**
|
|
40
|
+
* Classify a channel into the kind this server serves, or `null` to ignore it.
|
|
41
|
+
* Pure — the single source of truth for "should chat-serve touch this channel",
|
|
42
|
+
* shared by the initial refresh and the Realtime registration path.
|
|
43
|
+
*
|
|
44
|
+
* Note the precise product rule: only `chat_mode='group'` product channels are
|
|
45
|
+
* served. `ai_assistant` (session) channels belong to `edsger session-serve`;
|
|
46
|
+
* any other mode (e.g. `direct`) is not a product group chat and is ignored.
|
|
47
|
+
*/
|
|
48
|
+
export declare function classifyChannel(channel: Pick<ChatChannel, 'channel_type' | 'chat_mode'>): ServedChannelKind | null;
|
|
49
|
+
/** Side-effectful collaborators, injectable for testing. */
|
|
50
|
+
export interface ChatServerDeps {
|
|
51
|
+
listChannels: typeof listChannels;
|
|
52
|
+
claimPendingMessages: typeof claimPendingMessages;
|
|
53
|
+
processHumanMessages: typeof processHumanMessages;
|
|
54
|
+
processProductHumanMessages: typeof processProductHumanMessages;
|
|
55
|
+
issueRepoExists: (workspaceRoot: string, issueId: string) => boolean;
|
|
56
|
+
getIssueRepoPath: (workspaceRoot: string, issueId: string) => string;
|
|
57
|
+
hasSupabaseSession: typeof hasSupabaseSession;
|
|
58
|
+
getSupabase: typeof getSupabase;
|
|
59
|
+
}
|
|
60
|
+
export interface ChatServerOptions {
|
|
61
|
+
config: EdsgerConfig;
|
|
62
|
+
/** Workspace root used to locate issue repo clones for code context. */
|
|
63
|
+
workspaceRoot: string;
|
|
64
|
+
verbose?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare class ChatServer {
|
|
67
|
+
private readonly deps;
|
|
68
|
+
private readonly config;
|
|
69
|
+
private readonly workspaceRoot;
|
|
70
|
+
private readonly verbose;
|
|
71
|
+
/** Unique id for atomic message claiming across competing workers. */
|
|
72
|
+
private readonly workerId;
|
|
73
|
+
/** issueId -> channelId */
|
|
74
|
+
private readonly issueChannels;
|
|
75
|
+
/** productId -> channelId */
|
|
76
|
+
private readonly productChannels;
|
|
77
|
+
/** channelId -> { kind, refId } */
|
|
78
|
+
private readonly metaByChannel;
|
|
79
|
+
/** In-flight message processing, awaited on shutdown so we drain cleanly. */
|
|
80
|
+
private readonly inflight;
|
|
81
|
+
private realtimeChannel;
|
|
82
|
+
private pollTimer;
|
|
83
|
+
private refreshTimer;
|
|
84
|
+
private running;
|
|
85
|
+
constructor(options: ChatServerOptions, deps?: ChatServerDeps);
|
|
86
|
+
/** Bring the server up: seed channels, drain backlog, start intake. */
|
|
87
|
+
start(): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Stop accepting new work, tear down timers/subscriptions, then wait for any
|
|
90
|
+
* in-flight message processing to finish (bounded by DRAIN_TIMEOUT_MS) so a
|
|
91
|
+
* mid-flight reply isn't abandoned. Idempotent.
|
|
92
|
+
*/
|
|
93
|
+
stop(): Promise<void>;
|
|
94
|
+
private refreshChannels;
|
|
95
|
+
/**
|
|
96
|
+
* Register a channel into the routing maps. Returns true if it was newly
|
|
97
|
+
* added. Ignores channels this server doesn't serve (e.g. session chats).
|
|
98
|
+
*/
|
|
99
|
+
private registerChannel;
|
|
100
|
+
/** Fire-and-forget a claim+process while tracking it for graceful drain. */
|
|
101
|
+
private dispatch;
|
|
102
|
+
/** Claim and process pending human messages on a single known channel. */
|
|
103
|
+
private claimAndProcess;
|
|
104
|
+
/**
|
|
105
|
+
* Locate the issue's cloned repo so the chat AI can read code. Derived from
|
|
106
|
+
* the workspace root with the same pure mapping the workflow uses; returns
|
|
107
|
+
* undefined when the clone isn't present (chat then runs without code).
|
|
108
|
+
*/
|
|
109
|
+
private resolveRepoPath;
|
|
110
|
+
private startRealtime;
|
|
111
|
+
private stopRealtime;
|
|
112
|
+
private startPolling;
|
|
113
|
+
private pollOnce;
|
|
114
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Server — the engine behind the standalone `edsger chat-serve` daemon.
|
|
3
|
+
*
|
|
4
|
+
* A single long-lived process that answers human messages on **issue** and
|
|
5
|
+
* **product group** chat channels (the chat surfaces in the issue- and
|
|
6
|
+
* product-detail pages). It is deliberately independent of the autonomous
|
|
7
|
+
* agent workflow: unlike the previous `chat-worker`, it is not forked by
|
|
8
|
+
* `AgentWorkflowProcessor` and needs no IPC from it — so chat keeps working
|
|
9
|
+
* whether or not the workflow is running.
|
|
10
|
+
*
|
|
11
|
+
* Two channel kinds are served here:
|
|
12
|
+
* - `issue` — `channel_type='issue'`
|
|
13
|
+
* - `product` — `channel_type='product'` AND `chat_mode='group'`
|
|
14
|
+
*
|
|
15
|
+
* Session chats (`chat_mode='ai_assistant'`) are deliberately excluded — they
|
|
16
|
+
* are owned by the per-session `edsger session-serve` daemon. Letting both
|
|
17
|
+
* claim the same messages would produce duplicate replies.
|
|
18
|
+
*
|
|
19
|
+
* Message intake prefers Supabase Realtime (a `chat_messages` INSERT wakes the
|
|
20
|
+
* claim+process path immediately); when no Supabase session is available it
|
|
21
|
+
* falls back to MCP polling. Either way, messages are claimed atomically
|
|
22
|
+
* (`claimPendingMessages`) so multiple workers never process the same message.
|
|
23
|
+
*
|
|
24
|
+
* Issue chats can give the AI read access to the issue's cloned repo. The repo
|
|
25
|
+
* path is derived locally from the workspace root + issue id (the same pure
|
|
26
|
+
* mapping the workflow uses), so no IPC hand-off is required; when the clone
|
|
27
|
+
* isn't present the chat simply runs without code context.
|
|
28
|
+
*
|
|
29
|
+
* Side-effectful collaborators are injected via {@link ChatServerDeps} (with
|
|
30
|
+
* real implementations as the default) so the routing/repo logic is unit
|
|
31
|
+
* testable without a live Supabase or filesystem.
|
|
32
|
+
*/
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { claimPendingMessages, listChannels } from '../../api/chat.js';
|
|
35
|
+
import { processHumanMessages, processProductHumanMessages, } from '../../phases/chat-processor/index.js';
|
|
36
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
37
|
+
import { logError, logInfo, logWarning } from '../../utils/logger.js';
|
|
38
|
+
import { getIssueRepoPath, issueRepoExists, } from '../../workspace/workspace-manager.js';
|
|
39
|
+
/**
|
|
40
|
+
* Classify a channel into the kind this server serves, or `null` to ignore it.
|
|
41
|
+
* Pure — the single source of truth for "should chat-serve touch this channel",
|
|
42
|
+
* shared by the initial refresh and the Realtime registration path.
|
|
43
|
+
*
|
|
44
|
+
* Note the precise product rule: only `chat_mode='group'` product channels are
|
|
45
|
+
* served. `ai_assistant` (session) channels belong to `edsger session-serve`;
|
|
46
|
+
* any other mode (e.g. `direct`) is not a product group chat and is ignored.
|
|
47
|
+
*/
|
|
48
|
+
export function classifyChannel(channel) {
|
|
49
|
+
if (channel.channel_type === 'issue') {
|
|
50
|
+
return 'issue';
|
|
51
|
+
}
|
|
52
|
+
if (channel.channel_type === 'product' && channel.chat_mode === 'group') {
|
|
53
|
+
return 'product';
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
58
|
+
/** Periodic channel re-discovery (also a Realtime-mode safety net + keepalive). */
|
|
59
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
60
|
+
/** Max time `stop()` waits for in-flight message processing before exiting. */
|
|
61
|
+
const DRAIN_TIMEOUT_MS = 15_000;
|
|
62
|
+
const defaultDeps = {
|
|
63
|
+
listChannels,
|
|
64
|
+
claimPendingMessages,
|
|
65
|
+
processHumanMessages,
|
|
66
|
+
processProductHumanMessages,
|
|
67
|
+
issueRepoExists,
|
|
68
|
+
getIssueRepoPath,
|
|
69
|
+
hasSupabaseSession,
|
|
70
|
+
getSupabase,
|
|
71
|
+
};
|
|
72
|
+
export class ChatServer {
|
|
73
|
+
deps;
|
|
74
|
+
config;
|
|
75
|
+
workspaceRoot;
|
|
76
|
+
verbose;
|
|
77
|
+
/** Unique id for atomic message claiming across competing workers. */
|
|
78
|
+
workerId = `chat-serve-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
79
|
+
/** issueId -> channelId */
|
|
80
|
+
issueChannels = new Map();
|
|
81
|
+
/** productId -> channelId */
|
|
82
|
+
productChannels = new Map();
|
|
83
|
+
/** channelId -> { kind, refId } */
|
|
84
|
+
metaByChannel = new Map();
|
|
85
|
+
/** In-flight message processing, awaited on shutdown so we drain cleanly. */
|
|
86
|
+
inflight = new Set();
|
|
87
|
+
realtimeChannel = null;
|
|
88
|
+
pollTimer = null;
|
|
89
|
+
refreshTimer = null;
|
|
90
|
+
running = false;
|
|
91
|
+
constructor(options, deps = defaultDeps) {
|
|
92
|
+
this.config = options.config;
|
|
93
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
94
|
+
this.verbose = options.verbose ?? false;
|
|
95
|
+
this.deps = deps;
|
|
96
|
+
}
|
|
97
|
+
/** Bring the server up: seed channels, drain backlog, start intake. */
|
|
98
|
+
async start() {
|
|
99
|
+
if (this.running) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.running = true;
|
|
103
|
+
logInfo(`Chat server starting (id: ${this.workerId})`);
|
|
104
|
+
// Seed the channel cache before subscriptions so Realtime events can be
|
|
105
|
+
// routed by channel id without an extra round-trip.
|
|
106
|
+
await this.refreshChannels();
|
|
107
|
+
// Drain anything that arrived while we were offline.
|
|
108
|
+
for (const channelId of this.metaByChannel.keys()) {
|
|
109
|
+
this.dispatch(channelId);
|
|
110
|
+
}
|
|
111
|
+
if (this.startRealtime()) {
|
|
112
|
+
logInfo('Chat server running in Realtime mode');
|
|
113
|
+
// Low-frequency refresh as a safety net for missed channel INSERTs and
|
|
114
|
+
// to keep the event loop alive even if the Realtime socket drops.
|
|
115
|
+
this.refreshTimer = setInterval(() => {
|
|
116
|
+
void this.refreshChannels();
|
|
117
|
+
}, REFRESH_INTERVAL_MS);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
logWarning('No Supabase session — chat server using MCP polling mode');
|
|
121
|
+
this.startPolling();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Stop accepting new work, tear down timers/subscriptions, then wait for any
|
|
125
|
+
* in-flight message processing to finish (bounded by DRAIN_TIMEOUT_MS) so a
|
|
126
|
+
* mid-flight reply isn't abandoned. Idempotent.
|
|
127
|
+
*/
|
|
128
|
+
async stop() {
|
|
129
|
+
this.running = false;
|
|
130
|
+
if (this.pollTimer) {
|
|
131
|
+
clearInterval(this.pollTimer);
|
|
132
|
+
this.pollTimer = null;
|
|
133
|
+
}
|
|
134
|
+
if (this.refreshTimer) {
|
|
135
|
+
clearInterval(this.refreshTimer);
|
|
136
|
+
this.refreshTimer = null;
|
|
137
|
+
}
|
|
138
|
+
this.stopRealtime();
|
|
139
|
+
if (this.inflight.size > 0) {
|
|
140
|
+
logInfo(`Draining ${this.inflight.size} in-flight message turn(s)…`);
|
|
141
|
+
await Promise.race([
|
|
142
|
+
Promise.allSettled([...this.inflight]),
|
|
143
|
+
delay(DRAIN_TIMEOUT_MS),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
logInfo('Chat server stopped');
|
|
147
|
+
}
|
|
148
|
+
// ── Channel discovery ──────────────────────────────────────────────────
|
|
149
|
+
async refreshChannels() {
|
|
150
|
+
try {
|
|
151
|
+
const [issueChannels, productChannels] = await Promise.all([
|
|
152
|
+
this.deps.listChannels('issue'),
|
|
153
|
+
this.deps.listChannels('product'),
|
|
154
|
+
]);
|
|
155
|
+
let added = 0;
|
|
156
|
+
for (const channel of [...issueChannels, ...productChannels]) {
|
|
157
|
+
if (this.registerChannel(channel)) {
|
|
158
|
+
added++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (added > 0) {
|
|
162
|
+
logInfo(`Discovered ${added} new channel(s) (issues: ${this.issueChannels.size}, products: ${this.productChannels.size})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
logError(`Failed to refresh channels: ${describe(error)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Register a channel into the routing maps. Returns true if it was newly
|
|
171
|
+
* added. Ignores channels this server doesn't serve (e.g. session chats).
|
|
172
|
+
*/
|
|
173
|
+
registerChannel(channel) {
|
|
174
|
+
if (!channel.channel_ref_id) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const kind = classifyChannel(channel);
|
|
178
|
+
if (!kind) {
|
|
179
|
+
return false; // session / general channel — not ours
|
|
180
|
+
}
|
|
181
|
+
const refMap = kind === 'issue' ? this.issueChannels : this.productChannels;
|
|
182
|
+
const isNew = !refMap.has(channel.channel_ref_id);
|
|
183
|
+
refMap.set(channel.channel_ref_id, channel.id);
|
|
184
|
+
this.metaByChannel.set(channel.id, { kind, refId: channel.channel_ref_id });
|
|
185
|
+
if (isNew) {
|
|
186
|
+
logInfo(`New ${kind} channel: ${channel.channel_ref_id} (${channel.id})`);
|
|
187
|
+
}
|
|
188
|
+
return isNew;
|
|
189
|
+
}
|
|
190
|
+
// ── Message processing ─────────────────────────────────────────────────
|
|
191
|
+
/** Fire-and-forget a claim+process while tracking it for graceful drain. */
|
|
192
|
+
dispatch(channelId) {
|
|
193
|
+
const task = this.claimAndProcess(channelId);
|
|
194
|
+
this.inflight.add(task);
|
|
195
|
+
void task.finally(() => this.inflight.delete(task));
|
|
196
|
+
}
|
|
197
|
+
/** Claim and process pending human messages on a single known channel. */
|
|
198
|
+
async claimAndProcess(channelId) {
|
|
199
|
+
if (!this.running) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const meta = this.metaByChannel.get(channelId);
|
|
203
|
+
if (!meta) {
|
|
204
|
+
// Unknown channel — the chat_channels subscription / next refresh will
|
|
205
|
+
// seed it, and the following message event will pick it up.
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const claimed = await this.deps.claimPendingMessages(channelId, this.workerId);
|
|
210
|
+
if (claimed.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
logInfo(`Claimed ${claimed.length} message(s) for ${meta.kind} ${meta.refId}`);
|
|
214
|
+
if (meta.kind === 'issue') {
|
|
215
|
+
await this.deps.processHumanMessages(claimed, meta.refId, this.config, this.verbose, this.resolveRepoPath(meta.refId));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
await this.deps.processProductHumanMessages(claimed, meta.refId, this.config, this.verbose);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
logError(`Error processing channel ${channelId}: ${describe(error)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Locate the issue's cloned repo so the chat AI can read code. Derived from
|
|
227
|
+
* the workspace root with the same pure mapping the workflow uses; returns
|
|
228
|
+
* undefined when the clone isn't present (chat then runs without code).
|
|
229
|
+
*/
|
|
230
|
+
resolveRepoPath(issueId) {
|
|
231
|
+
if (this.deps.issueRepoExists(this.workspaceRoot, issueId)) {
|
|
232
|
+
return this.deps.getIssueRepoPath(this.workspaceRoot, issueId);
|
|
233
|
+
}
|
|
234
|
+
if (this.verbose) {
|
|
235
|
+
logInfo(`No local clone for issue ${issueId} — chat runs without code context`);
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
// ── Realtime intake ────────────────────────────────────────────────────
|
|
240
|
+
startRealtime() {
|
|
241
|
+
if (this.realtimeChannel) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (!this.deps.hasSupabaseSession()) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
this.realtimeChannel = this.deps
|
|
249
|
+
.getSupabase()
|
|
250
|
+
.channel('chat-serve', { config: { broadcast: { ack: false } } })
|
|
251
|
+
.on('postgres_changes', {
|
|
252
|
+
event: 'INSERT',
|
|
253
|
+
schema: 'public',
|
|
254
|
+
table: 'chat_messages',
|
|
255
|
+
filter: 'sender_type=eq.human',
|
|
256
|
+
}, (payload) => {
|
|
257
|
+
const row = payload.new;
|
|
258
|
+
if (!row.channel_id || row.is_processed) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.dispatch(row.channel_id);
|
|
262
|
+
})
|
|
263
|
+
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'chat_channels' }, (payload) => {
|
|
264
|
+
const channel = payload.new;
|
|
265
|
+
this.registerChannel(channel);
|
|
266
|
+
if (channel.id) {
|
|
267
|
+
this.dispatch(channel.id);
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
.subscribe((status) => {
|
|
271
|
+
if (status === 'SUBSCRIBED') {
|
|
272
|
+
logInfo('Realtime subscription active (chat_messages, chat_channels)');
|
|
273
|
+
}
|
|
274
|
+
else if (status === 'CHANNEL_ERROR' ||
|
|
275
|
+
status === 'TIMED_OUT' ||
|
|
276
|
+
status === 'CLOSED') {
|
|
277
|
+
logWarning(`Realtime channel status: ${status}`);
|
|
278
|
+
// supabase-js auto-reconnects; the refresh timer covers the gap.
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
logError(`Failed to start Realtime subscription: ${describe(error)}`);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
stopRealtime() {
|
|
289
|
+
if (!this.realtimeChannel) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
void this.deps.getSupabase().removeChannel(this.realtimeChannel);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Best-effort; supabase may already be torn down.
|
|
297
|
+
}
|
|
298
|
+
this.realtimeChannel = null;
|
|
299
|
+
}
|
|
300
|
+
// ── Polling fallback (no Supabase session) ─────────────────────────────
|
|
301
|
+
startPolling() {
|
|
302
|
+
if (this.pollTimer) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
void this.pollOnce();
|
|
306
|
+
this.pollTimer = setInterval(() => void this.pollOnce(), POLL_INTERVAL_MS);
|
|
307
|
+
// Periodically re-discover channels in the polling path too.
|
|
308
|
+
this.refreshTimer = setInterval(() => {
|
|
309
|
+
void this.refreshChannels();
|
|
310
|
+
}, REFRESH_INTERVAL_MS);
|
|
311
|
+
}
|
|
312
|
+
async pollOnce() {
|
|
313
|
+
if (!this.running) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
for (const channelId of this.metaByChannel.keys()) {
|
|
317
|
+
const task = this.claimAndProcess(channelId);
|
|
318
|
+
this.inflight.add(task);
|
|
319
|
+
try {
|
|
320
|
+
await task;
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
this.inflight.delete(task);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function describe(error) {
|
|
329
|
+
return error instanceof Error ? error.message : String(error);
|
|
330
|
+
}
|
|
331
|
+
function delay(ms) {
|
|
332
|
+
return new Promise((resolve) => {
|
|
333
|
+
const timer = setTimeout(resolve, ms);
|
|
334
|
+
// Don't keep the event loop alive solely for the drain timeout.
|
|
335
|
+
if (typeof timer.unref === 'function') {
|
|
336
|
+
timer.unref();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger chat-serve` — long-lived daemon that answers human messages on issue
|
|
3
|
+
* and product group chat channels.
|
|
4
|
+
*
|
|
5
|
+
* This is the standalone replacement for the old `chat-worker` that
|
|
6
|
+
* `AgentWorkflowProcessor` used to fork. Running it as its own top-level
|
|
7
|
+
* process (spawned by the desktop app, sibling to `edsger --verbose` and
|
|
8
|
+
* `edsger session-serve`) decouples chat from the autonomous workflow: chat
|
|
9
|
+
* works whether or not the workflow is running, and the two never both claim
|
|
10
|
+
* the same messages.
|
|
11
|
+
*
|
|
12
|
+
* Like the agent workflow, it requires `edsger login` first and exits promptly
|
|
13
|
+
* if no auth is found. The actual chat-serving logic lives in {@link ChatServer}.
|
|
14
|
+
*/
|
|
15
|
+
export interface ChatServeCliOptions {
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
/** Optional path to an edsger config file (forwarded to validateConfiguration). */
|
|
18
|
+
config?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function runChatServeCommand(options?: ChatServeCliOptions): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger chat-serve` — long-lived daemon that answers human messages on issue
|
|
3
|
+
* and product group chat channels.
|
|
4
|
+
*
|
|
5
|
+
* This is the standalone replacement for the old `chat-worker` that
|
|
6
|
+
* `AgentWorkflowProcessor` used to fork. Running it as its own top-level
|
|
7
|
+
* process (spawned by the desktop app, sibling to `edsger --verbose` and
|
|
8
|
+
* `edsger session-serve`) decouples chat from the autonomous workflow: chat
|
|
9
|
+
* works whether or not the workflow is running, and the two never both claim
|
|
10
|
+
* the same messages.
|
|
11
|
+
*
|
|
12
|
+
* Like the agent workflow, it requires `edsger login` first and exits promptly
|
|
13
|
+
* if no auth is found. The actual chat-serving logic lives in {@link ChatServer}.
|
|
14
|
+
*/
|
|
15
|
+
import { isLoggedIn } from '../../auth/auth-store.js';
|
|
16
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
17
|
+
import { validateConfiguration } from '../../utils/validation.js';
|
|
18
|
+
import { ensureWorkspaceDir } from '../../workspace/workspace-manager.js';
|
|
19
|
+
import { ChatServer } from './chat-server.js';
|
|
20
|
+
export async function runChatServeCommand(options = {}) {
|
|
21
|
+
// Mirror the agent workflow: auth is required, and we exit fast when absent
|
|
22
|
+
// so the desktop supervisor can surface a "run `edsger login`" hint.
|
|
23
|
+
if (!isLoggedIn()) {
|
|
24
|
+
logError('Not authenticated. Please run `edsger login` first.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const cliOptions = {
|
|
28
|
+
verbose: options.verbose,
|
|
29
|
+
config: options.config,
|
|
30
|
+
};
|
|
31
|
+
const config = validateConfiguration(cliOptions);
|
|
32
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
33
|
+
logInfo(`Workspace: ${workspaceRoot}`);
|
|
34
|
+
const server = new ChatServer({
|
|
35
|
+
config,
|
|
36
|
+
workspaceRoot,
|
|
37
|
+
verbose: options.verbose,
|
|
38
|
+
});
|
|
39
|
+
await server.start();
|
|
40
|
+
let shuttingDown = false;
|
|
41
|
+
const shutdown = async (signal) => {
|
|
42
|
+
if (shuttingDown) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
shuttingDown = true;
|
|
46
|
+
logInfo(`Received ${signal} — shutting down chat server.`);
|
|
47
|
+
await server.stop();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
};
|
|
50
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
51
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
52
|
+
process.on('uncaughtException', (error) => {
|
|
53
|
+
logError(`Uncaught exception: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
void server.stop().finally(() => process.exit(1));
|
|
55
|
+
});
|
|
56
|
+
// The Realtime socket / poll timer keep the event loop alive; nothing else
|
|
57
|
+
// to do on the main path. The process lives until a signal arrives.
|
|
58
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
3
|
*
|
|
4
|
-
* Reads the team's configured github_org
|
|
5
|
-
* via the
|
|
4
|
+
* Reads the team's configured github_org and fetches all repos from that org
|
|
5
|
+
* server-side (via the Edsger GitHub App, falling back to the user's personal
|
|
6
|
+
* GitHub token), then upserts a repositories row for each repo.
|
|
6
7
|
*/
|
|
7
8
|
export interface SyncOrgReposCliOptions {
|
|
8
9
|
verbose?: boolean;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
3
|
*
|
|
4
|
-
* Reads the team's configured github_org
|
|
5
|
-
* via the
|
|
4
|
+
* Reads the team's configured github_org and fetches all repos from that org
|
|
5
|
+
* server-side (via the Edsger GitHub App, falling back to the user's personal
|
|
6
|
+
* GitHub token), then upserts a repositories row for each repo.
|
|
6
7
|
*/
|
|
7
8
|
import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
|
|
8
9
|
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runAgentWorkflow } from './commands/agent-workflow/index.js';
|
|
|
10
10
|
import { runAnalyzeLogs } from './commands/analyze-logs/index.js';
|
|
11
11
|
import { runAppStoreGeneration } from './commands/app-store/index.js';
|
|
12
12
|
import { runBuild } from './commands/build/index.js';
|
|
13
|
+
import { runChatServeCommand } from './commands/chat-serve/index.js';
|
|
13
14
|
import { runChecklists } from './commands/checklists/index.js';
|
|
14
15
|
import { runCodeReview } from './commands/code-review/index.js';
|
|
15
16
|
import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './commands/config/index.js';
|
|
@@ -495,6 +496,22 @@ program
|
|
|
495
496
|
}
|
|
496
497
|
});
|
|
497
498
|
// ============================================================
|
|
499
|
+
// Subcommand: edsger chat-serve
|
|
500
|
+
// ============================================================
|
|
501
|
+
program
|
|
502
|
+
.command('chat-serve')
|
|
503
|
+
.description('Run a long-lived daemon that answers human messages on issue and product group chat channels')
|
|
504
|
+
.option('-v, --verbose', 'Verbose output')
|
|
505
|
+
.action(async (opts) => {
|
|
506
|
+
try {
|
|
507
|
+
await runChatServeCommand({ verbose: opts.verbose });
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
// ============================================================
|
|
498
515
|
// Subcommand: edsger user-stories-analysis <issueId>
|
|
499
516
|
// ============================================================
|
|
500
517
|
program
|
|
@@ -670,7 +687,7 @@ program
|
|
|
670
687
|
// ============================================================
|
|
671
688
|
program
|
|
672
689
|
.command('sync-org-repos <teamId>')
|
|
673
|
-
.description("Sync a GitHub org's repos
|
|
690
|
+
.description("Sync a GitHub org's repos under a team via the Edsger GitHub App, falling back to your personal GitHub token.")
|
|
674
691
|
.option('-v, --verbose', 'Verbose output')
|
|
675
692
|
.option('--org <name>', "GitHub org name (defaults to the team's configured github_org)")
|
|
676
693
|
.action(async (teamId, opts) => {
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phase: sync-org-repos
|
|
3
3
|
*
|
|
4
|
-
* Fetches all repositories from a GitHub organization
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Fetches all repositories from a GitHub organization via the Edsger GitHub
|
|
5
|
+
* App (server-side, through the MCP `github/org_repos` endpoint), then upserts
|
|
6
|
+
* a row into the team-scoped `repositories` table for each repo. Products are
|
|
7
|
+
* NOT created here — a product is a higher-level grouping that a user links one
|
|
8
|
+
* or more repositories to afterwards.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Repos are listed with a short-lived GitHub App installation token minted on
|
|
11
|
+
* the server, so this no longer depends on the user's machine having the `gh`
|
|
12
|
+
* CLI installed, authenticated, and on PATH. Forks and archived repos are
|
|
13
|
+
* filtered out server-side.
|
|
11
14
|
*/
|
|
12
15
|
export interface SyncOrgReposResult {
|
|
13
16
|
status: 'success' | 'error';
|
|
@@ -1,44 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phase: sync-org-repos
|
|
3
3
|
*
|
|
4
|
-
* Fetches all repositories from a GitHub organization
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Fetches all repositories from a GitHub organization via the Edsger GitHub
|
|
5
|
+
* App (server-side, through the MCP `github/org_repos` endpoint), then upserts
|
|
6
|
+
* a row into the team-scoped `repositories` table for each repo. Products are
|
|
7
|
+
* NOT created here — a product is a higher-level grouping that a user links one
|
|
8
|
+
* or more repositories to afterwards.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Repos are listed with a short-lived GitHub App installation token minted on
|
|
11
|
+
* the server, so this no longer depends on the user's machine having the `gh`
|
|
12
|
+
* CLI installed, authenticated, and on PATH. Forks and archived repos are
|
|
13
|
+
* filtered out server-side.
|
|
11
14
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { promisify } from 'util';
|
|
15
|
+
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
16
|
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
15
17
|
import { logDebug, logError, logInfo, logWarning } from '../../utils/logger.js';
|
|
16
|
-
const execFileAsync = promisify(execFile);
|
|
17
18
|
const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
18
|
-
async function fetchOrgRepos(orgLogin, verbose) {
|
|
19
|
-
logInfo(`Fetching repos for org "${orgLogin}" via
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (!stdout.trim()) {
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
const repos = [];
|
|
31
|
-
for (const line of stdout.trim().split('\n')) {
|
|
32
|
-
if (!line) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
try {
|
|
36
|
-
repos.push(JSON.parse(line));
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// skip malformed lines
|
|
40
|
-
}
|
|
19
|
+
async function fetchOrgRepos(teamId, orgLogin, verbose) {
|
|
20
|
+
logInfo(`Fetching repos for org "${orgLogin}" via Edsger GitHub App...`);
|
|
21
|
+
const result = (await callMcpEndpoint('github/org_repos', {
|
|
22
|
+
team_id: teamId,
|
|
23
|
+
org: orgLogin,
|
|
24
|
+
}));
|
|
25
|
+
if (!result.configured) {
|
|
26
|
+
throw new Error(result.message ||
|
|
27
|
+
`The Edsger GitHub App is not configured for "${orgLogin}".`);
|
|
41
28
|
}
|
|
29
|
+
const repos = result.repos ?? [];
|
|
42
30
|
if (verbose) {
|
|
43
31
|
logDebug(`Fetched ${repos.length} source repos from ${orgLogin}`);
|
|
44
32
|
}
|
|
@@ -67,14 +55,14 @@ export async function syncOrgRepos(opts) {
|
|
|
67
55
|
const supabase = getSupabase();
|
|
68
56
|
let orgRepos;
|
|
69
57
|
try {
|
|
70
|
-
orgRepos = await fetchOrgRepos(orgLogin, verbose);
|
|
58
|
+
orgRepos = await fetchOrgRepos(teamId, orgLogin, verbose);
|
|
71
59
|
}
|
|
72
60
|
catch (err) {
|
|
73
61
|
const msg = err instanceof Error ? err.message : String(err);
|
|
74
62
|
logError(`Failed to fetch repos: ${msg}`);
|
|
75
63
|
return {
|
|
76
64
|
status: 'error',
|
|
77
|
-
message: `Failed to fetch repos from GitHub
|
|
65
|
+
message: `Failed to fetch repos from GitHub: ${msg}`,
|
|
78
66
|
total: 0,
|
|
79
67
|
created: 0,
|
|
80
68
|
skipped: 0,
|
package/dist/types/index.d.ts
CHANGED
|
@@ -204,34 +204,3 @@ export interface ChatReadStatus {
|
|
|
204
204
|
last_read_message_id: string | null;
|
|
205
205
|
last_read_at: string;
|
|
206
206
|
}
|
|
207
|
-
export interface ChatWorkerInitMessage {
|
|
208
|
-
type: 'init';
|
|
209
|
-
config: EdsgerConfig;
|
|
210
|
-
verbose?: boolean;
|
|
211
|
-
}
|
|
212
|
-
export interface ChatWorkerPhaseEvent {
|
|
213
|
-
type: 'event:phase_completed' | 'event:phase_failed' | 'event:issue_done' | 'event:issue_started';
|
|
214
|
-
issueId: string;
|
|
215
|
-
phase?: string;
|
|
216
|
-
summary?: string;
|
|
217
|
-
phaseOutput?: unknown;
|
|
218
|
-
error?: string;
|
|
219
|
-
repoPath?: string;
|
|
220
|
-
}
|
|
221
|
-
export interface ChatWorkerCommand {
|
|
222
|
-
type: 'command:pause_issue' | 'command:resume_issue';
|
|
223
|
-
issueId: string;
|
|
224
|
-
}
|
|
225
|
-
export interface ChatWorkerQuery {
|
|
226
|
-
type: 'query:active_issues';
|
|
227
|
-
}
|
|
228
|
-
export interface ChatWorkerReply {
|
|
229
|
-
type: 'reply:active_issues';
|
|
230
|
-
issues: {
|
|
231
|
-
id: string;
|
|
232
|
-
name: string;
|
|
233
|
-
phase: string;
|
|
234
|
-
}[];
|
|
235
|
-
}
|
|
236
|
-
export type ChatWorkerParentMessage = ChatWorkerInitMessage | ChatWorkerPhaseEvent;
|
|
237
|
-
export type ChatWorkerChildMessage = ChatWorkerCommand | ChatWorkerQuery;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"cosmiconfig": "^9.0.0",
|
|
52
52
|
"dotenv": "^16.4.5",
|
|
53
53
|
"edsger-contract": "0.7.0",
|
|
54
|
-
"edsger-tools": "0.
|
|
54
|
+
"edsger-tools": "0.9.0",
|
|
55
55
|
"gray-matter": "^4.0.3",
|
|
56
56
|
"zod": "^4.0.0"
|
|
57
57
|
},
|