@jeffober/thatch 0.1.40 → 0.1.42
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 +27 -10
- package/artifacts/skills/thatch-code-review.md +4 -4
- package/artifacts/skills/thatch-review-synthesizer.md +8 -8
- package/artifacts/skills/{thatch-review-mentorship.md → thatch-review-technique.md} +9 -10
- package/bin/release +4 -1
- package/bin/thatch +318 -18
- package/docs/dev/README.md +29 -15
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +33 -4
- package/docs/dev/features/cross-session-chat.md +315 -0
- package/docs/dev/features/database.md +52 -4
- package/docs/dev/features/multi-host.md +18 -9
- package/docs/dev/features/setup.md +12 -5
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +7 -4
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +5 -4
- package/docs/dev/skills.md +5 -4
- package/docs/user/README.md +25 -6
- package/docs/user/cli.md +78 -0
- package/docs/user/code-review.md +1 -1
- package/docs/user/cross-session-chat.md +150 -0
- package/docs/user/setup.md +16 -2
- package/docs/user/skills.md +7 -6
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +120 -0
- package/src/chat.ts +1210 -0
- package/src/config.ts +34 -1
- package/src/db.ts +227 -0
- package/src/index.ts +188 -17
- package/src/prompts.ts +218 -29
- package/src/setup.ts +83 -29
- package/src/skills.ts +47 -17
- package/src/tool-defs.ts +574 -31
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/src/tools.ts
CHANGED
|
@@ -18,6 +18,7 @@ export function createTools(
|
|
|
18
18
|
extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
|
|
19
19
|
drainExtractionQueue?: CoreContext["drainExtractionQueue"];
|
|
20
20
|
watcherRegistry?: WatcherRegistry;
|
|
21
|
+
projectDir?: string;
|
|
21
22
|
},
|
|
22
23
|
): Record<string, ReturnType<typeof tool>> {
|
|
23
24
|
const ctx: CoreContext = {
|
|
@@ -27,6 +28,7 @@ export function createTools(
|
|
|
27
28
|
extractionPayloadProvider: extensions?.extractionPayloadProvider,
|
|
28
29
|
drainExtractionQueue: extensions?.drainExtractionQueue,
|
|
29
30
|
watchers: extensions?.watcherRegistry,
|
|
31
|
+
projectDir: extensions?.projectDir,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
const tools: Record<string, ReturnType<typeof tool>> = {};
|
package/src/watchers.ts
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* The registry supports multiple source types behind one mechanism. The
|
|
6
6
|
* first source was GitHub pull requests (source "pr"); GitHub branches
|
|
7
7
|
* (source "branch") arrived second, for watching CI and workflow runs
|
|
8
|
-
* against main
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* against main; shell commands (source "command") arrived third, for
|
|
9
|
+
* waiting on arbitrary local conditions. A source contributes: event
|
|
10
|
+
* types, a fetch function that produces a snapshot of the watched state,
|
|
11
|
+
* and a pure diff between two snapshots. Everything else - registry,
|
|
12
|
+
* pending queue, delivery gating - is source-agnostic. The command source
|
|
13
|
+
* stretches the recipe in two ways: its fetch runs the watched command
|
|
14
|
+
* instead of reading an API, and its diff is a trivial exit-code check -
|
|
15
|
+
* the command is a condition variable, and only its exit code is read,
|
|
16
|
+
* never its output (injection hygiene: command output can be external
|
|
17
|
+
* content, so it never rides a notification).
|
|
12
18
|
*
|
|
13
19
|
* Lifetime is deliberately process-scoped. The registry lives in plugin
|
|
14
20
|
* memory, never in SQLite: opencode loads thatch in-process, so the plugin
|
|
@@ -40,7 +46,10 @@ export type PrWatcherEventType =
|
|
|
40
46
|
/** Event types for source "branch" (typically main): commits land, CI and workflow runs execute. */
|
|
41
47
|
export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_workflow";
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
/** Event type for source "command": the watched shell command exited 0. */
|
|
50
|
+
export type CommandWatcherEventType = "command_success";
|
|
51
|
+
|
|
52
|
+
export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType | CommandWatcherEventType;
|
|
44
53
|
|
|
45
54
|
export const PR_EVENT_TYPES: PrWatcherEventType[] = [
|
|
46
55
|
"pr_comment",
|
|
@@ -59,15 +68,20 @@ export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
|
|
|
59
68
|
"branch_workflow",
|
|
60
69
|
];
|
|
61
70
|
|
|
71
|
+
export const COMMAND_EVENT_TYPES: CommandWatcherEventType[] = ["command_success"];
|
|
72
|
+
|
|
62
73
|
/** Every event type across all sources - for diagnostics and tests. */
|
|
63
|
-
export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES];
|
|
74
|
+
export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES, ...COMMAND_EVENT_TYPES];
|
|
64
75
|
|
|
65
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* A single detected change, ready for delivery. Pointer data plus machine
|
|
78
|
+
* status (check names, conclusions, counts) - never external content.
|
|
79
|
+
*/
|
|
66
80
|
export interface WatcherEvent {
|
|
67
81
|
type: WatcherEventType;
|
|
68
82
|
/** Which watch produced this event, e.g. "acme/widgets#7" or "acme/widgets@main". */
|
|
69
83
|
target: string;
|
|
70
|
-
/**
|
|
84
|
+
/** Short summary: who/what/where plus machine status (CI conclusions) - never body text or other external content. */
|
|
71
85
|
summary: string;
|
|
72
86
|
url: string;
|
|
73
87
|
}
|
|
@@ -143,6 +157,8 @@ export interface PrWatcher {
|
|
|
143
157
|
repo: string;
|
|
144
158
|
pr: number;
|
|
145
159
|
events: PrWatcherEventType[];
|
|
160
|
+
/** One-shot: cancelled automatically after the first matching event. */
|
|
161
|
+
once: boolean;
|
|
146
162
|
/** Epoch ms. When now > expiresAt the watcher is silently dropped. */
|
|
147
163
|
expiresAt: number;
|
|
148
164
|
createdAt: number;
|
|
@@ -159,12 +175,39 @@ export interface BranchWatcher {
|
|
|
159
175
|
events: BranchWatcherEventType[];
|
|
160
176
|
/** Substring filters on workflow run names; empty means all workflows. */
|
|
161
177
|
workflows: string[];
|
|
178
|
+
/** One-shot: cancelled automatically after the first matching event. */
|
|
179
|
+
once: boolean;
|
|
180
|
+
/** Epoch ms. When now > expiresAt the watcher is silently dropped. */
|
|
162
181
|
expiresAt: number;
|
|
163
182
|
createdAt: number;
|
|
164
183
|
state: BranchState;
|
|
165
184
|
}
|
|
166
185
|
|
|
167
|
-
|
|
186
|
+
/** The last-seen state of a command watch: the most recent observed exit code. */
|
|
187
|
+
export interface CommandState {
|
|
188
|
+
lastExit: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface CommandWatcher {
|
|
192
|
+
id: string;
|
|
193
|
+
source: "command";
|
|
194
|
+
sessionID: string;
|
|
195
|
+
/** The shell command, run via bash -c in cwd every poll. A condition variable: only its exit code is read, never its output. */
|
|
196
|
+
command: string;
|
|
197
|
+
/** Working directory the command runs in, captured at registration. */
|
|
198
|
+
cwd: string;
|
|
199
|
+
/** Per-run kill timeout in ms - a hanging command must not stall the shared poll cycle. */
|
|
200
|
+
timeoutMs: number;
|
|
201
|
+
events: CommandWatcherEventType[];
|
|
202
|
+
/** Command watchers are inherently one-shot: they fire on the first exit 0 and cancel. */
|
|
203
|
+
once: true;
|
|
204
|
+
/** Epoch ms. When now > expiresAt the watcher is silently dropped. */
|
|
205
|
+
expiresAt: number;
|
|
206
|
+
createdAt: number;
|
|
207
|
+
state: CommandState;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type Watcher = PrWatcher | BranchWatcher | CommandWatcher;
|
|
168
211
|
|
|
169
212
|
/** Runs one GitHub API call via the gh CLI and parses the JSON response.
|
|
170
213
|
* The array is the argument list after `gh api`: a REST path like
|
|
@@ -173,17 +216,41 @@ export type Watcher = PrWatcher | BranchWatcher;
|
|
|
173
216
|
* without the callers string-concatenating shell quotes. */
|
|
174
217
|
export type GhRunner = (apiArgs: string[]) => Promise<unknown>;
|
|
175
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Runs one watched command and reports how it ended. The command's stdout is
|
|
221
|
+
* drained but discarded - the watcher treats the command as a condition
|
|
222
|
+
* variable (exit code only), so command output never reaches a notification.
|
|
223
|
+
* stderr is kept (capped) for registration-time error messages only.
|
|
224
|
+
*/
|
|
225
|
+
export interface CommandRunResult {
|
|
226
|
+
exitCode: number;
|
|
227
|
+
/** True when the runner killed the command at the timeout instead of letting it finish. */
|
|
228
|
+
timedOut: boolean;
|
|
229
|
+
stderr: string;
|
|
230
|
+
/** Wall-clock duration of the run in ms, for the notification summary. */
|
|
231
|
+
durationMs: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type CommandRunner = (command: string, cwd: string, timeoutMs: number) => Promise<CommandRunResult>;
|
|
235
|
+
|
|
176
236
|
export interface WatcherRegistryOptions {
|
|
177
237
|
/** Delivers a batch of events for a session. Injected so tests never spawn. */
|
|
178
238
|
deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
|
|
179
239
|
/**
|
|
180
240
|
* Gate for delivery. The plugin passes a predicate that returns true only
|
|
181
241
|
* when the session can accept a proactive prompt right now (idle, not
|
|
182
|
-
* compacting).
|
|
183
|
-
*
|
|
242
|
+
* compacting). May be async - the plugin's gate verifies against the
|
|
243
|
+
* server's live session status, because the event-fed map can be stale
|
|
244
|
+
* and a wake injected into a running turn is mid-turn context injection.
|
|
245
|
+
* Undeliverable events stay pending and retry on later cycles or when
|
|
246
|
+
* the session next goes idle.
|
|
184
247
|
*/
|
|
185
|
-
canDeliver: (sessionID: string) => boolean
|
|
248
|
+
canDeliver: (sessionID: string) => boolean | Promise<boolean>;
|
|
186
249
|
ghRunner: GhRunner;
|
|
250
|
+
/** Runs watched commands. Injected so tests never spawn; defaults to runWatchedCommand. */
|
|
251
|
+
commandRunner?: CommandRunner;
|
|
252
|
+
/** Per-run kill timeout for watched commands; defaults to THATCH_WATCH_COMMAND_TIMEOUT_SECONDS or 30s. */
|
|
253
|
+
commandTimeoutMs?: number;
|
|
187
254
|
pollIntervalMs?: number;
|
|
188
255
|
ttlMinutes?: number;
|
|
189
256
|
maxPerSession?: number;
|
|
@@ -196,26 +263,78 @@ export interface WatcherRegistryOptions {
|
|
|
196
263
|
/**
|
|
197
264
|
* The default GhRunner. Shells out to `gh api` so auth comes from the user's
|
|
198
265
|
* existing gh session - thatch never sees or stores a token. Throws on
|
|
199
|
-
* non-zero exit or unparseable output.
|
|
266
|
+
* non-zero exit or unparseable output. See ghApiRunWithTimeout for the
|
|
267
|
+
* drain-race hardening.
|
|
200
268
|
*/
|
|
201
269
|
export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
|
|
270
|
+
return ghApiRunWithTimeout(apiArgs, GH_API_TIMEOUT_MS);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Per-call timeout for gh api invocations. */
|
|
274
|
+
const GH_API_TIMEOUT_MS = 15_000;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One gh api call with the drain-race hardening: the pipe drains are raced
|
|
278
|
+
* against a hard deadline (timeout plus COMMAND_DRAIN_GRACE_MS) so a gh
|
|
279
|
+
* child process holding the pipes past the kill rejects cleanly instead of
|
|
280
|
+
* deadlocking the shared poll cycle (same hardening as runWatchedCommand).
|
|
281
|
+
* The stderr read on the error path gets the same race - a descendant that
|
|
282
|
+
* dup2'd stdout away but kept stderr would otherwise wedge the cycle.
|
|
283
|
+
*/
|
|
284
|
+
export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number): Promise<unknown> {
|
|
202
285
|
const proc = Bun.spawn(["gh", "api", ...apiArgs], {
|
|
203
286
|
stdout: "pipe",
|
|
204
287
|
stderr: "pipe",
|
|
205
288
|
});
|
|
206
|
-
const timeout = setTimeout(() => proc.kill(),
|
|
289
|
+
const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
|
|
290
|
+
const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
|
|
291
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
207
292
|
try {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
293
|
+
const stdoutReader = proc.stdout.getReader();
|
|
294
|
+
const stderrReader = proc.stderr.getReader();
|
|
295
|
+
const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
|
|
296
|
+
let text = "";
|
|
297
|
+
try {
|
|
298
|
+
for (;;) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done) break;
|
|
301
|
+
text += commandTargetLabel.length === -1 ? "" : new TextDecoder().decode(value, { stream: true });
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// Cancelled or errored: keep what was read.
|
|
305
|
+
}
|
|
306
|
+
return text;
|
|
307
|
+
};
|
|
308
|
+
const drain = Promise.all([readAll(stdoutReader), proc.exited]);
|
|
309
|
+
// If the drain does not finish by the deadline (orphaned grandchild
|
|
310
|
+
// holding the pipes), abandon it - and cancel the readers so the pending
|
|
311
|
+
// reads settle instead of pinning the host process at shutdown.
|
|
312
|
+
const raced = await Promise.race([
|
|
313
|
+
drain.then(([stdout, exitCode]) => ({ stdout, exitCode })),
|
|
314
|
+
new Promise<null>((resolve) => {
|
|
315
|
+
raceTimer = setTimeout(() => resolve(null), deadlineMs);
|
|
316
|
+
raceTimer.unref?.();
|
|
317
|
+
}),
|
|
211
318
|
]);
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
319
|
+
if (raced === null) {
|
|
320
|
+
void stdoutReader.cancel().catch(() => {});
|
|
321
|
+
void stderrReader.cancel().catch(() => {});
|
|
322
|
+
throw new Error(`gh api ${apiArgs.join(" ")} timed out (orphaned child holding the output pipe; killed at the ${timeoutMs}ms timeout, drains abandoned after ${Math.round(deadlineMs / 1000)}s)`);
|
|
323
|
+
}
|
|
324
|
+
if (raced.exitCode !== 0) {
|
|
325
|
+
const racedErr = await Promise.race([
|
|
326
|
+
readAll(stderrReader),
|
|
327
|
+
new Promise<string>((resolve) => {
|
|
328
|
+
const t = setTimeout(() => resolve(""), deadlineMs);
|
|
329
|
+
t.unref?.();
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
332
|
+
throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${raced.exitCode}): ${racedErr.trim().slice(0, 200)}`);
|
|
215
333
|
}
|
|
216
|
-
return JSON.parse(stdout);
|
|
334
|
+
return JSON.parse(raced.stdout);
|
|
217
335
|
} finally {
|
|
218
336
|
clearTimeout(timeout);
|
|
337
|
+
clearTimeout(raceTimer);
|
|
219
338
|
}
|
|
220
339
|
}
|
|
221
340
|
|
|
@@ -245,6 +364,115 @@ export function _resetGhAvailability(): void {
|
|
|
245
364
|
ghAvailability = null;
|
|
246
365
|
}
|
|
247
366
|
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Watched command execution
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/** Per-run timeout for watched commands, overridable via THATCH_WATCH_COMMAND_TIMEOUT_SECONDS. */
|
|
372
|
+
export function defaultCommandTimeoutMs(): number {
|
|
373
|
+
const env = Number(process.env.THATCH_WATCH_COMMAND_TIMEOUT_SECONDS ?? 0);
|
|
374
|
+
return env > 0 ? env * 1000 : 30_000;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** One-line clip of a command for use as a watcher target label. */
|
|
378
|
+
export function commandTargetLabel(command: string): string {
|
|
379
|
+
const oneLine = command.replace(/\s+/g, " ").trim();
|
|
380
|
+
return oneLine.length <= 60 ? oneLine : oneLine.slice(0, 57) + "...";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const COMMAND_STDERR_CAP = 2000;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Grace past the spawn timeout before the pipe drains are abandoned. The
|
|
387
|
+
* direct child has already been SIGKILLed by then, so anything still holding
|
|
388
|
+
* the pipes is an orphaned grandchild - its output is lost, but the poll
|
|
389
|
+
* cycle survives.
|
|
390
|
+
*/
|
|
391
|
+
const COMMAND_DRAIN_GRACE_MS = 2_000;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The default CommandRunner: bash -c in the given cwd, killed at the timeout.
|
|
395
|
+
* Both pipes are always drained even though stdout is discarded - a child
|
|
396
|
+
* writing more than the pipe buffer would otherwise block forever. A timeout
|
|
397
|
+
* counts as "condition not met yet", not an error, so the watch keeps polling.
|
|
398
|
+
*
|
|
399
|
+
* The kill cannot always terminate the whole process tree: a command that
|
|
400
|
+
* backgrounds a long-lived child leaves a grandchild holding the pipe
|
|
401
|
+
* write-ends, so the pipe reads may never reach EOF. The drains are therefore
|
|
402
|
+
* raced against a hard deadline (timeout plus a short grace period) - the
|
|
403
|
+
* run completes as timed-out even when the pipes stay open, and a leaked
|
|
404
|
+
* reader (and its buffered output) is abandoned rather than stalling the
|
|
405
|
+
* shared poll cycle forever. An abandoned grandchild itself keeps running
|
|
406
|
+
* until it exits on its own; nothing reaps it.
|
|
407
|
+
*/
|
|
408
|
+
export async function runWatchedCommand(command: string, cwd: string, timeoutMs: number): Promise<CommandRunResult> {
|
|
409
|
+
const startedAt = Date.now();
|
|
410
|
+
let killed = false;
|
|
411
|
+
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
412
|
+
try {
|
|
413
|
+
proc = Bun.spawn(["bash", "-c", command], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
414
|
+
} catch (err) {
|
|
415
|
+
throw new Error(`failed to start the watched command (check the command and project directory): ${err instanceof Error ? err.message : String(err)}`);
|
|
416
|
+
}
|
|
417
|
+
const timeout = setTimeout(() => {
|
|
418
|
+
killed = true;
|
|
419
|
+
proc.kill("SIGKILL");
|
|
420
|
+
}, timeoutMs);
|
|
421
|
+
const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
|
|
422
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
423
|
+
try {
|
|
424
|
+
// Explicit readers instead of Response.text(): a held reader lock keeps
|
|
425
|
+
// stream.cancel() from working, but reader.cancel() both settles the
|
|
426
|
+
// pending reads and releases the host when the deadline wins.
|
|
427
|
+
const stdoutReader = proc.stdout.getReader();
|
|
428
|
+
const stderrReader = proc.stderr.getReader();
|
|
429
|
+
const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
|
|
430
|
+
let text = "";
|
|
431
|
+
try {
|
|
432
|
+
for (;;) {
|
|
433
|
+
const { done, value } = await reader.read();
|
|
434
|
+
if (done) break;
|
|
435
|
+
text += new TextDecoder().decode(value, { stream: true });
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// Cancelled or errored: keep what was read.
|
|
439
|
+
}
|
|
440
|
+
return text;
|
|
441
|
+
};
|
|
442
|
+
const drain = Promise.all([readAll(stdoutReader), readAll(stderrReader), proc.exited]);
|
|
443
|
+
// If the drain does not finish by the deadline (orphaned grandchild
|
|
444
|
+
// holding the pipes), abandon it - and cancel the readers so the pending
|
|
445
|
+
// reads settle instead of pinning the host process at shutdown.
|
|
446
|
+
// The race timer is unref'd and cleared so a lingering fallback can
|
|
447
|
+
// never keep the host process alive on its own.
|
|
448
|
+
const result = await Promise.race([
|
|
449
|
+
drain.then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })),
|
|
450
|
+
new Promise<null>((resolve) => {
|
|
451
|
+
raceTimer = setTimeout(() => resolve(null), Math.max(0, deadlineMs - (Date.now() - startedAt)));
|
|
452
|
+
raceTimer.unref?.();
|
|
453
|
+
}),
|
|
454
|
+
]);
|
|
455
|
+
if (result === null) {
|
|
456
|
+
void stdoutReader.cancel().catch(() => {});
|
|
457
|
+
void stderrReader.cancel().catch(() => {});
|
|
458
|
+
return { exitCode: 124, timedOut: true, stderr: "", durationMs: Date.now() - startedAt };
|
|
459
|
+
}
|
|
460
|
+
const timedOut = killed || result.exitCode === null;
|
|
461
|
+
if (timedOut) {
|
|
462
|
+
console.error(`[thatch] watched command killed at the ${timeoutMs}ms timeout: ${commandTargetLabel(command)}`);
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
exitCode: result.exitCode ?? 124,
|
|
466
|
+
timedOut,
|
|
467
|
+
stderr: result.stderr.slice(-COMMAND_STDERR_CAP),
|
|
468
|
+
durationMs: Date.now() - startedAt,
|
|
469
|
+
};
|
|
470
|
+
} finally {
|
|
471
|
+
clearTimeout(timeout);
|
|
472
|
+
clearTimeout(raceTimer);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
248
476
|
// ---------------------------------------------------------------------------
|
|
249
477
|
// Snapshot construction
|
|
250
478
|
// ---------------------------------------------------------------------------
|
|
@@ -553,13 +781,21 @@ export class WatcherRegistry {
|
|
|
553
781
|
#timer: ReturnType<typeof setInterval> | null = null;
|
|
554
782
|
#delivering = false;
|
|
555
783
|
#polling = false;
|
|
556
|
-
readonly #opts: WatcherRegistryOptions & {
|
|
784
|
+
readonly #opts: WatcherRegistryOptions & {
|
|
785
|
+
pollIntervalMs: number;
|
|
786
|
+
ttlMinutes: number;
|
|
787
|
+
maxPerSession: number;
|
|
788
|
+
commandRunner: CommandRunner;
|
|
789
|
+
commandTimeoutMs: number;
|
|
790
|
+
};
|
|
557
791
|
|
|
558
792
|
constructor(options: WatcherRegistryOptions) {
|
|
559
793
|
this.#opts = {
|
|
560
794
|
pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
|
|
561
795
|
ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
|
|
562
796
|
maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
|
|
797
|
+
commandRunner: runWatchedCommand,
|
|
798
|
+
commandTimeoutMs: defaultCommandTimeoutMs(),
|
|
563
799
|
...options,
|
|
564
800
|
};
|
|
565
801
|
}
|
|
@@ -587,6 +823,15 @@ export class WatcherRegistry {
|
|
|
587
823
|
return this.#timer !== null;
|
|
588
824
|
}
|
|
589
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Poll cadence in seconds, surfaced in tool output and notifications so
|
|
828
|
+
* watcher-vs-poll choices compare real numbers even when the interval is
|
|
829
|
+
* overridden via THATCH_WATCH_POLL_SECONDS.
|
|
830
|
+
*/
|
|
831
|
+
get pollSeconds(): number {
|
|
832
|
+
return Math.round(this.#opts.pollIntervalMs / 1000);
|
|
833
|
+
}
|
|
834
|
+
|
|
590
835
|
/** Stops polling and drops all state. Called from the plugin's dispose. */
|
|
591
836
|
dispose(): void {
|
|
592
837
|
this.stop();
|
|
@@ -607,6 +852,7 @@ export class WatcherRegistry {
|
|
|
607
852
|
repo: string,
|
|
608
853
|
pr: number,
|
|
609
854
|
events: PrWatcherEventType[],
|
|
855
|
+
opts: { once?: boolean } = {},
|
|
610
856
|
): Promise<{ ok: true; watcher: PrWatcher } | { ok: false; error: string }> {
|
|
611
857
|
const limit = this.#checkLimit(sessionID);
|
|
612
858
|
if (limit) return { ok: false, error: limit };
|
|
@@ -629,6 +875,7 @@ export class WatcherRegistry {
|
|
|
629
875
|
repo,
|
|
630
876
|
pr,
|
|
631
877
|
events,
|
|
878
|
+
once: opts.once === true,
|
|
632
879
|
expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
|
|
633
880
|
createdAt: Date.now(),
|
|
634
881
|
state,
|
|
@@ -648,6 +895,7 @@ export class WatcherRegistry {
|
|
|
648
895
|
branch: string,
|
|
649
896
|
events: BranchWatcherEventType[],
|
|
650
897
|
workflows: string[] = [],
|
|
898
|
+
opts: { once?: boolean } = {},
|
|
651
899
|
): Promise<{ ok: true; watcher: BranchWatcher } | { ok: false; error: string }> {
|
|
652
900
|
const limit = this.#checkLimit(sessionID);
|
|
653
901
|
if (limit) return { ok: false, error: limit };
|
|
@@ -671,6 +919,7 @@ export class WatcherRegistry {
|
|
|
671
919
|
branch,
|
|
672
920
|
events,
|
|
673
921
|
workflows,
|
|
922
|
+
once: opts.once === true,
|
|
674
923
|
expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
|
|
675
924
|
createdAt: Date.now(),
|
|
676
925
|
state,
|
|
@@ -679,6 +928,61 @@ export class WatcherRegistry {
|
|
|
679
928
|
return { ok: true, watcher };
|
|
680
929
|
}
|
|
681
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Registers a command watcher: the poller runs the command (as a condition
|
|
933
|
+
* variable - exit code only) every cycle until it first exits 0, notifies,
|
|
934
|
+
* and cancels. The baseline run doubles as validation, like createPr: a
|
|
935
|
+
* command that already exits 0 means the condition is already met (refuse,
|
|
936
|
+
* so the caller proceeds now), exit 127 means the command does not exist,
|
|
937
|
+
* and a spawn failure fails with a real error instead of a watcher that
|
|
938
|
+
* can never fire.
|
|
939
|
+
*/
|
|
940
|
+
async createCommand(
|
|
941
|
+
sessionID: string,
|
|
942
|
+
command: string,
|
|
943
|
+
cwd: string,
|
|
944
|
+
): Promise<{ ok: true; watcher: CommandWatcher } | { ok: false; error: string }> {
|
|
945
|
+
const limit = this.#checkLimit(sessionID);
|
|
946
|
+
if (limit) return { ok: false, error: limit };
|
|
947
|
+
if (!command.trim()) return { ok: false, error: "No command to watch - pass a shell command." };
|
|
948
|
+
|
|
949
|
+
let baseline: CommandRunResult;
|
|
950
|
+
try {
|
|
951
|
+
baseline = await this.#opts.commandRunner(command, cwd, this.#opts.commandTimeoutMs);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
return { ok: false, error: `Failed to run the command: ${err instanceof Error ? err.message : String(err)}` };
|
|
954
|
+
}
|
|
955
|
+
if (!baseline.timedOut && baseline.exitCode === 0) {
|
|
956
|
+
return { ok: false, error: "The command already exits 0 - the condition is already met, so no watcher was registered. Proceed now, or re-run the command yourself if you need its output." };
|
|
957
|
+
}
|
|
958
|
+
if (baseline.exitCode === 127) {
|
|
959
|
+
return {
|
|
960
|
+
ok: false,
|
|
961
|
+
error: `The command exited 127 (command not found) - nothing to wait for until it exists. stderr: ${baseline.stderr.trim() || "(none)"}`,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const watcher: CommandWatcher = {
|
|
966
|
+
id: `watch_${Math.random().toString(36).slice(2, 10)}`,
|
|
967
|
+
source: "command",
|
|
968
|
+
sessionID,
|
|
969
|
+
command,
|
|
970
|
+
cwd,
|
|
971
|
+
timeoutMs: this.#opts.commandTimeoutMs,
|
|
972
|
+
events: [...COMMAND_EVENT_TYPES],
|
|
973
|
+
once: true,
|
|
974
|
+
expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
|
|
975
|
+
createdAt: Date.now(),
|
|
976
|
+
// A timed-out baseline has no meaningful exit code - the killed process
|
|
977
|
+
// may have exited 0 while dying. Record the 124 sentinel so watch_list
|
|
978
|
+
// and the registration output never display a misleading 0 for a
|
|
979
|
+
// watcher that has not observed a real exit yet.
|
|
980
|
+
state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
|
|
981
|
+
};
|
|
982
|
+
this.#watchers.set(watcher.id, watcher);
|
|
983
|
+
return { ok: true, watcher };
|
|
984
|
+
}
|
|
985
|
+
|
|
682
986
|
#checkLimit(sessionID: string): string | null {
|
|
683
987
|
const existing = this.listForSession(sessionID);
|
|
684
988
|
if (existing.length >= this.#opts.maxPerSession) {
|
|
@@ -735,6 +1039,12 @@ export class WatcherRegistry {
|
|
|
735
1039
|
const queue = this.#pending.get(watcher.sessionID) ?? [];
|
|
736
1040
|
queue.push(...events);
|
|
737
1041
|
this.#pending.set(watcher.sessionID, queue);
|
|
1042
|
+
// One-shot watchers end at first detection, not first delivery:
|
|
1043
|
+
// the queued events deliver through the normal pending path even
|
|
1044
|
+
// if the session is busy. Cancellation at detection time keeps
|
|
1045
|
+
// "wait for this run to finish" from leaving a standing watch
|
|
1046
|
+
// polling a target nobody is waiting on anymore.
|
|
1047
|
+
if (watcher.once) this.#watchers.delete(id);
|
|
738
1048
|
}
|
|
739
1049
|
} catch (err) {
|
|
740
1050
|
console.error(`[thatch] watcher ${id} poll failed: ${err}`);
|
|
@@ -755,6 +1065,22 @@ export class WatcherRegistry {
|
|
|
755
1065
|
watcher.state = after;
|
|
756
1066
|
return events;
|
|
757
1067
|
}
|
|
1068
|
+
if (watcher.source === "command") {
|
|
1069
|
+
const result = await this.#opts.commandRunner(watcher.command, watcher.cwd, watcher.timeoutMs);
|
|
1070
|
+
// A timed-out run produced no meaningful exit code, so the last-seen
|
|
1071
|
+
// exit stays as-is and the watch keeps waiting. Exit 0 needs no
|
|
1072
|
+
// transition guard: the baseline refusal keeps lastExit non-zero at
|
|
1073
|
+
// registration, and the one-shot cancel in poll() removes the watcher
|
|
1074
|
+
// the moment this event fires.
|
|
1075
|
+
if (!result.timedOut) watcher.state = { lastExit: result.exitCode };
|
|
1076
|
+
if (result.timedOut || result.exitCode !== 0) return [];
|
|
1077
|
+
return [{
|
|
1078
|
+
type: "command_success",
|
|
1079
|
+
target: commandTargetLabel(watcher.command),
|
|
1080
|
+
summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
|
|
1081
|
+
url: "",
|
|
1082
|
+
}];
|
|
1083
|
+
}
|
|
758
1084
|
const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
|
|
759
1085
|
const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
|
|
760
1086
|
watcher.state = after;
|
|
@@ -780,7 +1106,16 @@ export class WatcherRegistry {
|
|
|
780
1106
|
try {
|
|
781
1107
|
for (const [sessionID, events] of this.#pending) {
|
|
782
1108
|
if (events.length === 0) continue;
|
|
783
|
-
|
|
1109
|
+
// A throwing gate must not escape poll() - an unhandled rejection
|
|
1110
|
+
// here would take down the whole poll cycle, the same failure class
|
|
1111
|
+
// as a hung fetch. Fail closed: the mail stays pending.
|
|
1112
|
+
let deliverable = false;
|
|
1113
|
+
try {
|
|
1114
|
+
deliverable = await this.#opts.canDeliver(sessionID);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
console.error(`[thatch] canDeliver gate failed for ${sessionID}: ${err}`);
|
|
1117
|
+
}
|
|
1118
|
+
if (!deliverable) continue;
|
|
784
1119
|
try {
|
|
785
1120
|
await this.#opts.deliver(sessionID, events);
|
|
786
1121
|
this.#pending.delete(sessionID);
|