@aivi/tracker-linear 0.0.0-stage → 0.4.1
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/README.md +41 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +277 -0
- package/dist/client.js +362 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +171 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +70 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/links.d.ts +48 -0
- package/dist/links.js +83 -0
- package/dist/mcp.d.ts +32 -0
- package/dist/mcp.js +126 -0
- package/dist/module.d.ts +38 -0
- package/dist/module.js +767 -0
- package/dist/projects.d.ts +40 -0
- package/dist/projects.js +119 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +40 -0
- package/dist/runprogress.d.ts +60 -0
- package/dist/runprogress.js +120 -0
- package/dist/setup-project.d.ts +63 -0
- package/dist/setup-project.js +254 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +526 -0
- package/dist/tracker.d.ts +178 -0
- package/dist/tracker.js +435 -0
- package/dist/webhook.d.ts +97 -0
- package/dist/webhook.js +40 -0
- package/dist/work.d.ts +66 -0
- package/dist/work.js +98 -0
- package/docs/linear.md +90 -0
- package/package.json +56 -4
package/dist/setup.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup `aivi add linear` runs: everything Linear-specific lives in
|
|
3
|
+
* this file. It starts only when a live aivi answers its health endpoint —
|
|
4
|
+
* the test observes what that host's request diary records, so no host, no
|
|
5
|
+
* install. It asks for the app's credentials and proves them at Linear,
|
|
6
|
+
* installs the app through a browser round, then — before writing anything —
|
|
7
|
+
* tests the two systems the module lives on in two waits that run one
|
|
8
|
+
* after the other: that Linear posts deliveries to this URL, and that
|
|
9
|
+
* delegating a throwaway ticket creates an agent session whose created
|
|
10
|
+
* event arrives the same way. The flow draws its own lines with the clack
|
|
11
|
+
* the runner's prompts render with — one live line per wait, settled by
|
|
12
|
+
* clack's own verdict marks: a hollow green diamond for passed, a red
|
|
13
|
+
* square for failed. It ends with its own last line. The ticket is created
|
|
14
|
+
* and archived by the installer; a passed test is nobody's waiting room.
|
|
15
|
+
* Nobody opens an agent session by hand; we only watch what Linear does.
|
|
16
|
+
* Only after the checks have a verdict is anything written. Performing a
|
|
17
|
+
* restart never belongs to this flow — the CLI owns that, and reports it
|
|
18
|
+
* as it happens.
|
|
19
|
+
*/
|
|
20
|
+
import { createServer } from 'node:http';
|
|
21
|
+
import { configSchema, errorMessage, hostUrl, PROJECT_ID } from '@aivi/core';
|
|
22
|
+
import { settled } from '@aivi/plugin';
|
|
23
|
+
import { LinearClient } from './client.js';
|
|
24
|
+
import { linearPrimarySecretNames, MODULE_ID } from './config.js';
|
|
25
|
+
import { appWebhookPath } from './routes.js';
|
|
26
|
+
import { isAgentSessionEvent, isIssueEvent, verifyWebhook } from './webhook.js';
|
|
27
|
+
/** How long the diary is watched when a check cannot pass: silence is a
|
|
28
|
+
* result too, and only a waited-out silence proves it. A test where both
|
|
29
|
+
* systems show themselves ends at once and waits for nothing. Tests
|
|
30
|
+
* shorten the window; people get the honest cap. */
|
|
31
|
+
const DEFAULT_WINDOW_MS = 90_000;
|
|
32
|
+
const probeWindowMs = () => Number(process.env.AIVI_PROBE_WINDOW_MS ?? DEFAULT_WINDOW_MS);
|
|
33
|
+
/** What the install asks Linear to let the app do; `app:assignable` is
|
|
34
|
+
* what lets it be a delegate. See `authorizeUrl` for the link itself. */
|
|
35
|
+
const INSTALL_SCOPES = 'read,write,app:assignable,app:mentionable';
|
|
36
|
+
/**
|
|
37
|
+
* The one-shot loopback listener: bind, print, wait, close. Anything that
|
|
38
|
+
* is not our callback — the browser's favicon.ico probe among them — is a
|
|
39
|
+
* 404 that never touches the code. It binds an ephemeral port (0) before
|
|
40
|
+
* the instructions print, so the human registers exactly this callback on
|
|
41
|
+
* the app and the install link is accepted first try.
|
|
42
|
+
*/
|
|
43
|
+
const catchInstall = () => {
|
|
44
|
+
let ok = () => { };
|
|
45
|
+
let fail = () => { };
|
|
46
|
+
let bindFailed = () => { };
|
|
47
|
+
const code = new Promise((resolvePromise, rejectPromise) => {
|
|
48
|
+
ok = resolvePromise;
|
|
49
|
+
fail = rejectPromise;
|
|
50
|
+
});
|
|
51
|
+
// A rejection the installer answers with its own message must not crash the
|
|
52
|
+
// process on its way past.
|
|
53
|
+
code.catch(() => { });
|
|
54
|
+
const server = createServer((req, res) => {
|
|
55
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
56
|
+
const got = url.pathname === '/callback' ? url.searchParams.get('code') : null;
|
|
57
|
+
if (!got) {
|
|
58
|
+
res.writeHead(404).end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res
|
|
62
|
+
.writeHead(200, { 'content-type': 'text/html' })
|
|
63
|
+
.end('<!doctype html><title>aivi</title><h1>Linear is installed</h1><p>You can close this window and go back to the CLI.</p>');
|
|
64
|
+
ok(got);
|
|
65
|
+
});
|
|
66
|
+
const ready = new Promise((resolvePromise, rejectPromise) => {
|
|
67
|
+
bindFailed = rejectPromise;
|
|
68
|
+
server.listen(0, '127.0.0.1', () => {
|
|
69
|
+
const address = server.address();
|
|
70
|
+
if (address && typeof address !== 'string')
|
|
71
|
+
resolvePromise(`http://127.0.0.1:${address.port}/callback`);
|
|
72
|
+
else
|
|
73
|
+
rejectPromise(new Error('the install listener could not report its port'));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
ready.catch(() => { });
|
|
77
|
+
server.on('error', error => {
|
|
78
|
+
const failure = new Error(`the install listener could not listen: ${errorMessage(error)}`);
|
|
79
|
+
fail(failure);
|
|
80
|
+
bindFailed(failure);
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
code,
|
|
84
|
+
ready,
|
|
85
|
+
close: () => {
|
|
86
|
+
server.close();
|
|
87
|
+
server.closeAllConnections();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Linear compares `redirect_uri` against the registered string as it is
|
|
93
|
+
* written — percent-encoding it is refused — so the URL goes out exactly
|
|
94
|
+
* as the human registered it. The scopes are what delegation and replies
|
|
95
|
+
* need: `app:assignable` is what lets the app be a delegate.
|
|
96
|
+
*/
|
|
97
|
+
const authorizeUrl = (clientId, redirect) => 'https://linear.app/oauth/authorize' +
|
|
98
|
+
`?client_id=${clientId}&redirect_uri=${redirect}&response_type=code&actor=app&scope=${INSTALL_SCOPES}`;
|
|
99
|
+
/**
|
|
100
|
+
* The code is traded for an access token the module never uses: the
|
|
101
|
+
* exchange is what makes the install real, and the app's own
|
|
102
|
+
* client-credentials token is all the module needs from then on.
|
|
103
|
+
*/
|
|
104
|
+
const completeInstall = async (ctx, clientId, clientSecret, code, redirect) => {
|
|
105
|
+
const response = await ctx.fetch('https://api.linear.app/oauth/token', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
108
|
+
body: new URLSearchParams({
|
|
109
|
+
code,
|
|
110
|
+
redirect_uri: redirect,
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
client_secret: clientSecret,
|
|
113
|
+
grant_type: 'authorization_code',
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
throw new Error(`Linear refused the install code (HTTP ${response.status}): ${await response.text()}`);
|
|
118
|
+
};
|
|
119
|
+
/** The installer is a second process and file events do not report SQLite's WAL
|
|
120
|
+
* writes (macOS never mentions them, measured 2026-09-26), so the diary is
|
|
121
|
+
* re-read on this quiet interval — one query on an indexed column. */
|
|
122
|
+
const POLL_MS = 2_000;
|
|
123
|
+
const setup = async (ctx) => {
|
|
124
|
+
const windowMs = probeWindowMs();
|
|
125
|
+
const config = configSchema.parse(ctx.config);
|
|
126
|
+
const existingBlock = ctx.config.plugins?.[MODULE_ID];
|
|
127
|
+
const configured = Object.keys(existingBlock?.apps ?? {});
|
|
128
|
+
if (configured.length > 1)
|
|
129
|
+
throw new Error(`this aivi already has ${configured.length} Linear apps; the installer configures the one app — edit config.json and .env by hand (docs/linear.md).`);
|
|
130
|
+
// The test observes what the running host records in its request diary:
|
|
131
|
+
// with no live aivi there is nothing to observe, and finding that out at
|
|
132
|
+
// the end wastes a whole install round. The host is alive when it answers
|
|
133
|
+
// `GET /health` on its endpoint, whatever its version — the same test the
|
|
134
|
+
// CLI uses. The installer does not own restarts, so a no is final.
|
|
135
|
+
const hostIsUp = await ctx
|
|
136
|
+
.fetch(`${hostUrl(config.host)}/health`, { signal: AbortSignal.timeout(2_000) })
|
|
137
|
+
.then(response => response.ok)
|
|
138
|
+
.catch(() => false);
|
|
139
|
+
if (!hostIsUp)
|
|
140
|
+
throw new Error('aivi must be running while the installer tests the webhook: start it (`aivi serve`, or `aivi service restart`) and run `aivi add linear` again.');
|
|
141
|
+
// 1. The name aivi calls this app, and the URL Linear can reach it at.
|
|
142
|
+
const id = configured[0] ??
|
|
143
|
+
settled(ctx, await ctx.prompts.text({
|
|
144
|
+
message: 'What should aivi call this Linear app?',
|
|
145
|
+
placeholder: 'e.g. linear-assistant',
|
|
146
|
+
validate: value => value !== undefined && PROJECT_ID.test(value)
|
|
147
|
+
? undefined
|
|
148
|
+
: 'lowercase letters, digits, dashes and underscores',
|
|
149
|
+
}));
|
|
150
|
+
const webhookPath = appWebhookPath(id);
|
|
151
|
+
let base = config.host.public;
|
|
152
|
+
if (!base) {
|
|
153
|
+
ctx.prompts.note([
|
|
154
|
+
'Linear posts webhooks to this aivi, so it needs a URL Linear can reach.',
|
|
155
|
+
'A tunnel is the usual answer — a Tailscale Funnel or similar pointed at aivi.',
|
|
156
|
+
'aivi cannot discover this URL itself, so the setup asks once and saves it;',
|
|
157
|
+
'later runs will not ask.',
|
|
158
|
+
].join('\n'), 'Linear reaches aivi');
|
|
159
|
+
base = settled(ctx, await ctx.prompts.text({
|
|
160
|
+
message: 'The public URL Linear can reach this aivi at',
|
|
161
|
+
placeholder: 'e.g. https://my-machine.ts.net',
|
|
162
|
+
validate: value => value !== undefined && /^https?:\/\//.test(value)
|
|
163
|
+
? value.endsWith('/')
|
|
164
|
+
? 'no trailing /'
|
|
165
|
+
: undefined
|
|
166
|
+
: 'an http(s) URL, like https://my-machine.ts.net',
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
const webhookUrl = `${base}${webhookPath}`;
|
|
170
|
+
// 2. The credentials, proven at Linear before anything is written. The
|
|
171
|
+
// install listener binds first because the app must have its exact
|
|
172
|
+
// Redirect URI registered before the install link is accepted — so
|
|
173
|
+
// the instructions print the URL the installer is really listening on.
|
|
174
|
+
// Every exit from this section closes the listener: a bound server
|
|
175
|
+
// keeps the process alive after the last line is said, and a
|
|
176
|
+
// mistyped secret used to hang the installer on its 401 rather than
|
|
177
|
+
// let it exit (seen live 2026-09-27).
|
|
178
|
+
const install = catchInstall();
|
|
179
|
+
let clientId;
|
|
180
|
+
let clientSecret;
|
|
181
|
+
let client;
|
|
182
|
+
let viewer;
|
|
183
|
+
let teams;
|
|
184
|
+
try {
|
|
185
|
+
const redirect = await install.ready;
|
|
186
|
+
ctx.prompts.note([
|
|
187
|
+
'1. Open https://linear.app/settings/api/applications/new',
|
|
188
|
+
'2. App name is the bot’s name; Developer name is yours.',
|
|
189
|
+
`3. Redirect URIs: ${redirect}`,
|
|
190
|
+
'4. Tick Client credentials and Webhooks.',
|
|
191
|
+
`5. Webhook URL: ${webhookUrl}`,
|
|
192
|
+
' Tick the checkboxes for Issues under Data change events',
|
|
193
|
+
' and Agent session events under App events.',
|
|
194
|
+
'6. Press Create, then copy the Client ID and Client Secret below.',
|
|
195
|
+
].join('\n'), 'Creating the Linear app');
|
|
196
|
+
clientId = settled(ctx, await ctx.prompts.text({
|
|
197
|
+
message: 'The app’s Client ID',
|
|
198
|
+
placeholder: 'cli_…',
|
|
199
|
+
validate: value => (value ? undefined : 'Paste the Client ID from the app page'),
|
|
200
|
+
}));
|
|
201
|
+
clientSecret = settled(ctx, await ctx.prompts.password({
|
|
202
|
+
message: 'The app’s Client Secret',
|
|
203
|
+
validate: value => (value ? undefined : 'Paste the Client Secret from the app page'),
|
|
204
|
+
}));
|
|
205
|
+
client = new LinearClient({ clientId, clientSecret }, { fetch: ctx.fetch });
|
|
206
|
+
viewer = await client.viewerId();
|
|
207
|
+
await ctx.prompts.log.message('Linear took the credentials — the app answers as its own bot user.');
|
|
208
|
+
teams = await client.listTeams();
|
|
209
|
+
await ctx.prompts.log.message(`The app can see ${teams.length ? teams.map(t => `${t.key} (${t.name})`).join(', ') : 'no teams'}.`);
|
|
210
|
+
// The install URL goes out on a bare line: the operator copies it, and
|
|
211
|
+
// note boxes and log guides put characters in the way of copying.
|
|
212
|
+
ctx.print(`Install the app — open this once and allow it:\n\n${authorizeUrl(clientId, redirect)}`);
|
|
213
|
+
const installCode = await install.code;
|
|
214
|
+
await completeInstall(ctx, clientId, clientSecret, installCode, redirect);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
install.close();
|
|
218
|
+
}
|
|
219
|
+
await ctx.prompts.log.message('Installed.');
|
|
220
|
+
const webhookSecret = settled(ctx, await ctx.prompts.password({
|
|
221
|
+
message: 'The webhook’s Signing Secret',
|
|
222
|
+
validate: value => (value ? undefined : 'Paste the Signing Secret from the webhook section'),
|
|
223
|
+
}));
|
|
224
|
+
// 3. The test, before any write: one throwaway ticket, proven in two
|
|
225
|
+
// waits that run one after the other, named for the systems the module
|
|
226
|
+
// lives on — first that Linear posts deliveries to this URL, then that
|
|
227
|
+
// delegating the ticket creates an agent session whose created event
|
|
228
|
+
// arrives the same way. Clack animates one live line and prints
|
|
229
|
+
// nothing beside it, so each wait takes the screen in turn: its line
|
|
230
|
+
// says what just landed, and its verdict closes the line with clack's
|
|
231
|
+
// own marks — a hollow green diamond for passed, a red square for
|
|
232
|
+
// failed — and a failure names the likeliest cause. With the module not
|
|
233
|
+
// yet loaded, every delivery is recorded untouched: pure observation
|
|
234
|
+
// of Linear's behavior. A wait that passes is not made to sit out a
|
|
235
|
+
// window; the window is what a failure needs, because silence is a
|
|
236
|
+
// result too and only a waited-out silence proves it.
|
|
237
|
+
const checks = [];
|
|
238
|
+
if (!teams.length) {
|
|
239
|
+
await ctx.prompts.log.message('The app sees no teams, so there is nowhere to put the throwaway ticket — the checks are skipped.');
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// What the test does is said before the operator is asked to point it
|
|
243
|
+
// at a team; starting it takes no further confirmation.
|
|
244
|
+
ctx.prompts.note([
|
|
245
|
+
'The installer will now test if the webhook works. It does this by',
|
|
246
|
+
'creating a temporary ticket, delegating it to the app, and watching',
|
|
247
|
+
'the webhooks Linear posts back. It archives the ticket when the',
|
|
248
|
+
'test ends. When Linear can reach this aivi, the test takes seconds.',
|
|
249
|
+
].join('\n'), 'Testing the configuration');
|
|
250
|
+
const teamId = settled(ctx, await ctx.prompts.select({
|
|
251
|
+
message: 'Which team gets the throwaway ticket?',
|
|
252
|
+
options: teams.map(t => ({ value: t.id, label: `${t.key} — ${t.name}` })),
|
|
253
|
+
}));
|
|
254
|
+
// The store door the runner supplies: the home database is open for this
|
|
255
|
+
// block and closed after it, whatever the two waits do.
|
|
256
|
+
await ctx.withStore(async (store) => {
|
|
257
|
+
// Whatever the diary holds before the test ticket exists is nobody's
|
|
258
|
+
// business; the cursor starts at its newest row.
|
|
259
|
+
const cursor = latestId(store);
|
|
260
|
+
const issue = await client.createIssue({
|
|
261
|
+
teamId,
|
|
262
|
+
title: 'aivi add test — safe to delete',
|
|
263
|
+
description: `Created by \`aivi add linear\` to test the webhook at ${webhookUrl}. The installer archives it when the test ends.`,
|
|
264
|
+
});
|
|
265
|
+
await ctx.prompts.log.message(`Created ${issue.identifier}.`);
|
|
266
|
+
const probe = { ctx, client, viewer, windowMs, webhookPath, webhookSecret, checks };
|
|
267
|
+
const posted = await checkWebhookDeliveries(probe, store, issue, cursor);
|
|
268
|
+
await checkAgentSessionEvents(probe, store, issue, posted);
|
|
269
|
+
// The installer made the ticket, so the installer tidies it — Linear's
|
|
270
|
+
// delete archives it, off the board. A delete that fails is said, not
|
|
271
|
+
// swallowed, so a ticket is never left without the operator knowing.
|
|
272
|
+
try {
|
|
273
|
+
await client.deleteIssue(issue.id);
|
|
274
|
+
await ctx.prompts.log.message(`${issue.identifier} archived.`);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
await ctx.prompts.log.message(`Could not archive ${issue.identifier} — delete it by hand (${errorMessage(error)}).`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// 4. Only now is anything written: the secrets under the bare primary
|
|
282
|
+
// names, `host.public` if the installer asked for it, and the
|
|
283
|
+
// `tracker-linear` block with this app added.
|
|
284
|
+
if (!config.host.public)
|
|
285
|
+
await ctx.writeConfigBlock(['host', 'public'], base);
|
|
286
|
+
await ctx.writeSecret(linearPrimarySecretNames.clientId, clientId);
|
|
287
|
+
await ctx.writeSecret(linearPrimarySecretNames.clientSecret, clientSecret);
|
|
288
|
+
await ctx.writeSecret(linearPrimarySecretNames.webhookSecret, webhookSecret);
|
|
289
|
+
await ctx.writeConfigBlock(['plugins', MODULE_ID], {
|
|
290
|
+
...existingBlock,
|
|
291
|
+
apps: { ...(existingBlock?.apps ?? {}), [id]: {} },
|
|
292
|
+
});
|
|
293
|
+
const failed = checks.filter(c => !c.pass);
|
|
294
|
+
const outcome = !checks.length
|
|
295
|
+
? 'nothing was tested — the app sees no teams, so there was nowhere to put the throwaway ticket'
|
|
296
|
+
: failed.length
|
|
297
|
+
? `FAILED — ${failed.map(c => `${c.name}: ${c.detail}`).join('; ')}. Fix that and run the install again`
|
|
298
|
+
: 'both checks passed';
|
|
299
|
+
// The installer started only because a live aivi answered its health
|
|
300
|
+
// endpoint, so what loads the module is its own line to say; when a
|
|
301
|
+
// service is installed the CLI restarts it anyway and reports that as
|
|
302
|
+
// it happens. Performing a restart never belongs to this flow.
|
|
303
|
+
return {
|
|
304
|
+
module: MODULE_ID,
|
|
305
|
+
summary: failed.length
|
|
306
|
+
? `Linear is configured: ${outcome}.`
|
|
307
|
+
: `Linear is configured: ${outcome}. Restart aivi to load Linear.`,
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
/** The first wait, on the live line: Linear must post the ticket's creation
|
|
311
|
+
* to the URL before anything else has proof to stand on. `stop` closes a
|
|
312
|
+
* line with clack's settled mark, a hollow green diamond; `cancel` with a
|
|
313
|
+
* red square. A verdict is said on the live line and recorded for the
|
|
314
|
+
* summary, together, in every branch. */
|
|
315
|
+
async function checkWebhookDeliveries(probe, store, issue, cursor) {
|
|
316
|
+
const { ctx, windowMs, webhookPath, webhookSecret, checks } = probe;
|
|
317
|
+
const webhooks = ctx.prompts.spinner();
|
|
318
|
+
webhooks.start(`webhooks: waiting for Linear to post about ${issue.identifier}…`);
|
|
319
|
+
let posted;
|
|
320
|
+
try {
|
|
321
|
+
posted = await waitDiary(store, webhookPath, webhookSecret, issue.id, cursor, windowMs, isIssueEvent, line => webhooks.message(line));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
webhooks.cancel('webhooks — a delivery arrived that did not verify');
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
if (posted.mine) {
|
|
328
|
+
const detail = `${posted.mine} issue ${posted.mine === 1 ? 'delivery' : 'deliveries'} arrived and verified`;
|
|
329
|
+
webhooks.stop(`webhooks — ${detail}`);
|
|
330
|
+
checks.push({ name: 'webhooks', pass: true, detail });
|
|
331
|
+
}
|
|
332
|
+
else if (posted.others) {
|
|
333
|
+
const detail = `deliveries arrived, but none about ${issue.identifier} — the categories ticked on the webhook decide what Linear posts`;
|
|
334
|
+
webhooks.cancel(`webhooks — ${detail}`);
|
|
335
|
+
checks.push({ name: 'webhooks', pass: false, detail });
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const detail = `nothing arrived in ${windowMs / 1000} s — the Webhook URL on the app is not this one, the categories are off, or Linear cannot reach this machine`;
|
|
339
|
+
webhooks.cancel(`webhooks — ${detail}`);
|
|
340
|
+
checks.push({ name: 'webhooks', pass: false, detail });
|
|
341
|
+
}
|
|
342
|
+
return posted;
|
|
343
|
+
}
|
|
344
|
+
/** The second wait needs a delegation of its own: becoming the delegate is
|
|
345
|
+
* what makes Linear create the session, and the session's own delivery
|
|
346
|
+
* proves the app is subscribed to those too. It is skipped only when the
|
|
347
|
+
* webhooks wait proved nothing arrives at all — the same silence waited
|
|
348
|
+
* out twice proves nothing new. */
|
|
349
|
+
async function checkAgentSessionEvents(probe, store, issue, posted) {
|
|
350
|
+
const { ctx, client, viewer, windowMs, webhookPath, webhookSecret, checks } = probe;
|
|
351
|
+
if (!posted.mine && !posted.others) {
|
|
352
|
+
await ctx.prompts.log.message('agent events: skipped — it waits on the same webhooks; with nothing arriving it could prove nothing.');
|
|
353
|
+
checks.push({
|
|
354
|
+
name: 'agent events',
|
|
355
|
+
pass: false,
|
|
356
|
+
detail: 'not tested — no webhook arrived for the webhooks check either',
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const events = ctx.prompts.spinner();
|
|
361
|
+
events.start(`agent events: delegating ${issue.identifier} to the app…`);
|
|
362
|
+
let delegated;
|
|
363
|
+
// Linear's own answer to the delegation names the session it
|
|
364
|
+
// created; its arrival is settled the instant the mutation lands.
|
|
365
|
+
let made = false;
|
|
366
|
+
try {
|
|
367
|
+
const answer = await client.setDelegate(issue.id, viewer);
|
|
368
|
+
made = answer.issue?.agentSessions.nodes.some(s => s.status === 'pending') ?? false;
|
|
369
|
+
events.message('Delegated — waiting for the session event…');
|
|
370
|
+
delegated = await waitDiary(store, webhookPath, webhookSecret, issue.id, posted.last, windowMs, isAgentSessionEvent, line => events.message(line));
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
events.cancel('agent events — stopped before the session event could be seen');
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
if (delegated.mine) {
|
|
377
|
+
const detail = made
|
|
378
|
+
? 'delegation created the session and its created event arrived'
|
|
379
|
+
: 'a created session event arrived though the delegate answer named no session';
|
|
380
|
+
events.stop(`agent events — ${detail}`);
|
|
381
|
+
checks.push({ name: 'agent events', pass: true, detail });
|
|
382
|
+
}
|
|
383
|
+
else if (made) {
|
|
384
|
+
const detail = `the session was created but its event did not arrive in ${windowMs / 1000} s — tick Agent session events under App events on the app`;
|
|
385
|
+
events.cancel(`agent events — ${detail}`);
|
|
386
|
+
checks.push({ name: 'agent events', pass: false, detail });
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
const detail = 'no session at all — the app took the delegation but Linear made nothing to work from';
|
|
390
|
+
events.cancel(`agent events — ${detail}`);
|
|
391
|
+
checks.push({ name: 'agent events', pass: false, detail });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** What one delivery is about, and whether it names our test ticket. */
|
|
395
|
+
const namesIssue = (payload, issueId) => isIssueEvent(payload)
|
|
396
|
+
? payload.data.id === issueId
|
|
397
|
+
: isAgentSessionEvent(payload) && payload.agentSession.issue?.id === issueId;
|
|
398
|
+
/** One line saying what a verified delivery is, in Linear's own words, naming
|
|
399
|
+
* the issue it is about — during a test several issues can be in play. The
|
|
400
|
+
* status our side answered with is left out on purpose: with the module not
|
|
401
|
+
* yet loaded nothing can answer 2xx, and the delivery arriving is the point. */
|
|
402
|
+
const describeDelivery = (payload) => {
|
|
403
|
+
if (isAgentSessionEvent(payload)) {
|
|
404
|
+
const issue = payload.agentSession.issue?.identifier ?? 'an issue the payload did not name';
|
|
405
|
+
return `agent-session event: ${payload.action}${payload.agentSession.status ? `, status ${payload.agentSession.status}` : ''} on ${issue}`;
|
|
406
|
+
}
|
|
407
|
+
if (isIssueEvent(payload)) {
|
|
408
|
+
const changed = payload.action === 'update' && payload.updatedFrom ? Object.keys(payload.updatedFrom).join(', ') : '';
|
|
409
|
+
return `issue ${payload.action} on ${payload.data.identifier ?? 'an issue the payload did not name'}${changed ? `, changed ${changed}` : ''}`;
|
|
410
|
+
}
|
|
411
|
+
return `${payload.type} ${payload.action}`;
|
|
412
|
+
};
|
|
413
|
+
/** The newest diary row id right now. Whatever exists before a test starts is
|
|
414
|
+
* nobody's business; anything the test draws lands with a larger id. */
|
|
415
|
+
const latestId = (store) => {
|
|
416
|
+
let last = 0;
|
|
417
|
+
let rows = store.requests({ sinceId: last });
|
|
418
|
+
while (rows.length) {
|
|
419
|
+
last = rows[rows.length - 1].id;
|
|
420
|
+
rows = store.requests({ sinceId: last });
|
|
421
|
+
}
|
|
422
|
+
return last;
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* Watch the diary from `cursor` until one verified delivery that `accepts`
|
|
426
|
+
* names our ticket, or `ms` has passed. A wait that passes is not made to
|
|
427
|
+
* sit out its window; the window is what a failure needs, because silence
|
|
428
|
+
* is a result too and only a waited-out silence proves it. The installer is
|
|
429
|
+
* another process than the server, and file events do not report SQLite's
|
|
430
|
+
* WAL writes — macOS never mentions them (measured 2026-09-26) — so a quiet
|
|
431
|
+
* re-read of one indexed query is the reliable arrival signal. Every
|
|
432
|
+
* verified delivery is said the moment it lands, whatever issue it names:
|
|
433
|
+
* the line belongs to the wait's spinner. A delivery that fails the
|
|
434
|
+
* module's own `verifyWebhook` ends the installer at once — the signing
|
|
435
|
+
* secret would be wrong for every delivery, so waiting cannot help.
|
|
436
|
+
*/
|
|
437
|
+
async function waitDiary(store, path, webhookSecret, issueId, cursor, ms, accepts, say) {
|
|
438
|
+
let mine = 0;
|
|
439
|
+
let others = 0;
|
|
440
|
+
let last = cursor;
|
|
441
|
+
await new Promise((settled, failed) => {
|
|
442
|
+
let done = false;
|
|
443
|
+
let poll;
|
|
444
|
+
let expiry;
|
|
445
|
+
const read = () => {
|
|
446
|
+
// `aivi host clear-logs` can empty the diary and restart its ids below
|
|
447
|
+
// our cursor; when that happens, follow the fresh diary, not the mark.
|
|
448
|
+
if (latestId(store) < last)
|
|
449
|
+
last = 0;
|
|
450
|
+
for (const row of store.requests({ method: 'POST', path, sinceId: last })) {
|
|
451
|
+
last = row.id;
|
|
452
|
+
if (row.truncated)
|
|
453
|
+
throw new Error(`a delivery arrived but its body is not readable JSON — the diary cut it off (row ${row.id}) — verification needs the whole body.`);
|
|
454
|
+
const verdict = verifyWebhook({
|
|
455
|
+
body: Buffer.from(row.body ?? '', 'utf8'),
|
|
456
|
+
signature: row.headers['linear-signature'],
|
|
457
|
+
secret: webhookSecret,
|
|
458
|
+
now: row.at,
|
|
459
|
+
});
|
|
460
|
+
if (!verdict.ok)
|
|
461
|
+
throw new Error(`a delivery arrived but failed the module's own check: ${verdict.reason} (HTTP ${row.status}).` +
|
|
462
|
+
(verdict.reason.includes('signature')
|
|
463
|
+
? ' The signing secret pasted here is not the one Linear sends.'
|
|
464
|
+
: ''));
|
|
465
|
+
say(`Heard: ${describeDelivery(verdict.payload)}`); // every delivery, the moment it lands
|
|
466
|
+
if (!namesIssue(verdict.payload, issueId)) {
|
|
467
|
+
others++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
// The proof is in: a passed wait is nobody's waiting room, and the
|
|
471
|
+
// stop's own read finishes nothing this loop has left to say.
|
|
472
|
+
if (accepts(verdict.payload)) {
|
|
473
|
+
mine++;
|
|
474
|
+
stop();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const stop = (error) => {
|
|
480
|
+
if (done)
|
|
481
|
+
return;
|
|
482
|
+
done = true;
|
|
483
|
+
if (poll !== undefined)
|
|
484
|
+
clearTimeout(poll);
|
|
485
|
+
if (expiry !== undefined)
|
|
486
|
+
clearTimeout(expiry);
|
|
487
|
+
if (error === undefined) {
|
|
488
|
+
try {
|
|
489
|
+
read(); // the deadline read: complete even if a tick was late
|
|
490
|
+
}
|
|
491
|
+
catch (cause) {
|
|
492
|
+
failed(cause);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
settled();
|
|
496
|
+
}
|
|
497
|
+
else
|
|
498
|
+
failed(error);
|
|
499
|
+
};
|
|
500
|
+
const tick = () => {
|
|
501
|
+
try {
|
|
502
|
+
read();
|
|
503
|
+
}
|
|
504
|
+
catch (cause) {
|
|
505
|
+
stop(cause);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// Self-rescheduling: one pending timer, always, and a slow read
|
|
509
|
+
// cannot pile up behind itself. And a read that ended the wait is
|
|
510
|
+
// never re-armed: no timer outlives the verdict.
|
|
511
|
+
if (done)
|
|
512
|
+
return;
|
|
513
|
+
poll = setTimeout(tick, POLL_MS);
|
|
514
|
+
};
|
|
515
|
+
poll = setTimeout(tick, POLL_MS); // the reliable arrival signal across processes: a re-read
|
|
516
|
+
expiry = setTimeout(() => stop(), ms); // the window's end: a known instant
|
|
517
|
+
try {
|
|
518
|
+
read(); // whatever already arrived before the watching began
|
|
519
|
+
}
|
|
520
|
+
catch (cause) {
|
|
521
|
+
stop(cause);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
return { mine, others, last };
|
|
525
|
+
}
|
|
526
|
+
export default setup;
|