edsger 0.63.1 → 0.65.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/commands/session-turn/index.d.ts +18 -0
- package/dist/commands/session-turn/index.js +114 -76
- package/dist/commands/sync-aws/index.js +44 -14
- package/dist/commands/sync-datadog/index.js +58 -21
- package/dist/commands/sync-org-repos/index.js +1 -1
- package/dist/commands/sync-terraform/index.js +47 -17
- package/dist/index.js +5 -0
- package/dist/phases/chat-processor/product-prompts.d.ts +1 -1
- package/dist/phases/chat-processor/product-prompts.js +10 -0
- package/dist/phases/flow-shared/clone-repos.d.ts +13 -0
- package/dist/phases/flow-shared/clone-repos.js +7 -7
- package/dist/phases/sync-org-repos/index.js +2 -1
- package/dist/workspace/session-workspace.d.ts +43 -0
- package/dist/workspace/session-workspace.js +90 -0
- package/eslint.config.mjs +4 -0
- package/package.json +3 -3
- package/vitest.config.ts +11 -18
|
@@ -4,12 +4,30 @@
|
|
|
4
4
|
* its own line and free of logger formatting so it parses cleanly.
|
|
5
5
|
*/
|
|
6
6
|
export declare const SESSION_ID_MARKER = "__EDSGER_SESSION_ID__=";
|
|
7
|
+
/**
|
|
8
|
+
* Marker emitted on stdout for each background `cli_*` run still in flight when
|
|
9
|
+
* the turn ends. The desktop main process parses these, watches each run to
|
|
10
|
+
* completion (the turn process itself exits immediately), and then re-engages
|
|
11
|
+
* the agent via `--run-finished` so it reports results without the user having
|
|
12
|
+
* to ask. The JSON payload is `{ run_id, pid, log_path, command }`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CLI_RUN_MARKER = "__EDSGER_CLI_RUN__=";
|
|
7
15
|
export interface SessionTurnCliOptions {
|
|
8
16
|
channelId: string;
|
|
9
17
|
productId: string;
|
|
10
18
|
repositoryIds?: string[];
|
|
11
19
|
/** SDK session id from a previous turn, to resume the same conversation. */
|
|
12
20
|
resumeSessionId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Follow-up turn triggered by the desktop watcher when a background `cli_*`
|
|
23
|
+
* run finishes. When set, the turn is driven by a synthetic prompt instead of
|
|
24
|
+
* the channel's pending human messages: the agent inspects what the run
|
|
25
|
+
* produced and reports it to the user.
|
|
26
|
+
*/
|
|
27
|
+
runFinished?: {
|
|
28
|
+
runId: string;
|
|
29
|
+
command?: string;
|
|
30
|
+
};
|
|
13
31
|
verbose?: boolean;
|
|
14
32
|
}
|
|
15
33
|
export declare function runSessionTurnCommand(options: SessionTurnCliOptions): Promise<void>;
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* its own line and free of logger formatting so it parses cleanly.
|
|
5
5
|
*/
|
|
6
6
|
export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
|
|
7
|
+
/**
|
|
8
|
+
* Marker emitted on stdout for each background `cli_*` run still in flight when
|
|
9
|
+
* the turn ends. The desktop main process parses these, watches each run to
|
|
10
|
+
* completion (the turn process itself exits immediately), and then re-engages
|
|
11
|
+
* the agent via `--run-finished` so it reports results without the user having
|
|
12
|
+
* to ask. The JSON payload is `{ run_id, pid, log_path, command }`.
|
|
13
|
+
*/
|
|
14
|
+
export const CLI_RUN_MARKER = '__EDSGER_CLI_RUN__=';
|
|
7
15
|
/**
|
|
8
16
|
* `edsger session-turn <channelId>` — run one conversational agent turn for a
|
|
9
17
|
* chat session (an ai_assistant channel bound to a product).
|
|
@@ -17,106 +25,120 @@ export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
|
|
|
17
25
|
* there is no fixed pipeline.
|
|
18
26
|
*/
|
|
19
27
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
-
import { createSessionMcpServer } from 'edsger-tools';
|
|
28
|
+
import { buildSessionAgentOptions, buildSessionSystemPrompt, buildSessionUserPrompt, createSessionMcpServer, listActiveCliRuns, loadExternalMcpServers, SESSION_MAX_TURNS, } from 'edsger-tools';
|
|
21
29
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
22
30
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
23
31
|
import { getToolDeps } from '../../tools/bootstrap.js';
|
|
24
|
-
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
2. create_issue with a clear, implementable description (the product may span multiple repos — say which).
|
|
48
|
-
3. Mark it ready for AI with update_issue_status so the engineering pipeline picks it up, clones the repos, implements the change, and opens the pull request(s).
|
|
49
|
-
4. Tell the user you filed the issue and that the pipeline will produce the PR(s); offer to check status later.
|
|
50
|
-
Only create issues/tasks when the work warrants tracking — a pure question or quality check should not leave an issue behind.
|
|
51
|
-
|
|
52
|
-
## Talking to the user
|
|
53
|
-
- send_chat_message for explanations, summaries, clarifying questions (channel_id "${channelId}").
|
|
54
|
-
- provide_options when there are 2-4 clear next steps.
|
|
55
|
-
- Be concise; report what you did and what you need.`;
|
|
56
|
-
}
|
|
57
|
-
function buildUserPrompt(messages) {
|
|
58
|
-
const conversation = messages
|
|
59
|
-
.map((m) => {
|
|
60
|
-
const who = m.sender_agent_id
|
|
61
|
-
? `[Agent: ${m.sender_name || 'Unknown'}]`
|
|
62
|
-
: `[${m.sender_name || 'User'}]`;
|
|
63
|
-
return `${who}: ${m.content}`;
|
|
64
|
-
})
|
|
65
|
-
.join('\n\n');
|
|
66
|
-
return `New message(s) in this session:\n\n${conversation}\n\nDecide what to do and act. Keep the user informed via send_chat_message.`;
|
|
32
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
33
|
+
import { cloneSessionRepos, describeSessionRepos, } from '../../workspace/session-workspace.js';
|
|
34
|
+
/**
|
|
35
|
+
* Clone (or refresh) the session product's repositories into a local session
|
|
36
|
+
* directory and produce the agent's cwd + a prompt note describing the layout.
|
|
37
|
+
*
|
|
38
|
+
* One session dir holds every repo as a subdirectory; clones are reused across
|
|
39
|
+
* turns. Best-effort: if nothing can be cloned (no GitHub config, private repo
|
|
40
|
+
* the token can't reach), the agent still runs — it can answer, file issues,
|
|
41
|
+
* etc. — just without a checked-out codebase.
|
|
42
|
+
*/
|
|
43
|
+
async function prepareSessionWorkspace(opts) {
|
|
44
|
+
let workspace = null;
|
|
45
|
+
try {
|
|
46
|
+
workspace = await cloneSessionRepos(opts);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logWarning(`Could not prepare session repos: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
sessionDir: workspace?.sessionDir,
|
|
53
|
+
repoScopeNote: workspace ? describeSessionRepos(workspace.repos) : '',
|
|
54
|
+
};
|
|
67
55
|
}
|
|
68
56
|
export async function runSessionTurnCommand(options) {
|
|
69
|
-
const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
|
|
57
|
+
const { channelId, productId, repositoryIds = [], resumeSessionId, runFinished, verbose = false, } = options;
|
|
70
58
|
// Emit the SDK session id so the desktop can persist it for the next turn.
|
|
71
59
|
const emitSessionId = (id) => {
|
|
72
60
|
if (id) {
|
|
73
61
|
process.stdout.write(`\n${SESSION_ID_MARKER}${id}\n`);
|
|
74
62
|
}
|
|
75
63
|
};
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
64
|
+
// Emit a marker for every background cli_* run still in flight, so the
|
|
65
|
+
// desktop watcher can poll each to completion and re-engage the agent.
|
|
66
|
+
const emitActiveCliRuns = () => {
|
|
67
|
+
for (const run of listActiveCliRuns()) {
|
|
68
|
+
const payload = JSON.stringify({
|
|
69
|
+
run_id: run.run_id,
|
|
70
|
+
pid: run.pid,
|
|
71
|
+
log_path: run.log_path,
|
|
72
|
+
command: run.command,
|
|
73
|
+
});
|
|
74
|
+
process.stdout.write(`\n${CLI_RUN_MARKER}${payload}\n`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
// 1. Decide what drives this turn. A normal turn replays the channel's
|
|
78
|
+
// pending human messages; a watcher-triggered follow-up replays a
|
|
79
|
+
// synthetic prompt about the background run that just finished.
|
|
80
|
+
let messages = [];
|
|
81
|
+
if (runFinished) {
|
|
82
|
+
logInfo(`Reporting completion of background run ${runFinished.runId} for session ${channelId}`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
|
|
86
|
+
channel_id: channelId,
|
|
87
|
+
}));
|
|
88
|
+
messages = pendingResult.messages ?? [];
|
|
89
|
+
if (messages.length === 0) {
|
|
90
|
+
logInfo('No pending messages for this session — nothing to do.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
|
|
84
94
|
}
|
|
85
|
-
|
|
86
|
-
//
|
|
95
|
+
// 2. Clone the product's in-scope repositories into a local session
|
|
96
|
+
// directory so the agent's Read/Grep/Glob can inspect the real code.
|
|
97
|
+
const { sessionDir, repoScopeNote } = await prepareSessionWorkspace({
|
|
98
|
+
channelId,
|
|
99
|
+
productId,
|
|
100
|
+
repositoryIds,
|
|
101
|
+
verbose,
|
|
102
|
+
});
|
|
103
|
+
// 3. Build the session toolbelt + prompts.
|
|
87
104
|
const deps = getToolDeps({
|
|
88
105
|
verbose,
|
|
89
106
|
context: { productId, channelId, repositoryIds },
|
|
90
107
|
});
|
|
91
108
|
const sessionServer = createSessionMcpServer(deps);
|
|
92
|
-
|
|
109
|
+
// User-configured external MCP servers (~/.edsger/mcp.json). Best-effort.
|
|
110
|
+
const external = loadExternalMcpServers({ warn: logWarning });
|
|
111
|
+
if (external.names.length > 0) {
|
|
112
|
+
logInfo(`External MCP servers: ${external.names.join(', ')}`);
|
|
113
|
+
}
|
|
114
|
+
const systemPrompt = buildSessionSystemPrompt({
|
|
93
115
|
channelId,
|
|
94
116
|
productId,
|
|
95
117
|
repositoryIds,
|
|
118
|
+
repoScopeNote,
|
|
119
|
+
externalMcpNames: external.names,
|
|
96
120
|
});
|
|
97
|
-
const userPrompt =
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
const userPrompt = runFinished
|
|
122
|
+
? buildRunFinishedPrompt(runFinished)
|
|
123
|
+
: buildSessionUserPrompt(messages);
|
|
124
|
+
// 4. Run one SDK turn. Resume the prior SDK session when we have its id so
|
|
125
|
+
// the conversation (and prompt cache) carries across turns. Point the
|
|
126
|
+
// agent's cwd at the session directory so its read-only file tools resolve
|
|
127
|
+
// against the cloned repos. Prompt + tool/MCP wiring come from the shared
|
|
128
|
+
// session core (edsger-tools) so CLI and worker can't drift apart.
|
|
100
129
|
let finalResponse = '';
|
|
101
130
|
let sdkSessionId = resumeSessionId ?? '';
|
|
102
131
|
try {
|
|
103
132
|
for await (const message of query({
|
|
104
133
|
prompt: userPrompt,
|
|
105
134
|
options: {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
disallowedTools: ['Edit', 'Write', 'Bash', 'NotebookEdit', 'Agent'],
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
mcpServers: { 'edsger-session': sessionServer },
|
|
117
|
-
tools: ['Read', 'Grep', 'Glob'],
|
|
118
|
-
permissionMode: 'bypassPermissions',
|
|
119
|
-
allowDangerouslySkipPermissions: true,
|
|
135
|
+
...buildSessionAgentOptions(systemPrompt, {
|
|
136
|
+
sessionServer,
|
|
137
|
+
externalServers: external.servers,
|
|
138
|
+
externalNames: external.names,
|
|
139
|
+
sessionDir,
|
|
140
|
+
maxTurns: SESSION_MAX_TURNS,
|
|
141
|
+
}),
|
|
120
142
|
model: DEFAULT_MODEL,
|
|
121
143
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
122
144
|
},
|
|
@@ -146,10 +168,11 @@ export async function runSessionTurnCommand(options) {
|
|
|
146
168
|
metadata: {},
|
|
147
169
|
}).catch(() => undefined);
|
|
148
170
|
emitSessionId(sdkSessionId);
|
|
171
|
+
emitActiveCliRuns();
|
|
149
172
|
await markProcessed(messages);
|
|
150
173
|
return;
|
|
151
174
|
}
|
|
152
|
-
//
|
|
175
|
+
// 5. Post the final reply (the agent may also have posted via
|
|
153
176
|
// send_chat_message during the turn; this carries the closing summary).
|
|
154
177
|
const reply = finalResponse.trim();
|
|
155
178
|
if (reply) {
|
|
@@ -162,12 +185,27 @@ export async function runSessionTurnCommand(options) {
|
|
|
162
185
|
logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
|
|
163
186
|
});
|
|
164
187
|
}
|
|
165
|
-
//
|
|
188
|
+
// 6. Mark the triggering messages processed so they aren't re-run. (No-op for
|
|
189
|
+
// a watcher follow-up, which has no triggering human messages.)
|
|
166
190
|
await markProcessed(messages);
|
|
167
|
-
//
|
|
191
|
+
// 7. Emit the SDK session id so the desktop persists it for the next turn,
|
|
192
|
+
// plus a marker for any background run launched this turn so the watcher
|
|
193
|
+
// keeps following it to completion.
|
|
168
194
|
emitSessionId(sdkSessionId);
|
|
195
|
+
emitActiveCliRuns();
|
|
169
196
|
logSuccess('Session turn complete.');
|
|
170
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* The synthetic user prompt for a watcher-triggered follow-up turn. Steers the
|
|
200
|
+
* agent to inspect what the finished run produced and report it — explicitly
|
|
201
|
+
* NOT to ask the user whether it should check.
|
|
202
|
+
*/
|
|
203
|
+
function buildRunFinishedPrompt(runFinished) {
|
|
204
|
+
const which = runFinished.command
|
|
205
|
+
? `the \`${runFinished.command}\` analysis (run ${runFinished.runId})`
|
|
206
|
+
: `a background analysis you launched earlier (run ${runFinished.runId})`;
|
|
207
|
+
return `${which} just finished. Inspect what it produced — call get_cli_run with run_id "${runFinished.runId}" for the log tail, then use the list_* / get_* tools (e.g. list_issues) to see the findings it filed. Summarize the results for the user via send_chat_message. Do not ask whether you should check — just report what you found. If nothing of note was produced, say so briefly.`;
|
|
208
|
+
}
|
|
171
209
|
async function markProcessed(messages) {
|
|
172
210
|
for (const m of messages) {
|
|
173
211
|
await callMcpEndpoint('chat/messages/mark_processed', {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { exec as execCb } from 'child_process';
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
15
15
|
const exec = promisify(execCb);
|
|
16
16
|
async function runAwsCmd(args, region) {
|
|
17
17
|
const regionFlag = region ? ` --region ${region}` : '';
|
|
@@ -37,7 +37,10 @@ async function checkAwsCli() {
|
|
|
37
37
|
}
|
|
38
38
|
export async function runSyncAws(teamId, options = {}) {
|
|
39
39
|
const { verbose, noCost } = options;
|
|
40
|
-
const region = options.region ??
|
|
40
|
+
const region = options.region ??
|
|
41
|
+
process.env.AWS_REGION ??
|
|
42
|
+
process.env.AWS_DEFAULT_REGION ??
|
|
43
|
+
'us-east-1';
|
|
41
44
|
logInfo(`Starting AWS sync for team ${teamId} (region: ${region})`);
|
|
42
45
|
if (!(await checkAwsCli())) {
|
|
43
46
|
logError('AWS CLI not found. Install it from https://aws.amazon.com/cli/ ' +
|
|
@@ -57,7 +60,7 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
57
60
|
}
|
|
58
61
|
// ── Phase 1: Resource discovery by Service tag ──
|
|
59
62
|
logInfo('Phase 1: Discovering resources by Service tag...');
|
|
60
|
-
|
|
63
|
+
const resources = [];
|
|
61
64
|
try {
|
|
62
65
|
let paginationToken;
|
|
63
66
|
do {
|
|
@@ -79,10 +82,12 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
79
82
|
const byService = new Map();
|
|
80
83
|
for (const res of resources) {
|
|
81
84
|
const serviceTag = res.Tags.find((t) => t.Key === 'Service')?.Value;
|
|
82
|
-
if (!serviceTag)
|
|
85
|
+
if (!serviceTag) {
|
|
83
86
|
continue;
|
|
84
|
-
|
|
87
|
+
}
|
|
88
|
+
if (!byService.has(serviceTag)) {
|
|
85
89
|
byService.set(serviceTag, []);
|
|
90
|
+
}
|
|
86
91
|
byService.get(serviceTag).push(res);
|
|
87
92
|
}
|
|
88
93
|
logInfo(`Mapped to ${byService.size} distinct services`);
|
|
@@ -92,10 +97,13 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
92
97
|
for (const [serviceName, svcResources] of byService) {
|
|
93
98
|
const teamTag = svcResources[0]?.Tags.find((t) => t.Key === 'Team')?.Value;
|
|
94
99
|
// Tier signals
|
|
95
|
-
const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') ||
|
|
100
|
+
const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') ||
|
|
101
|
+
r.ResourceARN.includes(':elasticache:'));
|
|
96
102
|
const hasLambda = svcResources.some((r) => r.ResourceARN.includes(':lambda:'));
|
|
97
103
|
const hasEcs = svcResources.some((r) => r.ResourceARN.includes(':ecs:'));
|
|
98
|
-
const tier = hasMultiAz || (hasEcs && svcResources.length > 3)
|
|
104
|
+
const tier = hasMultiAz || (hasEcs && svcResources.length > 3)
|
|
105
|
+
? 'critical'
|
|
106
|
+
: 'standard';
|
|
99
107
|
const kind = hasLambda && !hasEcs ? 'job' : 'service';
|
|
100
108
|
if (verbose) {
|
|
101
109
|
logInfo(` ${serviceName}: ${svcResources.length} resources, tier=${tier}`);
|
|
@@ -109,16 +117,36 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
109
117
|
source: 'aws',
|
|
110
118
|
source_ref: `aws:${region}:${serviceName}`,
|
|
111
119
|
field_sources: {
|
|
112
|
-
name: {
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
name: {
|
|
121
|
+
value: serviceName,
|
|
122
|
+
confidence: 1.0,
|
|
123
|
+
source: 'aws_tag',
|
|
124
|
+
source_detail: 'Tag:Service',
|
|
125
|
+
},
|
|
126
|
+
tier: {
|
|
127
|
+
value: tier,
|
|
128
|
+
confidence: hasMultiAz ? 0.8 : 0.5,
|
|
129
|
+
source: 'aws_resource_analysis',
|
|
130
|
+
},
|
|
131
|
+
...(teamTag
|
|
132
|
+
? {
|
|
133
|
+
owner: {
|
|
134
|
+
value: teamTag,
|
|
135
|
+
confidence: 0.8,
|
|
136
|
+
source: 'aws_tag',
|
|
137
|
+
source_detail: 'Tag:Team',
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
115
141
|
},
|
|
116
142
|
needs_review: false,
|
|
117
143
|
}));
|
|
118
|
-
if (result.action === 'created')
|
|
144
|
+
if (result.action === 'created') {
|
|
119
145
|
created++;
|
|
120
|
-
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
121
148
|
updated++;
|
|
149
|
+
}
|
|
122
150
|
// Add resource components
|
|
123
151
|
for (const res of svcResources) {
|
|
124
152
|
const arnParts = res.ResourceARN.split(':');
|
|
@@ -164,11 +192,13 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
164
192
|
for (const period of parsed.ResultsByTime) {
|
|
165
193
|
for (const group of period.Groups) {
|
|
166
194
|
const tagValue = group.Keys[0]?.replace('Service$', '') ?? '';
|
|
167
|
-
if (!tagValue || tagValue === '')
|
|
195
|
+
if (!tagValue || tagValue === '') {
|
|
168
196
|
continue;
|
|
197
|
+
}
|
|
169
198
|
const cost = parseFloat(group.Metrics.BlendedCost.Amount);
|
|
170
|
-
if (cost <= 0)
|
|
199
|
+
if (cost <= 0) {
|
|
171
200
|
continue;
|
|
201
|
+
}
|
|
172
202
|
if (verbose) {
|
|
173
203
|
logInfo(` ${tagValue}: $${cost.toFixed(2)}/month`);
|
|
174
204
|
}
|
|
@@ -11,40 +11,54 @@
|
|
|
11
11
|
* 3. Upsert via MCP endpoints (services.upsert_draft, services.add_component)
|
|
12
12
|
*/
|
|
13
13
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
15
15
|
function readDatadogEnv() {
|
|
16
16
|
const apiKey = process.env.DD_API_KEY ?? '';
|
|
17
17
|
const appKey = process.env.DD_APP_KEY ?? '';
|
|
18
18
|
const site = process.env.DD_SITE ?? 'datadoghq.com';
|
|
19
19
|
const missing = [];
|
|
20
|
-
if (!apiKey)
|
|
20
|
+
if (!apiKey) {
|
|
21
21
|
missing.push('DD_API_KEY');
|
|
22
|
-
|
|
22
|
+
}
|
|
23
|
+
if (!appKey) {
|
|
23
24
|
missing.push('DD_APP_KEY');
|
|
24
|
-
|
|
25
|
+
}
|
|
26
|
+
if (missing.length > 0) {
|
|
25
27
|
return { ok: false, missing };
|
|
28
|
+
}
|
|
26
29
|
return { ok: true, env: { apiKey, appKey, site } };
|
|
27
30
|
}
|
|
28
31
|
function mapTier(ddTier) {
|
|
29
|
-
if (!ddTier)
|
|
32
|
+
if (!ddTier) {
|
|
30
33
|
return 'standard';
|
|
34
|
+
}
|
|
31
35
|
const lower = ddTier.toLowerCase();
|
|
32
|
-
if (lower.includes('1') ||
|
|
36
|
+
if (lower.includes('1') ||
|
|
37
|
+
lower.includes('critical') ||
|
|
38
|
+
lower.includes('high')) {
|
|
33
39
|
return 'critical';
|
|
34
|
-
|
|
40
|
+
}
|
|
41
|
+
if (lower.includes('3') ||
|
|
42
|
+
lower.includes('low') ||
|
|
43
|
+
lower.includes('experiment')) {
|
|
35
44
|
return 'experimental';
|
|
45
|
+
}
|
|
36
46
|
return 'standard';
|
|
37
47
|
}
|
|
38
48
|
function mapKind(ddType) {
|
|
39
|
-
if (!ddType)
|
|
49
|
+
if (!ddType) {
|
|
40
50
|
return 'service';
|
|
51
|
+
}
|
|
41
52
|
const lower = ddType.toLowerCase();
|
|
42
|
-
if (lower === 'web' || lower === 'website' || lower === 'frontend')
|
|
53
|
+
if (lower === 'web' || lower === 'website' || lower === 'frontend') {
|
|
43
54
|
return 'website';
|
|
44
|
-
|
|
55
|
+
}
|
|
56
|
+
if (lower === 'library' || lower === 'lib') {
|
|
45
57
|
return 'library';
|
|
46
|
-
|
|
58
|
+
}
|
|
59
|
+
if (lower === 'job' || lower === 'worker' || lower === 'cron') {
|
|
47
60
|
return 'job';
|
|
61
|
+
}
|
|
48
62
|
return 'service';
|
|
49
63
|
}
|
|
50
64
|
export async function runSyncDatadog(teamId, options = {}) {
|
|
@@ -97,8 +111,9 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
97
111
|
skipped++;
|
|
98
112
|
continue;
|
|
99
113
|
}
|
|
100
|
-
if (verbose)
|
|
114
|
+
if (verbose) {
|
|
101
115
|
logInfo(`Processing: ${name}`);
|
|
116
|
+
}
|
|
102
117
|
try {
|
|
103
118
|
// Upsert service
|
|
104
119
|
const result = (await callMcpEndpoint('services.upsert_draft', {
|
|
@@ -114,21 +129,40 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
114
129
|
source_ref: `datadog:${name}`,
|
|
115
130
|
field_sources: {
|
|
116
131
|
name: { value: name, confidence: 1.0, source: 'datadog' },
|
|
117
|
-
tier: {
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
tier: {
|
|
133
|
+
value: mapTier(schema.tier),
|
|
134
|
+
confidence: 0.9,
|
|
135
|
+
source: 'datadog',
|
|
136
|
+
},
|
|
137
|
+
language: {
|
|
138
|
+
value: schema.languages?.[0],
|
|
139
|
+
confidence: 1.0,
|
|
140
|
+
source: 'datadog',
|
|
141
|
+
},
|
|
142
|
+
...(schema.team
|
|
143
|
+
? {
|
|
144
|
+
owner: {
|
|
145
|
+
value: schema.team,
|
|
146
|
+
confidence: 0.9,
|
|
147
|
+
source: 'datadog',
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
120
151
|
},
|
|
121
152
|
needs_review: false,
|
|
122
153
|
}));
|
|
123
154
|
const serviceId = result.service.id;
|
|
124
|
-
if (result.action === 'created')
|
|
155
|
+
if (result.action === 'created') {
|
|
125
156
|
created++;
|
|
126
|
-
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
127
159
|
updated++;
|
|
160
|
+
}
|
|
128
161
|
// Add repo components
|
|
129
162
|
for (const repo of schema.repos ?? []) {
|
|
130
|
-
if (!repo.url)
|
|
163
|
+
if (!repo.url) {
|
|
131
164
|
continue;
|
|
165
|
+
}
|
|
132
166
|
const repoName = repo.url
|
|
133
167
|
.replace(/^https?:\/\/github\.com\//, '')
|
|
134
168
|
.replace(/\.git$/, '');
|
|
@@ -143,14 +177,16 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
143
177
|
componentCount++;
|
|
144
178
|
}
|
|
145
179
|
catch {
|
|
146
|
-
if (verbose)
|
|
180
|
+
if (verbose) {
|
|
147
181
|
logWarning(` Failed to add repo: ${repo.url}`);
|
|
182
|
+
}
|
|
148
183
|
}
|
|
149
184
|
}
|
|
150
185
|
// Add link components (dashboards, runbooks, etc.)
|
|
151
186
|
for (const link of schema.links ?? []) {
|
|
152
|
-
if (!link.url)
|
|
187
|
+
if (!link.url) {
|
|
153
188
|
continue;
|
|
189
|
+
}
|
|
154
190
|
const kind = link.type === 'dashboard' || link.type === 'grafana'
|
|
155
191
|
? 'dashboard'
|
|
156
192
|
: link.type === 'runbook' || link.type === 'doc'
|
|
@@ -169,8 +205,9 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
169
205
|
componentCount++;
|
|
170
206
|
}
|
|
171
207
|
catch {
|
|
172
|
-
if (verbose)
|
|
208
|
+
if (verbose) {
|
|
173
209
|
logWarning(` Failed to add link: ${link.url}`);
|
|
210
|
+
}
|
|
174
211
|
}
|
|
175
212
|
}
|
|
176
213
|
// Add Slack channel
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Reads the team's configured github_org, fetches all repos from that org
|
|
5
5
|
* via the local `gh` CLI, and upserts a repositories row for each repo.
|
|
6
6
|
*/
|
|
7
|
-
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
8
7
|
import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
|
|
8
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
9
9
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
10
10
|
const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
11
11
|
export async function runSyncOrgRepos(teamId, options = {}) {
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* about module structure, variable passing, resource tagging, etc.
|
|
11
11
|
*/
|
|
12
12
|
import { execSync } from 'child_process';
|
|
13
|
-
import { mkdtempSync,
|
|
13
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
|
|
14
14
|
import { tmpdir } from 'os';
|
|
15
15
|
import { join, relative } from 'path';
|
|
16
16
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
17
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
17
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
18
18
|
export async function runSyncTerraform(teamId, options = {}) {
|
|
19
19
|
const { verbose } = options;
|
|
20
20
|
logInfo(`Starting Terraform sync for team ${teamId}`);
|
|
@@ -57,8 +57,9 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
57
57
|
function walk(dir) {
|
|
58
58
|
try {
|
|
59
59
|
for (const entry of readdirSync(dir)) {
|
|
60
|
-
if (entry.startsWith('.') || entry === 'node_modules')
|
|
60
|
+
if (entry.startsWith('.') || entry === 'node_modules') {
|
|
61
61
|
continue;
|
|
62
|
+
}
|
|
62
63
|
const full = join(dir, entry);
|
|
63
64
|
try {
|
|
64
65
|
const stat = statSync(full);
|
|
@@ -100,8 +101,9 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
100
101
|
moduleFiles.get(moduleDir).push({ path: relPath, content });
|
|
101
102
|
}
|
|
102
103
|
catch {
|
|
103
|
-
if (verbose)
|
|
104
|
+
if (verbose) {
|
|
104
105
|
logWarning(`Could not read ${relPath}`);
|
|
106
|
+
}
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
logInfo(`Found ${moduleFiles.size} module directories`);
|
|
@@ -111,13 +113,14 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
111
113
|
let components = 0;
|
|
112
114
|
for (const [moduleDir, files] of moduleFiles) {
|
|
113
115
|
// Skip root-level files that are just backend/provider config
|
|
114
|
-
if (moduleDir === '.' &&
|
|
116
|
+
if (moduleDir === '.' &&
|
|
117
|
+
files.every((f) => /^(backend|provider|versions|terraform)\b/.test(f.path))) {
|
|
115
118
|
continue;
|
|
116
119
|
}
|
|
117
120
|
// Extract service name from module directory
|
|
118
121
|
const moduleName = moduleDir === '.'
|
|
119
|
-
? repoFullName?.split('/')[1] ?? 'root'
|
|
120
|
-
: moduleDir.split('/').pop() ?? moduleDir;
|
|
122
|
+
? (repoFullName?.split('/')[1] ?? 'root')
|
|
123
|
+
: (moduleDir.split('/').pop() ?? moduleDir);
|
|
121
124
|
// Extract resource ARNs and types from .tf content
|
|
122
125
|
const resources = [];
|
|
123
126
|
const tags = {};
|
|
@@ -137,18 +140,22 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
137
140
|
tags.Team = m[1];
|
|
138
141
|
}
|
|
139
142
|
// Tier signals
|
|
140
|
-
if (/multi_az\s*=\s*true/i.test(file.content))
|
|
143
|
+
if (/multi_az\s*=\s*true/i.test(file.content)) {
|
|
141
144
|
hasMultiAz = true;
|
|
142
|
-
|
|
145
|
+
}
|
|
146
|
+
if (/aws_appautoscaling|autoscaling_group/i.test(file.content)) {
|
|
143
147
|
hasAutoscaling = true;
|
|
148
|
+
}
|
|
144
149
|
// Module references (dependencies)
|
|
145
150
|
for (const m of file.content.matchAll(/module\.([a-zA-Z0-9_-]+)\./g)) {
|
|
146
|
-
if (!moduleRefs.includes(m[1]))
|
|
151
|
+
if (!moduleRefs.includes(m[1])) {
|
|
147
152
|
moduleRefs.push(m[1]);
|
|
153
|
+
}
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
|
-
if (resources.length === 0)
|
|
156
|
+
if (resources.length === 0) {
|
|
151
157
|
continue;
|
|
158
|
+
}
|
|
152
159
|
const serviceName = tags.Service ?? moduleName;
|
|
153
160
|
const tier = hasMultiAz || hasAutoscaling ? 'critical' : 'standard';
|
|
154
161
|
if (verbose) {
|
|
@@ -163,16 +170,35 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
163
170
|
source: 'terraform',
|
|
164
171
|
source_ref: `terraform:${repoFullName ?? localDir}:${moduleDir}`,
|
|
165
172
|
field_sources: {
|
|
166
|
-
name: {
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
name: {
|
|
174
|
+
value: serviceName,
|
|
175
|
+
confidence: tags.Service ? 1.0 : 0.7,
|
|
176
|
+
source: 'terraform',
|
|
177
|
+
source_detail: moduleDir,
|
|
178
|
+
},
|
|
179
|
+
tier: {
|
|
180
|
+
value: tier,
|
|
181
|
+
confidence: hasMultiAz ? 0.8 : 0.5,
|
|
182
|
+
source: 'terraform',
|
|
183
|
+
},
|
|
184
|
+
...(tags.Team
|
|
185
|
+
? {
|
|
186
|
+
owner: {
|
|
187
|
+
value: tags.Team,
|
|
188
|
+
confidence: 0.8,
|
|
189
|
+
source: 'terraform_tag',
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
169
193
|
},
|
|
170
194
|
needs_review: !tags.Service,
|
|
171
195
|
}));
|
|
172
|
-
if (result.action === 'created')
|
|
196
|
+
if (result.action === 'created') {
|
|
173
197
|
created++;
|
|
174
|
-
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
175
200
|
updated++;
|
|
201
|
+
}
|
|
176
202
|
// Add AWS resource components
|
|
177
203
|
for (const res of resources) {
|
|
178
204
|
try {
|
|
@@ -181,7 +207,11 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
181
207
|
kind: 'aws_resource',
|
|
182
208
|
provider: 'terraform',
|
|
183
209
|
external_id: `${res.type}.${res.name}`,
|
|
184
|
-
metadata: {
|
|
210
|
+
metadata: {
|
|
211
|
+
resource_type: res.type,
|
|
212
|
+
resource_name: res.name,
|
|
213
|
+
module: moduleDir,
|
|
214
|
+
},
|
|
185
215
|
});
|
|
186
216
|
components++;
|
|
187
217
|
}
|
package/dist/index.js
CHANGED
|
@@ -441,6 +441,8 @@ program
|
|
|
441
441
|
.requiredOption('--product <productId>', 'Product the session is bound to')
|
|
442
442
|
.option('--repos <ids>', 'Comma-separated repository IDs the agent may touch', '')
|
|
443
443
|
.option('--resume <sessionId>', 'SDK session id from a previous turn to resume the same conversation')
|
|
444
|
+
.option('--run-finished <runId>', 'Follow-up turn: report the results of a background cli_* run that finished')
|
|
445
|
+
.option('--run-command <command>', 'The command name of the finished run (used with --run-finished)')
|
|
444
446
|
.option('-v, --verbose', 'Verbose output')
|
|
445
447
|
.action(async (channelId, opts) => {
|
|
446
448
|
try {
|
|
@@ -452,6 +454,9 @@ program
|
|
|
452
454
|
.map((s) => s.trim())
|
|
453
455
|
.filter(Boolean),
|
|
454
456
|
resumeSessionId: opts.resume,
|
|
457
|
+
runFinished: opts.runFinished
|
|
458
|
+
? { runId: opts.runFinished, command: opts.runCommand }
|
|
459
|
+
: undefined,
|
|
455
460
|
verbose: opts.verbose,
|
|
456
461
|
});
|
|
457
462
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* System prompts for the product chat AI processor.
|
|
3
3
|
*/
|
|
4
|
-
export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
|
|
4
|
+
export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n- Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)\n\n## Analyzing the code\nYou cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles \u2014 launch the matching analysis:\n- \"what should we optimize / refactor / improve\", code smells, dead code, perf, type-safety \u2192 cli_find_smells\n- bugs / correctness audit \u2192 cli_find_bugs\n- architecture / coupling / layering / cycles \u2192 cli_find_architecture\n- feature opportunities from feedback + code \u2192 cli_find_features\n\nThese run in the background and FILE EACH FINDING AS AN ISSUE \u2014 they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
|
|
@@ -11,6 +11,16 @@ You can see the product's current state, all issues (with statuses), and team me
|
|
|
11
11
|
- Create tasks for team members (human) or AI
|
|
12
12
|
- Look up product team members by name
|
|
13
13
|
- Send follow-up messages and present options to the user
|
|
14
|
+
- Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)
|
|
15
|
+
|
|
16
|
+
## Analyzing the code
|
|
17
|
+
You cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles — launch the matching analysis:
|
|
18
|
+
- "what should we optimize / refactor / improve", code smells, dead code, perf, type-safety → cli_find_smells
|
|
19
|
+
- bugs / correctness audit → cli_find_bugs
|
|
20
|
+
- architecture / coupling / layering / cycles → cli_find_architecture
|
|
21
|
+
- feature opportunities from feedback + code → cli_find_features
|
|
22
|
+
|
|
23
|
+
These run in the background and FILE EACH FINDING AS AN ISSUE — they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.
|
|
14
24
|
|
|
15
25
|
## How to Respond
|
|
16
26
|
1. **Understand the intent** — Is this a question about product state, a request to create something, or coordination work?
|
|
@@ -31,6 +31,19 @@ export interface CloneFlowReposFailure {
|
|
|
31
31
|
ok: false;
|
|
32
32
|
message: string;
|
|
33
33
|
}
|
|
34
|
+
export declare function safeDirName(fullName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the repositories a flow targets (by id, preserving the stored
|
|
37
|
+
* order), falling back to the product's primary repo.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback: {
|
|
40
|
+
owner: string;
|
|
41
|
+
repo: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
fullName: string;
|
|
44
|
+
owner: string;
|
|
45
|
+
repo: string;
|
|
46
|
+
}[]>;
|
|
34
47
|
export declare function cloneFlowRepos(opts: {
|
|
35
48
|
productId: string;
|
|
36
49
|
repositoryIds: string[];
|
|
@@ -17,14 +17,14 @@ import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
|
17
17
|
import { getSupabase } from '../../supabase/client.js';
|
|
18
18
|
import { logInfo, logWarning } from '../../utils/logger.js';
|
|
19
19
|
import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
|
|
20
|
-
function safeDirName(fullName) {
|
|
20
|
+
export function safeDirName(fullName) {
|
|
21
21
|
return fullName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the repositories a flow targets (by id, preserving the stored
|
|
25
25
|
* order), falling back to the product's primary repo.
|
|
26
26
|
*/
|
|
27
|
-
async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
27
|
+
export async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
28
28
|
if (repositoryIds.length === 0) {
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
@@ -39,10 +39,7 @@ async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
|
39
39
|
.from('repositories')
|
|
40
40
|
.select('id, full_name')
|
|
41
41
|
.in('id', repositoryIds);
|
|
42
|
-
const byId = new Map((
|
|
43
|
-
r.id,
|
|
44
|
-
r.full_name,
|
|
45
|
-
]));
|
|
42
|
+
const byId = new Map((data ?? []).map((r) => [r.id, r.full_name]));
|
|
46
43
|
// Preserve the caller's order (flows.repository_ids is ordered).
|
|
47
44
|
const resolved = [];
|
|
48
45
|
for (const id of repositoryIds) {
|
|
@@ -104,7 +101,10 @@ export async function cloneFlowRepos(opts) {
|
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
103
|
if (repos.length === 0) {
|
|
107
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
message: 'Failed to clone any of the selected repositories.',
|
|
107
|
+
};
|
|
108
108
|
}
|
|
109
109
|
if (repos.length > 1) {
|
|
110
110
|
logInfo(`Cloned ${repos.length} repos for ${workspaceKey}: ${repos.map((r) => r.fullName).join(', ')}`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workspace — clones a product's configured repositories into a single
|
|
3
|
+
* local "session directory", one subdirectory per repo.
|
|
4
|
+
*
|
|
5
|
+
* A chat session (an `ai_assistant` channel) is bound to a product, and a
|
|
6
|
+
* product can span several repositories. For the session agent's read-only
|
|
7
|
+
* tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
|
|
8
|
+
* repo into:
|
|
9
|
+
*
|
|
10
|
+
* <workspace>/sessions/<channelId>/<owner>_<repo>/
|
|
11
|
+
*
|
|
12
|
+
* and point the agent's cwd at the per-session directory. The clones are reused
|
|
13
|
+
* (and fetched) across turns of the same session rather than re-cloned each
|
|
14
|
+
* time, so follow-up turns are fast and always see the latest code.
|
|
15
|
+
*
|
|
16
|
+
* Repo resolution and the secure-token clone are shared with flow generation
|
|
17
|
+
* (`phases/flow-shared/clone-repos.ts`).
|
|
18
|
+
*/
|
|
19
|
+
import { type ClonedRepo } from '../phases/flow-shared/clone-repos.js';
|
|
20
|
+
export interface SessionWorkspace {
|
|
21
|
+
/** Directory holding every cloned repo as a subdirectory; the agent's cwd. */
|
|
22
|
+
sessionDir: string;
|
|
23
|
+
/** The repos that were successfully cloned/refreshed for this session. */
|
|
24
|
+
repos: ClonedRepo[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Clone (or refresh) every repository the session's product is scoped to into a
|
|
28
|
+
* single local session directory. Returns the directory to use as the agent's
|
|
29
|
+
* cwd plus the cloned repos, or `null` when no repo could be made available
|
|
30
|
+
* (no GitHub config, or every clone failed) so the caller can still run the
|
|
31
|
+
* agent without a codebase.
|
|
32
|
+
*/
|
|
33
|
+
export declare function cloneSessionRepos(opts: {
|
|
34
|
+
channelId: string;
|
|
35
|
+
productId: string;
|
|
36
|
+
repositoryIds: string[];
|
|
37
|
+
verbose?: boolean;
|
|
38
|
+
}): Promise<SessionWorkspace | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Build a note for the agent's system prompt describing where each repo is
|
|
41
|
+
* checked out, so it points Read / Grep / Glob at the right subdirectory.
|
|
42
|
+
*/
|
|
43
|
+
export declare function describeSessionRepos(repos: ClonedRepo[]): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workspace — clones a product's configured repositories into a single
|
|
3
|
+
* local "session directory", one subdirectory per repo.
|
|
4
|
+
*
|
|
5
|
+
* A chat session (an `ai_assistant` channel) is bound to a product, and a
|
|
6
|
+
* product can span several repositories. For the session agent's read-only
|
|
7
|
+
* tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
|
|
8
|
+
* repo into:
|
|
9
|
+
*
|
|
10
|
+
* <workspace>/sessions/<channelId>/<owner>_<repo>/
|
|
11
|
+
*
|
|
12
|
+
* and point the agent's cwd at the per-session directory. The clones are reused
|
|
13
|
+
* (and fetched) across turns of the same session rather than re-cloned each
|
|
14
|
+
* time, so follow-up turns are fast and always see the latest code.
|
|
15
|
+
*
|
|
16
|
+
* Repo resolution and the secure-token clone are shared with flow generation
|
|
17
|
+
* (`phases/flow-shared/clone-repos.ts`).
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync } from 'fs';
|
|
20
|
+
import { getGitHubConfigByProduct } from '../api/github.js';
|
|
21
|
+
import { resolveTargetRepos, safeDirName, } from '../phases/flow-shared/clone-repos.js';
|
|
22
|
+
import { logInfo, logWarning } from '../utils/logger.js';
|
|
23
|
+
import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from './workspace-manager.js';
|
|
24
|
+
const SESSIONS_DIR_NAME = 'sessions';
|
|
25
|
+
/**
|
|
26
|
+
* Clone (or refresh) every repository the session's product is scoped to into a
|
|
27
|
+
* single local session directory. Returns the directory to use as the agent's
|
|
28
|
+
* cwd plus the cloned repos, or `null` when no repo could be made available
|
|
29
|
+
* (no GitHub config, or every clone failed) so the caller can still run the
|
|
30
|
+
* agent without a codebase.
|
|
31
|
+
*/
|
|
32
|
+
export async function cloneSessionRepos(opts) {
|
|
33
|
+
const { channelId, productId, repositoryIds, verbose } = opts;
|
|
34
|
+
const gh = await getGitHubConfigByProduct(productId, verbose);
|
|
35
|
+
if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
|
|
36
|
+
logWarning(gh.message ||
|
|
37
|
+
'No GitHub repository configured for this product; the session agent will run without a codebase.');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Resolve the in-scope repos (ordered), falling back to the product's
|
|
41
|
+
// primary repo when none were explicitly selected.
|
|
42
|
+
const targets = await resolveTargetRepos(productId, repositoryIds, {
|
|
43
|
+
owner: gh.owner,
|
|
44
|
+
repo: gh.repo,
|
|
45
|
+
});
|
|
46
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
47
|
+
const sessionDir = getIssueRepoPath(workspaceRoot, `${SESSIONS_DIR_NAME}/${safeDirName(channelId)}`);
|
|
48
|
+
// Ensure the per-session parent exists before cloning into subdirectories.
|
|
49
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
50
|
+
const repos = [];
|
|
51
|
+
for (const target of targets) {
|
|
52
|
+
try {
|
|
53
|
+
// The product-level token (installation or user PAT/OAuth) is reused for
|
|
54
|
+
// every repo; if it can't access one, that clone fails and we skip it
|
|
55
|
+
// rather than aborting the whole session.
|
|
56
|
+
const { repoPath } = cloneIssueRepo(sessionDir, safeDirName(target.fullName), target.owner, target.repo, gh.token);
|
|
57
|
+
repos.push({
|
|
58
|
+
fullName: target.fullName,
|
|
59
|
+
owner: target.owner,
|
|
60
|
+
repo: target.repo,
|
|
61
|
+
dir: repoPath,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
logWarning(`Skipping ${target.fullName}: clone failed (${err instanceof Error ? err.message : String(err)})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (repos.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
logInfo(`Session ${channelId}: ${repos.length} repo(s) ready in ${sessionDir}`);
|
|
72
|
+
return { sessionDir, repos };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a note for the agent's system prompt describing where each repo is
|
|
76
|
+
* checked out, so it points Read / Grep / Glob at the right subdirectory.
|
|
77
|
+
*/
|
|
78
|
+
export function describeSessionRepos(repos) {
|
|
79
|
+
if (repos.length === 0) {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
const list = repos.map((r) => `- ${r.fullName} → subdirectory \`${safeDirName(r.fullName)}/\``);
|
|
83
|
+
return [
|
|
84
|
+
repos.length === 1
|
|
85
|
+
? 'The product code is checked out in your working directory:'
|
|
86
|
+
: `This product spans ${repos.length} repositories, each checked out as a subdirectory of your working directory:`,
|
|
87
|
+
...list,
|
|
88
|
+
'Use Read / Grep / Glob against these paths to inspect the actual code before answering.',
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
package/eslint.config.mjs
CHANGED
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
} from '../edsger-lint/eslint/index.mjs'
|
|
6
6
|
|
|
7
7
|
export default [
|
|
8
|
+
// Test-only infra (the node:test→vitest shim) lives outside the src TS
|
|
9
|
+
// project, so skip it for the type-checked lint.
|
|
10
|
+
{ ignores: ['test/**'] },
|
|
11
|
+
|
|
8
12
|
...baseConfig,
|
|
9
13
|
|
|
10
14
|
// Type-checked rules
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"commander": "^12.0.0",
|
|
51
51
|
"cosmiconfig": "^9.0.0",
|
|
52
52
|
"dotenv": "^16.4.5",
|
|
53
|
-
"edsger-contract": "0.
|
|
54
|
-
"edsger-tools": "0.
|
|
53
|
+
"edsger-contract": "0.7.0",
|
|
54
|
+
"edsger-tools": "0.8.0",
|
|
55
55
|
"gray-matter": "^4.0.3",
|
|
56
56
|
"zod": "^4.0.0"
|
|
57
57
|
},
|
package/vitest.config.ts
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
1
3
|
import { defineConfig } from 'vitest/config'
|
|
2
4
|
|
|
3
5
|
export default defineConfig({
|
|
4
6
|
test: {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
include: [
|
|
10
|
-
'src/phases/run-sheet/__tests__/**/*.test.ts',
|
|
11
|
-
'src/phases/code-refine-verification/__tests__/**/*.test.ts',
|
|
12
|
-
'src/phases/find-bugs/__tests__/**/*.test.ts',
|
|
13
|
-
'src/phases/find-features/__tests__/**/*.test.ts',
|
|
14
|
-
'src/phases/find-smells/__tests__/**/*.test.ts',
|
|
15
|
-
'src/phases/sync-github-issues/__tests__/**/*.test.ts',
|
|
16
|
-
'src/phases/sync-sentry-issues/__tests__/**/*.test.ts',
|
|
17
|
-
'src/phases/sync-shared/__tests__/**/*.test.ts',
|
|
18
|
-
'src/phases/screen-flow/__tests__/**/*.test.ts',
|
|
19
|
-
'src/phases/recipes/__tests__/**/*.test.ts',
|
|
20
|
-
'src/types/__tests__/**/*.test.ts',
|
|
21
|
-
'src/commands/find-smells/__tests__/**/*.test.ts',
|
|
22
|
-
],
|
|
7
|
+
// Run every test under src, including the many files written against the
|
|
8
|
+
// node:test API — those used to be silently skipped. The `node:test` alias
|
|
9
|
+
// below lets them run under vitest unchanged.
|
|
10
|
+
include: ['src/**/__tests__/**/*.test.ts'],
|
|
23
11
|
exclude: ['dist/**', 'node_modules/**'],
|
|
24
12
|
environment: 'node',
|
|
13
|
+
alias: {
|
|
14
|
+
'node:test': fileURLToPath(
|
|
15
|
+
new URL('./test/node-test-shim.ts', import.meta.url)
|
|
16
|
+
),
|
|
17
|
+
},
|
|
25
18
|
},
|
|
26
19
|
})
|