@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.
@@ -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' : 'command_failed',
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,