@ghl-ai/aw 0.1.37-beta.8 → 0.1.37
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/cli.mjs +12 -2
- package/commands/init.mjs +27 -7
- package/commands/link-project.mjs +3 -0
- package/commands/nuke.mjs +19 -14
- package/commands/pull.mjs +74 -32
- package/commands/push-rules.mjs +212 -0
- package/commands/push.mjs +27 -3
- package/commands/slack-sim.mjs +128 -0
- package/config.mjs +1 -1
- package/constants.mjs +8 -1
- package/ecc.mjs +80 -4
- package/file-tree.mjs +76 -0
- package/fmt.mjs +14 -0
- package/git.mjs +2 -1
- package/hooks.mjs +101 -0
- package/integrate.mjs +49 -11
- package/package.json +10 -4
- package/render-rules.mjs +483 -0
- package/slack-sim/fake-slack.mjs +200 -0
- package/slack-sim/http.mjs +170 -0
- package/slack-sim/in-process.mjs +263 -0
- package/slack-sim/render.mjs +42 -0
- package/slack-sim/scenario.mjs +64 -0
- package/slack-sim/scenarios/checkpoint-approve.json +21 -0
- package/slack-sim/scenarios/image-thread.json +27 -0
- package/slack-sim/scenarios/implementation-basic.json +18 -0
- package/slack-sim/scenarios/poll-webhook-race.json +18 -0
- package/slack-sim/scenarios/review-pr.json +14 -0
- package/telemetry.mjs +5 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function createHttpTransport(opts = {}) {
|
|
6
|
+
let pg;
|
|
7
|
+
try {
|
|
8
|
+
({ default: pg } = await import('pg'));
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error('HTTP mode requires the `pg` package in this repo environment');
|
|
11
|
+
}
|
|
12
|
+
const apiBase = (opts.apiBase || 'http://localhost:3100/agentic-workspace/api').replace(/\/$/, '');
|
|
13
|
+
const headers = {
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
...(opts.orgId ? { 'x-org-id': opts.orgId } : {}),
|
|
16
|
+
...(opts.teamId ? { 'x-team-id': opts.teamId } : {}),
|
|
17
|
+
...(process.env.API_KEY ? { 'x-api-key': process.env.API_KEY } : {}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pool = new pg.Pool({
|
|
21
|
+
host: process.env.PGHOST ?? 'localhost',
|
|
22
|
+
port: parseInt(process.env.PGPORT ?? '5433', 10),
|
|
23
|
+
user: process.env.PGUSER ?? 'ghl_ai',
|
|
24
|
+
...(process.env.PGPASSWORD ? { password: process.env.PGPASSWORD } : {}),
|
|
25
|
+
database: process.env.PGDATABASE ?? 'ghl_ai_orchestrator',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function post(path, body) {
|
|
29
|
+
const res = await fetch(`${apiBase}${path}`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
return { status: res.status, body: text ? JSON.parse(text) : {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function get(path) {
|
|
39
|
+
const res = await fetch(`${apiBase}${path}`, { headers });
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
return { status: res.status, body: text ? JSON.parse(text) : {} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runAction(action, scenario) {
|
|
45
|
+
const channel = action.channel || scenario.defaults.channel;
|
|
46
|
+
const user = action.user || scenario.defaults.user;
|
|
47
|
+
const ts = action.ts || `${Date.now()}.001`;
|
|
48
|
+
const threadTs = action.threadTs || action.thread_ts || scenario.defaults.threadTs;
|
|
49
|
+
|
|
50
|
+
switch (action.type) {
|
|
51
|
+
case 'app_mention_top_level':
|
|
52
|
+
await post('/slack/events', {
|
|
53
|
+
type: 'event_callback',
|
|
54
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
55
|
+
event: { type: 'app_mention', channel, user, text: action.text, ts, event_ts: ts, files: action.files },
|
|
56
|
+
});
|
|
57
|
+
return { channel, threadTs: ts, ts };
|
|
58
|
+
case 'app_mention_thread':
|
|
59
|
+
await post('/slack/events', {
|
|
60
|
+
type: 'event_callback',
|
|
61
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
62
|
+
event: { type: 'app_mention', channel, user, text: action.text, ts, thread_ts: threadTs, event_ts: ts, files: action.files },
|
|
63
|
+
});
|
|
64
|
+
return { channel, threadTs, ts };
|
|
65
|
+
case 'message_thread_untagged':
|
|
66
|
+
await post('/slack/events', {
|
|
67
|
+
type: 'event_callback',
|
|
68
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
69
|
+
event: { type: 'message', channel, user, text: action.text, ts, thread_ts: threadTs, event_ts: ts, files: action.files },
|
|
70
|
+
});
|
|
71
|
+
return { channel, threadTs, ts };
|
|
72
|
+
case 'dispatch_confirm':
|
|
73
|
+
if (!action.value) throw new Error('HTTP mode dispatch_confirm requires action.value');
|
|
74
|
+
await post('/slack/interact', {
|
|
75
|
+
payload: JSON.stringify({
|
|
76
|
+
type: 'block_actions',
|
|
77
|
+
user: { id: user },
|
|
78
|
+
channel: { id: channel },
|
|
79
|
+
message: { ts: action.messageTs || threadTs, thread_ts: threadTs },
|
|
80
|
+
container: { channel_id: channel, message_ts: action.messageTs || threadTs },
|
|
81
|
+
actions: [{ action_id: 'dispatch_confirm', value: action.value }],
|
|
82
|
+
state: {
|
|
83
|
+
values: {
|
|
84
|
+
dispatch_resource: { dispatch_resource: { selected_option: { value: action.resource || 'ship-lfg' } } },
|
|
85
|
+
dispatch_repo: { dispatch_repo: { selected_option: action.repo ? { value: action.repo } : undefined } },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
return { channel, threadTs, ts };
|
|
91
|
+
case 'button_click':
|
|
92
|
+
if (!action.value) {
|
|
93
|
+
const state = await getState({ channel, threadTs });
|
|
94
|
+
if (action.actionId === 'approve_plan' && state.backendRef) {
|
|
95
|
+
action.value = JSON.stringify({ agentId: state.backendRef });
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error('HTTP mode button_click requires action.value (or a resolvable backendRef for approve_plan)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await post('/slack/interact', {
|
|
101
|
+
payload: JSON.stringify({
|
|
102
|
+
type: 'block_actions',
|
|
103
|
+
user: { id: user },
|
|
104
|
+
channel: { id: channel },
|
|
105
|
+
message: { ts: action.messageTs || threadTs, thread_ts: threadTs },
|
|
106
|
+
container: { channel_id: channel, message_ts: action.messageTs || threadTs },
|
|
107
|
+
actions: [{ action_id: action.actionId, value: action.value }],
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
return { channel, threadTs, ts };
|
|
111
|
+
case 'cursor_webhook': {
|
|
112
|
+
const state = await getState({ channel, threadTs });
|
|
113
|
+
await post('/slack/cursor-webhook', {
|
|
114
|
+
id: action.agentId || state.backendRef,
|
|
115
|
+
status: action.status,
|
|
116
|
+
summary: action.summary,
|
|
117
|
+
source: action.source,
|
|
118
|
+
target: action.target,
|
|
119
|
+
linesAdded: action.linesAdded,
|
|
120
|
+
linesRemoved: action.linesRemoved,
|
|
121
|
+
filesChanged: action.filesChanged,
|
|
122
|
+
});
|
|
123
|
+
return { channel, threadTs, ts };
|
|
124
|
+
}
|
|
125
|
+
case 'wait':
|
|
126
|
+
await sleep(action.ms || 1000);
|
|
127
|
+
return { channel, threadTs, ts };
|
|
128
|
+
case 'assert':
|
|
129
|
+
return { channel, threadTs, ts };
|
|
130
|
+
default:
|
|
131
|
+
throw new Error(`Unsupported action type in HTTP mode: ${action.type}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function getState({ channel, threadTs }) {
|
|
136
|
+
const latest = await get('/dispatch/latest').then((r) => r.body).catch(() => null);
|
|
137
|
+
const dbRow = await pool.query(
|
|
138
|
+
`SELECT st.thread_ts, cr.id AS command_run_id, cr.backend_ref, cr.status, cr.output
|
|
139
|
+
FROM slack_threads st
|
|
140
|
+
JOIN command_runs cr ON cr.id = st.command_run_id
|
|
141
|
+
WHERE st.channel_id = $1 AND st.thread_ts = $2
|
|
142
|
+
ORDER BY st.created_at DESC
|
|
143
|
+
LIMIT 1`,
|
|
144
|
+
[channel, threadTs],
|
|
145
|
+
).catch(() => ({ rows: [] }));
|
|
146
|
+
const row = dbRow.rows?.[0] || latest;
|
|
147
|
+
return {
|
|
148
|
+
channel,
|
|
149
|
+
threadTs,
|
|
150
|
+
commandRunId: row?.command_run_id || row?.id || null,
|
|
151
|
+
backendRef: row?.backend_ref || null,
|
|
152
|
+
status: row?.status || null,
|
|
153
|
+
checkpointActive: Boolean(row?.output?.checkpoint?.active),
|
|
154
|
+
prRole: row?.output?.slackPrPresentation?.prRole || null,
|
|
155
|
+
prUrl: row?.output?.slackPrPresentation?.primaryPrUrl || null,
|
|
156
|
+
branch: row?.output?.slackPrPresentation?.branch || null,
|
|
157
|
+
pollerActive: undefined,
|
|
158
|
+
messages: [],
|
|
159
|
+
messagesUnavailable: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
mode: 'http',
|
|
165
|
+
start: async () => {},
|
|
166
|
+
close: async () => { await pool.end(); },
|
|
167
|
+
runAction,
|
|
168
|
+
getState,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { FakeSlackClient } from './fake-slack.mjs';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function findRepoRoot(start = process.cwd()) {
|
|
14
|
+
let dir = start;
|
|
15
|
+
while (dir !== dirname(dir)) {
|
|
16
|
+
if (existsSync(join(dir, 'apps/api/src/app.module.ts')) && existsSync(join(dir, 'package.json'))) {
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
dir = dirname(dir);
|
|
20
|
+
}
|
|
21
|
+
throw new Error('Could not find repo root containing apps/api/src/app.module.ts');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function createInProcessTransport(opts = {}) {
|
|
25
|
+
const repoRoot = findRepoRoot(opts.repoRoot);
|
|
26
|
+
try {
|
|
27
|
+
require('ts-node/register/transpile-only');
|
|
28
|
+
require('tsconfig-paths/register');
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error('In-process mode requires local repo dev dependencies: ts-node and tsconfig-paths');
|
|
31
|
+
}
|
|
32
|
+
const { runWithRequestSession } = require(join(repoRoot, 'apps/api/src/config/request-session.context.ts'));
|
|
33
|
+
const { NestFactory } = require('@nestjs/core');
|
|
34
|
+
const { AppModule } = require(join(repoRoot, 'apps/api/src/app.module.ts'));
|
|
35
|
+
const { SlackController } = require(join(repoRoot, 'apps/api/src/slack/slack.controller.ts'));
|
|
36
|
+
const { SlackWebhookController } = require(join(repoRoot, 'apps/api/src/slack/slack-webhook.controller.ts'));
|
|
37
|
+
const { SlackService } = require(join(repoRoot, 'apps/api/src/slack/slack.service.ts'));
|
|
38
|
+
const { DatabaseService } = require(join(repoRoot, 'apps/api/src/database/database.service.ts'));
|
|
39
|
+
|
|
40
|
+
const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
|
|
41
|
+
const slackController = app.get(SlackController);
|
|
42
|
+
const webhookController = app.get(SlackWebhookController);
|
|
43
|
+
const slackService = app.get(SlackService);
|
|
44
|
+
const db = app.get(DatabaseService);
|
|
45
|
+
const fakeSlack = new FakeSlackClient();
|
|
46
|
+
|
|
47
|
+
slackService.slack = fakeSlack;
|
|
48
|
+
slackService.botUserId = fakeSlack.botUserId;
|
|
49
|
+
slackService.botBotId = fakeSlack.botBotId;
|
|
50
|
+
slackController.botUserId = fakeSlack.botUserId;
|
|
51
|
+
|
|
52
|
+
async function withSession(session, fn) {
|
|
53
|
+
return await runWithRequestSession(session, fn);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function settle() {
|
|
57
|
+
await sleep(5);
|
|
58
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
59
|
+
await sleep(5);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFiles(files = []) {
|
|
63
|
+
return files.map((file, index) => ({
|
|
64
|
+
id: file.id || `F_SIM_${index + 1}`,
|
|
65
|
+
name: file.name,
|
|
66
|
+
title: file.title,
|
|
67
|
+
filetype: file.filetype,
|
|
68
|
+
mimetype: file.mimetype,
|
|
69
|
+
original_w: file.width || file.original_w,
|
|
70
|
+
original_h: file.height || file.original_h,
|
|
71
|
+
contentBase64: file.contentBase64 || file.data,
|
|
72
|
+
plain_text: file.plain_text,
|
|
73
|
+
preview: file.preview,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function runAction(action, scenario) {
|
|
78
|
+
const channel = action.channel || scenario.defaults.channel;
|
|
79
|
+
const user = action.user || scenario.defaults.user;
|
|
80
|
+
const ts = action.ts || fakeSlack.nextTs();
|
|
81
|
+
const threadTs = action.threadTs || action.thread_ts || scenario.defaults.threadTs;
|
|
82
|
+
const files = makeFiles(action.files || []);
|
|
83
|
+
const session = scenario.session;
|
|
84
|
+
|
|
85
|
+
switch (action.type) {
|
|
86
|
+
case 'app_mention_top_level': {
|
|
87
|
+
fakeSlack.addIncomingMessage({ channel, user, text: action.text, ts, files });
|
|
88
|
+
await withSession(session, () => slackController.handleEvent({
|
|
89
|
+
body: {
|
|
90
|
+
type: 'event_callback',
|
|
91
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
92
|
+
event: {
|
|
93
|
+
type: 'app_mention',
|
|
94
|
+
channel,
|
|
95
|
+
user,
|
|
96
|
+
text: action.text,
|
|
97
|
+
ts,
|
|
98
|
+
event_ts: ts,
|
|
99
|
+
files,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
await settle();
|
|
104
|
+
return { channel, threadTs: ts, ts };
|
|
105
|
+
}
|
|
106
|
+
case 'app_mention_thread': {
|
|
107
|
+
const rootTs = threadTs;
|
|
108
|
+
if (!fakeSlack.findMessage(channel, rootTs)) {
|
|
109
|
+
fakeSlack.addIncomingMessage({ channel, user: action.rootUser || user, text: action.rootText || 'thread root', ts: rootTs });
|
|
110
|
+
}
|
|
111
|
+
fakeSlack.addIncomingMessage({ channel, user, text: action.text, ts, thread_ts: rootTs, files });
|
|
112
|
+
await withSession(session, () => slackController.handleEvent({
|
|
113
|
+
body: {
|
|
114
|
+
type: 'event_callback',
|
|
115
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
116
|
+
event: {
|
|
117
|
+
type: 'app_mention',
|
|
118
|
+
channel,
|
|
119
|
+
user,
|
|
120
|
+
text: action.text,
|
|
121
|
+
ts,
|
|
122
|
+
thread_ts: rootTs,
|
|
123
|
+
event_ts: ts,
|
|
124
|
+
files,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}));
|
|
128
|
+
await settle();
|
|
129
|
+
return { channel, threadTs: rootTs, ts };
|
|
130
|
+
}
|
|
131
|
+
case 'message_thread_untagged': {
|
|
132
|
+
const rootTs = threadTs;
|
|
133
|
+
if (!fakeSlack.findMessage(channel, rootTs)) {
|
|
134
|
+
fakeSlack.addIncomingMessage({ channel, user: action.rootUser || user, text: action.rootText || 'thread root', ts: rootTs });
|
|
135
|
+
}
|
|
136
|
+
fakeSlack.addIncomingMessage({ channel, user, text: action.text, ts, thread_ts: rootTs, files });
|
|
137
|
+
await withSession(session, () => slackController.handleEvent({
|
|
138
|
+
body: {
|
|
139
|
+
type: 'event_callback',
|
|
140
|
+
event_id: action.eventId || `evt-${ts}`,
|
|
141
|
+
event: {
|
|
142
|
+
type: 'message',
|
|
143
|
+
channel,
|
|
144
|
+
user,
|
|
145
|
+
text: action.text,
|
|
146
|
+
ts,
|
|
147
|
+
thread_ts: rootTs,
|
|
148
|
+
event_ts: ts,
|
|
149
|
+
files,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
await settle();
|
|
154
|
+
return { channel, threadTs: rootTs, ts };
|
|
155
|
+
}
|
|
156
|
+
case 'dispatch_confirm': {
|
|
157
|
+
const rootTs = threadTs;
|
|
158
|
+
const last = fakeSlack.findLastBotMessageWithAction(channel, rootTs, 'dispatch_confirm');
|
|
159
|
+
if (!last) throw new Error('No dispatch_confirm button found in thread');
|
|
160
|
+
const payload = {
|
|
161
|
+
type: 'block_actions',
|
|
162
|
+
user: { id: user },
|
|
163
|
+
channel: { id: channel },
|
|
164
|
+
message: { ts: last.message.ts, thread_ts: rootTs },
|
|
165
|
+
container: { channel_id: channel, message_ts: last.message.ts },
|
|
166
|
+
actions: [{ action_id: 'dispatch_confirm', value: last.action.value }],
|
|
167
|
+
state: {
|
|
168
|
+
values: {
|
|
169
|
+
dispatch_resource: { dispatch_resource: { selected_option: { value: action.resource || 'ship-lfg' } } },
|
|
170
|
+
dispatch_repo: { dispatch_repo: { selected_option: action.repo ? { value: action.repo } : undefined } },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
await withSession(session, () => slackController.handleInteract({ body: { payload: JSON.stringify(payload) } }));
|
|
175
|
+
await settle();
|
|
176
|
+
return { channel, threadTs: rootTs, ts: last.message.ts };
|
|
177
|
+
}
|
|
178
|
+
case 'button_click': {
|
|
179
|
+
const rootTs = threadTs;
|
|
180
|
+
const target = fakeSlack.findLastBotMessageWithAction(channel, rootTs, action.actionId);
|
|
181
|
+
const value = action.value || target?.action?.value || '{}';
|
|
182
|
+
const msgTs = target?.message?.ts || action.messageTs || rootTs;
|
|
183
|
+
await withSession(session, () => slackController.handleInteract({
|
|
184
|
+
body: {
|
|
185
|
+
payload: JSON.stringify({
|
|
186
|
+
type: 'block_actions',
|
|
187
|
+
user: { id: user },
|
|
188
|
+
channel: { id: channel },
|
|
189
|
+
message: { ts: msgTs, thread_ts: rootTs },
|
|
190
|
+
container: { channel_id: channel, message_ts: msgTs },
|
|
191
|
+
actions: [{ action_id: action.actionId, value }],
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
}));
|
|
195
|
+
await settle();
|
|
196
|
+
return { channel, threadTs: rootTs, ts: msgTs };
|
|
197
|
+
}
|
|
198
|
+
case 'cursor_webhook': {
|
|
199
|
+
const state = await getState({ channel, threadTs: threadTs || scenario.defaults.threadTs });
|
|
200
|
+
await withSession(session, () => webhookController.handleCursorWebhook({
|
|
201
|
+
id: action.agentId || state.backendRef,
|
|
202
|
+
status: action.status,
|
|
203
|
+
summary: action.summary,
|
|
204
|
+
source: action.source,
|
|
205
|
+
target: action.target,
|
|
206
|
+
linesAdded: action.linesAdded,
|
|
207
|
+
linesRemoved: action.linesRemoved,
|
|
208
|
+
filesChanged: action.filesChanged,
|
|
209
|
+
}));
|
|
210
|
+
await settle();
|
|
211
|
+
return { channel, threadTs: threadTs || scenario.defaults.threadTs, ts };
|
|
212
|
+
}
|
|
213
|
+
case 'wait': {
|
|
214
|
+
await sleep(action.ms || 1000);
|
|
215
|
+
return { channel, threadTs, ts };
|
|
216
|
+
}
|
|
217
|
+
case 'assert':
|
|
218
|
+
return { channel, threadTs, ts };
|
|
219
|
+
default:
|
|
220
|
+
throw new Error(`Unsupported action type: ${action.type}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function getState({ channel, threadTs }) {
|
|
225
|
+
const thread = await db.query(async (client) => {
|
|
226
|
+
const { rows } = await client.query(
|
|
227
|
+
`SELECT st.*, cr.status, cr.backend_ref, cr.output
|
|
228
|
+
FROM slack_threads st
|
|
229
|
+
JOIN command_runs cr ON cr.id = st.command_run_id
|
|
230
|
+
WHERE st.channel_id = $1 AND st.thread_ts = $2
|
|
231
|
+
ORDER BY st.created_at DESC
|
|
232
|
+
LIMIT 1`,
|
|
233
|
+
[channel, threadTs],
|
|
234
|
+
);
|
|
235
|
+
return rows[0] || null;
|
|
236
|
+
}).catch(() => null);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
channel,
|
|
240
|
+
threadTs,
|
|
241
|
+
commandRunId: thread?.command_run_id || null,
|
|
242
|
+
backendRef: thread?.backend_ref || null,
|
|
243
|
+
status: thread?.status || null,
|
|
244
|
+
checkpointActive: Boolean(thread?.output?.checkpoint?.active),
|
|
245
|
+
prRole: thread?.output?.slackPrPresentation?.prRole || null,
|
|
246
|
+
prUrl: thread?.output?.slackPrPresentation?.primaryPrUrl || null,
|
|
247
|
+
branch: thread?.output?.slackPrPresentation?.branch || null,
|
|
248
|
+
pollerActive: thread?.backend_ref ? slackService.pollers?.has?.(thread.backend_ref) : false,
|
|
249
|
+
messages: fakeSlack.latestThreadMessages(channel, threadTs),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
mode: 'in-process',
|
|
255
|
+
start: async () => {},
|
|
256
|
+
close: async () => {
|
|
257
|
+
await slackService.onModuleDestroy?.();
|
|
258
|
+
await app.close();
|
|
259
|
+
},
|
|
260
|
+
runAction,
|
|
261
|
+
getState,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fmt from '../fmt.mjs';
|
|
2
|
+
|
|
3
|
+
export function printScenarioIntro(scenario, mode) {
|
|
4
|
+
fmt.banner('slack sim', {
|
|
5
|
+
icon: '⟁',
|
|
6
|
+
subtitle: `${fmt.chalk.dim('scenario:')} ${scenario.id} ${fmt.chalk.dim('mode:')} ${mode}`,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function printDryRun(scenario) {
|
|
11
|
+
fmt.note(
|
|
12
|
+
scenario.actions.map((action, index) => `${index + 1}. ${action.type}`).join('\n'),
|
|
13
|
+
'Actions',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function printState(index, action, state) {
|
|
18
|
+
fmt.logStep(`Action ${index + 1}: ${action.type}`);
|
|
19
|
+
const lines = [
|
|
20
|
+
`channel: ${state.channel || '-'}`,
|
|
21
|
+
`thread: ${state.threadTs || '-'}`,
|
|
22
|
+
`run: ${state.commandRunId || '-'}`,
|
|
23
|
+
`backend: ${state.backendRef || '-'}`,
|
|
24
|
+
`status: ${state.status || '-'}`,
|
|
25
|
+
`checkpoint: ${state.checkpointActive ? 'active' : 'inactive'}`,
|
|
26
|
+
`poller: ${state.pollerActive === undefined ? 'unknown' : state.pollerActive ? 'active' : 'inactive'}`,
|
|
27
|
+
`pr role: ${state.prRole || '-'}`,
|
|
28
|
+
`pr url: ${state.prUrl || '-'}`,
|
|
29
|
+
`branch: ${state.branch || '-'}`,
|
|
30
|
+
];
|
|
31
|
+
fmt.note(lines.join('\n'), 'State');
|
|
32
|
+
|
|
33
|
+
if (state.messages?.length) {
|
|
34
|
+
const rendered = state.messages
|
|
35
|
+
.slice(-6)
|
|
36
|
+
.map((m) => `[${m.ts}] ${m.user}: ${String(m.text).slice(0, 180)}`)
|
|
37
|
+
.join('\n');
|
|
38
|
+
fmt.note(rendered, 'Thread');
|
|
39
|
+
} else if (state.messagesUnavailable) {
|
|
40
|
+
fmt.note('Thread transcript unavailable in HTTP mode; use --in-process for captured Slack-like output.', 'Thread');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const BUILTIN_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'scenarios');
|
|
6
|
+
const DEFAULT_ORG_ID = process.env.DEV_ORG_ID || 'e755eaad-1b31-4348-b545-981ef25dab04';
|
|
7
|
+
const DEFAULT_TEAM_ID = process.env.DEV_TEAM_ID || 'c6d24de4-75e0-4e60-b8f2-de4a1cd0357d';
|
|
8
|
+
|
|
9
|
+
export function listBuiltInScenarios() {
|
|
10
|
+
return readdirSync(BUILTIN_DIR)
|
|
11
|
+
.filter((name) => name.endsWith('.json'))
|
|
12
|
+
.map((name) => ({
|
|
13
|
+
name: name.replace(/\.json$/, ''),
|
|
14
|
+
path: join(BUILTIN_DIR, name),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveScenarioPath(specifier) {
|
|
19
|
+
if (!specifier) throw new Error('Scenario path or built-in scenario name is required');
|
|
20
|
+
const direct = resolve(process.cwd(), specifier);
|
|
21
|
+
if (existsSync(direct)) return direct;
|
|
22
|
+
|
|
23
|
+
const withJson = extname(specifier) ? specifier : `${specifier}.json`;
|
|
24
|
+
const builtin = join(BUILTIN_DIR, withJson);
|
|
25
|
+
if (existsSync(builtin)) return builtin;
|
|
26
|
+
|
|
27
|
+
throw new Error(`Scenario not found: ${specifier}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadScenario(specifier) {
|
|
31
|
+
const file = resolveScenarioPath(specifier);
|
|
32
|
+
const raw = readFileSync(file, 'utf8');
|
|
33
|
+
const scenario = JSON.parse(raw);
|
|
34
|
+
validateScenario(scenario, file);
|
|
35
|
+
return {
|
|
36
|
+
...scenario,
|
|
37
|
+
id: scenario.id || basename(file, '.json'),
|
|
38
|
+
session: {
|
|
39
|
+
orgId: scenario.session?.orgId || DEFAULT_ORG_ID,
|
|
40
|
+
teamId: scenario.session?.teamId || DEFAULT_TEAM_ID,
|
|
41
|
+
},
|
|
42
|
+
defaults: {
|
|
43
|
+
channel: scenario.defaults?.channel || 'C_SIM',
|
|
44
|
+
user: scenario.defaults?.user || 'U_SIM',
|
|
45
|
+
threadTs: scenario.defaults?.threadTs || '1000.001',
|
|
46
|
+
rootTs: scenario.defaults?.rootTs || '1000.001',
|
|
47
|
+
},
|
|
48
|
+
__file: file,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateScenario(scenario, file) {
|
|
53
|
+
if (!scenario || typeof scenario !== 'object') {
|
|
54
|
+
throw new Error(`Invalid scenario in ${file}: expected object`);
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(scenario.actions) || scenario.actions.length === 0) {
|
|
57
|
+
throw new Error(`Invalid scenario in ${file}: actions[] is required`);
|
|
58
|
+
}
|
|
59
|
+
for (const [index, action] of scenario.actions.entries()) {
|
|
60
|
+
if (!action || typeof action !== 'object' || typeof action.type !== 'string') {
|
|
61
|
+
throw new Error(`Invalid scenario in ${file}: action[${index}] must have a type`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "checkpoint-approve",
|
|
3
|
+
"description": "Threaded checkpoint flow with approve/continue.",
|
|
4
|
+
"actions": [
|
|
5
|
+
{
|
|
6
|
+
"type": "app_mention_thread",
|
|
7
|
+
"text": "<@U_GHL_AW> /ship-lfg propose a plan first",
|
|
8
|
+
"threadTs": "2000.001",
|
|
9
|
+
"rootText": "Need a plan for this task"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "wait",
|
|
13
|
+
"ms": 1000
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "button_click",
|
|
17
|
+
"actionId": "approve_plan",
|
|
18
|
+
"threadTs": "2000.001"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "image-thread",
|
|
3
|
+
"description": "Threaded image attachment flow using inline base64 test data.",
|
|
4
|
+
"actions": [
|
|
5
|
+
{
|
|
6
|
+
"type": "app_mention_thread",
|
|
7
|
+
"text": "<@U_GHL_AW> /ship-lfg audit this screenshot",
|
|
8
|
+
"threadTs": "3000.001",
|
|
9
|
+
"rootText": "Original bug thread",
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"id": "F_IMG_1",
|
|
13
|
+
"name": "bug.png",
|
|
14
|
+
"filetype": "png",
|
|
15
|
+
"mimetype": "image/png",
|
|
16
|
+
"width": 1,
|
|
17
|
+
"height": 1,
|
|
18
|
+
"contentBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WnSle8AAAAASUVORK5CYII="
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "wait",
|
|
24
|
+
"ms": 1000
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "implementation-basic",
|
|
3
|
+
"description": "Top-level implementation dispatch that should end in a canonical task PR summary.",
|
|
4
|
+
"actions": [
|
|
5
|
+
{
|
|
6
|
+
"type": "app_mention_top_level",
|
|
7
|
+
"text": "<@U_GHL_AW> /ship-lfg fix the failing flow"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "wait",
|
|
11
|
+
"ms": 1000
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "assert",
|
|
15
|
+
"status": "running"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "poll-webhook-race",
|
|
3
|
+
"description": "Manual webhook injection template for testing completion races.",
|
|
4
|
+
"actions": [
|
|
5
|
+
{
|
|
6
|
+
"type": "app_mention_top_level",
|
|
7
|
+
"text": "<@U_GHL_AW> /ship-lfg run the task"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "wait",
|
|
11
|
+
"ms": 1000
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "cursor_webhook",
|
|
15
|
+
"status": "FINISHED"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "review-pr",
|
|
3
|
+
"description": "Review an existing PR and verify the run stays tied to the reviewed PR.",
|
|
4
|
+
"actions": [
|
|
5
|
+
{
|
|
6
|
+
"type": "app_mention_top_level",
|
|
7
|
+
"text": "<@U_GHL_AW> /review https://github.com/GoHighLevel/example/pull/123"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "wait",
|
|
11
|
+
"ms": 1000
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
package/telemetry.mjs
CHANGED
|
@@ -191,10 +191,12 @@ export async function startSpan(command, args) {
|
|
|
191
191
|
async end({ status = 'completed', error_type = null, data = {} } = {}) {
|
|
192
192
|
if (disabled) return;
|
|
193
193
|
const duration_ms = Date.now() - startTime;
|
|
194
|
-
// Re-read registry_head so completed event reflects post-command state
|
|
195
|
-
const endEnv = { ...env, registry_head: getRegistryHead() };
|
|
194
|
+
// Re-read registry_head and namespace so completed event reflects post-command state
|
|
195
|
+
const endEnv = { ...env, registry_head: getRegistryHead(), namespace: getNamespace() || env.namespace };
|
|
196
196
|
await send({
|
|
197
|
-
event: status === 'completed' ? 'command_completed'
|
|
197
|
+
event: status === 'completed' ? 'command_completed'
|
|
198
|
+
: status === 'cancelled' ? 'command_cancelled'
|
|
199
|
+
: 'command_failed',
|
|
198
200
|
run_id: runId,
|
|
199
201
|
timestamp: new Date().toISOString(),
|
|
200
202
|
env: endEnv,
|