@aion0/forge 0.10.20 → 0.10.23

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,77 @@
1
+ # Background Watches
2
+
3
+ A **watch** is Forge's lightweight async primitive for long-running jobs:
4
+ you (or the assistant) kick something off that finishes minutes later —
5
+ a device firmware upgrade, a Jenkins build, a test run — and instead of
6
+ the assistant sitting in the conversation polling (burning tokens,
7
+ getting stuck), Forge polls it in the **background** and posts the result
8
+ back into this chat when it's done.
9
+
10
+ You don't manage watches directly most of the time — they appear when a
11
+ long job is started and clear themselves when it finishes.
12
+
13
+ ## What you see
14
+
15
+ - **Progress chip** — while a watch runs, a small status pill shows
16
+ above the chat composer (e.g. "Upgrading NAC 10.15.52.152… poll 3/15,
17
+ build 6956"). It updates in place, is **not** a chat message (doesn't
18
+ clutter the thread), and disappears when the job finishes.
19
+ - **Completion message** — when the job reaches its goal (or fails /
20
+ times out), a normal assistant message lands in the chat:
21
+ "NAC … upgrade confirmed — now running build 6957." If your session
22
+ came from Telegram, that message arrives in Telegram.
23
+ - **Watch list** — see and control active watches in two places:
24
+ - **/chat** web: the "Background watches" panel in the left sidebar.
25
+ - **Forge web**: user menu → **Monitor** → "Background Watches".
26
+ Each entry shows status (active / done / failed / timed_out), poll
27
+ count, and **Cancel** / **Delete** buttons.
28
+
29
+ Watches survive a Forge restart (persisted in SQLite) — an in-flight
30
+ upgrade keeps being watched after a restart, no chat needed.
31
+
32
+ ## Two ways a watch starts
33
+
34
+ **1. Connector built-in (`async` block).** Some connector tools are
35
+ pre-declared as long tasks by their author — e.g. `nac.upgrade`
36
+ automatically registers a watch that polls `nac.get_version` until the
37
+ new build is running. Nothing for you to do; it just works.
38
+
39
+ **2. The assistant decides (`start_watch`).** For long jobs that aren't
40
+ pre-declared — a Jenkins build, a one-off cross-connector flow — the
41
+ assistant calls the `start_watch` builtin on the fly: it triggers the
42
+ job, registers a watch to poll the right status tool, and then stops
43
+ talking. You get the result later in chat.
44
+
45
+ Both use the same background machinery; you experience them identically.
46
+
47
+ ## `start_watch` (for the assistant)
48
+
49
+ When you've just started a long job and have a tool that reports its
50
+ status, register a watch instead of polling in the conversation:
51
+
52
+ ```
53
+ start_watch({
54
+ poll: "jenkins.get_build", // <connector>.<status tool>
55
+ poll_args: { job_path: "job/foo", build_number: 18 },
56
+ done_match: { path: "result", equals: "SUCCESS" }, // or done_path (truthy)
57
+ interval_sec: 60, timeout_sec: 1800,
58
+ message: "Build 18 finished: {poll.result}" // {poll.<path>} = latest result
59
+ })
60
+ ```
61
+
62
+ Then **stop** — do not keep calling get_build in the conversation. A
63
+ completion message arrives in chat; the user can cancel it from the watch
64
+ list. Pick `done_match`/`done_path` from a field you saw in the status
65
+ tool's output (you usually called it once already). Queue/startup errors
66
+ (e.g. a build number 404 while still queued) are tolerated for a while.
67
+ Guards (max polls, timeout, lifetime, active cap) keep it from running
68
+ away; worst case it times out and reports that.
69
+
70
+ ## Limits
71
+
72
+ - Watches are short-lived; they end at done / failed / timed_out /
73
+ cancelled and stop polling.
74
+ - `start_watch` reports back only via chat — it doesn't open a separate
75
+ notification channel. Telegram-origin sessions reply on Telegram.
76
+ - A watch can't *fix* a job that's stuck waiting for human action (a
77
+ build needing approval); it only watches to a final state and reports.
@@ -50,6 +50,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
50
50
  | `21-build-connector.md` | **Authoring** a custom connector — interview script, manifest template (browser / http / shell protocols), how to install locally via the Forge data dir or a zip upload. Use this when the user asks to BUILD a connector, not when they ask about an existing one. |
51
51
  | `18-chrome-mcp.md` | Connect Forge Claude Code sessions to a real Chrome via chrome-devtools-mcp — dev-time browser access for connector authoring |
52
52
  | `23-automation-states.md` | Fortinet pipeline automation: GitLab MR stage labels, Mantis status flow, Teams notify policy |
53
+ | `24-watch.md` | Background watches — async polling of long jobs (device upgrade, Jenkins build, test run) that report back in chat. Two triggers: connectors' declarative `async` block, and the `start_watch` builtin the assistant calls on the fly. Where to see/cancel them. |
53
54
 
54
55
  ## Matching questions to docs
55
56
 
@@ -83,3 +84,4 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
83
84
  - Job / scheduled job / connector poll / "Jobs tab" → tell user: **Jobs is deprecated**; use Schedules (`13-schedules.md`) instead.
84
85
  - Recipe / "From recipe" form / parameterized job → tell user: **recipes deprecated** along with Jobs; fire pipelines manually or via Schedules.
85
86
  - Mantis bug fix / fortinet-mantis-bug-fix / open MR for Mantis bug / fortinet-mr-review / pre-review / GitLab stage labels → `23-automation-states.md` (kept Fortinet pipelines)
87
+ - Background watch / "watch this build" / "tell me when the upgrade is done" / progress chip / start_watch / async long job / why is the assistant polling → `24-watch.md`
@@ -0,0 +1,108 @@
1
+ /**
2
+ * registerWatch — turn a just-run `async` tool into a background watch.
3
+ *
4
+ * Called by the tool-dispatcher right after a tool with an `async` block
5
+ * runs (and detaches). Resolves the spec's templates against the
6
+ * trigger's args/result, enforces the global active cap and chain-depth
7
+ * guard, and persists a watch row. The watch-runner (chat-standalone)
8
+ * picks it up on the next tick. No dependency on the runner or
9
+ * tool-dispatcher → no import cycle.
10
+ */
11
+
12
+ import type { AsyncWatchSpec } from '../connectors/types';
13
+ import { resolveDeep, resolveTemplate } from './template';
14
+ import { createWatch, countActive, type WatchAction } from './watch-store';
15
+
16
+ export const MAX_ACTIVE_WATCHES = 50;
17
+ export const DEFAULT_CHAIN_DEPTH = 3;
18
+ const MIN_INTERVAL_SEC = 30;
19
+ const DEFAULT_INTERVAL_SEC = 60;
20
+ const DEFAULT_TIMEOUT_SEC = 1200;
21
+ const DEFAULT_MAX_POLLS = 40;
22
+
23
+ export interface RegisterWatchCtx {
24
+ spec: AsyncWatchSpec;
25
+ connectorId: string;
26
+ toolName: string;
27
+ args: Record<string, unknown>; // trigger tool input
28
+ result: unknown; // trigger tool result (parsed)
29
+ settings: Record<string, unknown>;
30
+ sessionId: string | null;
31
+ chainDepth: number; // remaining chain budget
32
+ }
33
+
34
+ export type RegisterResult =
35
+ | { ok: true; watch_id: string; label: string }
36
+ | { ok: false; reason: string };
37
+
38
+ function num(v: unknown, dflt: number): number {
39
+ const n = Number(v);
40
+ return Number.isFinite(n) && n > 0 ? n : dflt;
41
+ }
42
+
43
+ export function registerWatch(ctx: RegisterWatchCtx): RegisterResult {
44
+ const { spec } = ctx;
45
+ if (!spec || !spec.poll) return { ok: false, reason: 'no async.poll declared' };
46
+ if (ctx.chainDepth <= 0) return { ok: false, reason: 'chain depth exhausted' };
47
+ if (countActive() >= MAX_ACTIVE_WATCHES) {
48
+ return { ok: false, reason: `active watch limit reached (${MAX_ACTIVE_WATCHES})` };
49
+ }
50
+
51
+ // Templating context for register-time resolution.
52
+ const regCtx = { args: ctx.args, result: ctx.result, settings: ctx.settings };
53
+
54
+ const pollArgs = (resolveDeep(spec.poll_args || {}, regCtx) as Record<string, unknown>) || {};
55
+
56
+ let doneMatch: { path: string; equals?: string; contains?: string } | null = null;
57
+ if (spec.done_match && spec.done_match.path) {
58
+ doneMatch = {
59
+ path: spec.done_match.path,
60
+ ...(spec.done_match.equals != null ? { equals: resolveTemplate(String(spec.done_match.equals), regCtx) } : {}),
61
+ ...(spec.done_match.contains != null ? { contains: resolveTemplate(String(spec.done_match.contains), regCtx) } : {}),
62
+ };
63
+ }
64
+
65
+ const interval = Math.max(MIN_INTERVAL_SEC, num(spec.interval_sec, DEFAULT_INTERVAL_SEC));
66
+ const timeout = num(spec.timeout_sec, DEFAULT_TIMEOUT_SEC);
67
+ const maxPolls = num(spec.max_polls, DEFAULT_MAX_POLLS);
68
+
69
+ // Label: connector.tool + a hint (host/lab/ip) pulled from args if present.
70
+ const hint = ['host', 'lab', 'ip', 'name'].map((k) => ctx.args[k]).find((v) => typeof v === 'string' && v);
71
+ const label = `${ctx.connectorId}.${ctx.toolName}${hint ? ` ${hint}` : ''}`;
72
+
73
+ // Pre-resolve {args.*}/{result.*}/{settings.*} in messages/action-args NOW
74
+ // (they're fixed at register time). {poll.*}/{poll_count}/{max_polls} are
75
+ // left literal for the runner to fill per poll — resolveTemplate keeps
76
+ // tokens whose namespace isn't in the context, so the two passes compose.
77
+ const preAction = (a: WatchAction | undefined): WatchAction | null => {
78
+ if (!a) return null;
79
+ return {
80
+ ...a,
81
+ ...(a.message ? { message: resolveTemplate(a.message, regCtx) } : {}),
82
+ ...(a.args ? { args: resolveDeep(a.args, regCtx) as Record<string, unknown> } : {}),
83
+ };
84
+ };
85
+ const preProgress = spec.progress
86
+ ? { ...spec.progress, ...(spec.progress.message ? { message: resolveTemplate(spec.progress.message, regCtx) } : {}) }
87
+ : null;
88
+
89
+ const w = createWatch({
90
+ session_id: ctx.sessionId,
91
+ label,
92
+ connector_id: ctx.connectorId,
93
+ poll_tool: spec.poll,
94
+ poll_args: pollArgs,
95
+ done_path: spec.done_path ?? null,
96
+ done_match: doneMatch,
97
+ fail_path: spec.fail_path ?? null,
98
+ on_done: preAction(spec.on_done as WatchAction),
99
+ on_fail: preAction(spec.on_fail as WatchAction),
100
+ progress: preProgress,
101
+ interval_sec: interval,
102
+ timeout_sec: timeout,
103
+ max_polls: maxPolls,
104
+ chain_depth: ctx.chainDepth,
105
+ now: Date.now(),
106
+ });
107
+ return { ok: true, watch_id: w.id, label };
108
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `start_watch` — a builtin tool that lets the LLM register a background
3
+ * watch on the fly, instead of relying on a connector author's static
4
+ * `async` block. Use case: the model just triggered a long job (jenkins
5
+ * build, a test run) and, rather than polling in-conversation (burning
6
+ * tokens, getting stuck), it calls start_watch to have Forge poll a
7
+ * given tool until a done/fail condition, then report back in chat.
8
+ *
9
+ * This is the dynamic counterpart to manifest `async` blocks — same
10
+ * watch backend (store + runner + chip), just driven by the model. The
11
+ * handler closes over the originating session id so completion routes to
12
+ * the right conversation.
13
+ */
14
+
15
+ import type { BuiltinToolDef } from '../chat/tool-dispatcher';
16
+ import { createWatch, countActive } from './watch-store';
17
+ import { MAX_ACTIVE_WATCHES, DEFAULT_CHAIN_DEPTH } from './register';
18
+
19
+ const MIN_INTERVAL_SEC = 30;
20
+ const DEFAULT_INTERVAL_SEC = 60;
21
+ const DEFAULT_TIMEOUT_SEC = 1800;
22
+ const DEFAULT_MAX_POLLS = 40;
23
+
24
+ export interface StartWatchTool {
25
+ def: BuiltinToolDef;
26
+ handle: (input: unknown) => Promise<string>;
27
+ }
28
+
29
+ function num(v: unknown, dflt: number): number {
30
+ const n = Number(v);
31
+ return Number.isFinite(n) && n > 0 ? n : dflt;
32
+ }
33
+
34
+ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
35
+ const def: BuiltinToolDef = {
36
+ name: 'start_watch',
37
+ description:
38
+ 'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
39
+ 'Pick `poll` = the read tool that reports status (e.g. "jenkins.get_build") and `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). Tune `interval_sec`/`timeout_sec` to the job (build ≈ 60s / 1800s).',
40
+ input_schema: {
41
+ type: 'object',
42
+ properties: {
43
+ poll: { type: 'string', description: 'Tool to poll, "<connector>.<tool>" e.g. "jenkins.get_build". Must be a read/status tool.' },
44
+ poll_args: { type: 'object', description: 'Args to call the poll tool with each tick, e.g. {"job_path":"job/foo","build_number":18}. Concrete values, not templates.' },
45
+ done_match: {
46
+ type: 'object',
47
+ description: 'Done when poll-result <path> equals/contains this. e.g. {"path":"result","equals":"SUCCESS"}.',
48
+ properties: {
49
+ path: { type: 'string' },
50
+ equals: { type: 'string' },
51
+ contains: { type: 'string' },
52
+ },
53
+ },
54
+ done_path: { type: 'string', description: 'Alternative to done_match: done when this poll-result path is truthy.' },
55
+ fail_path: { type: 'string', description: 'Optional: poll-result path that, when truthy, means failed.' },
56
+ message: { type: 'string', description: 'Message posted to chat on completion. May reference {poll.<path>}, e.g. "Build 18 finished: {poll.result}".' },
57
+ fail_message: { type: 'string', description: 'Optional message on failure/timeout.' },
58
+ progress_message: { type: 'string', description: 'Optional per-poll status chip text (ambient, not a chat message). e.g. "Build 18 — {poll.result}".' },
59
+ interval_sec: { type: 'number', description: 'Seconds between polls (default 60, min 30).' },
60
+ timeout_sec: { type: 'number', description: 'Overall deadline in seconds (default 1800).' },
61
+ max_polls: { type: 'number', description: 'Hard cap on poll count (default 40).' },
62
+ },
63
+ required: ['poll'],
64
+ },
65
+ };
66
+
67
+ const handle = async (input: unknown): Promise<string> => {
68
+ const a = (input ?? {}) as Record<string, any>;
69
+ const poll = String(a.poll || '').trim();
70
+ const dot = poll.indexOf('.');
71
+ if (dot < 1) return JSON.stringify({ ok: false, error: 'poll must be "<connector>.<tool>", e.g. jenkins.get_build' });
72
+ const connectorId = poll.slice(0, dot);
73
+ const pollTool = poll.slice(dot + 1);
74
+
75
+ const doneMatch = a.done_match && typeof a.done_match === 'object' && a.done_match.path
76
+ ? { path: String(a.done_match.path), ...(a.done_match.equals != null ? { equals: String(a.done_match.equals) } : {}), ...(a.done_match.contains != null ? { contains: String(a.done_match.contains) } : {}) }
77
+ : null;
78
+ const donePath = typeof a.done_path === 'string' && a.done_path ? a.done_path : null;
79
+ if (!doneMatch && !donePath) {
80
+ return JSON.stringify({ ok: false, error: 'give a done condition: done_match {path,equals} or done_path' });
81
+ }
82
+ if (countActive() >= MAX_ACTIVE_WATCHES) {
83
+ return JSON.stringify({ ok: false, error: `active watch limit reached (${MAX_ACTIVE_WATCHES})` });
84
+ }
85
+
86
+ const hint = ['build_number', 'host', 'ip', 'lab', 'id', 'name'].map((k) => a.poll_args?.[k]).find((v) => v != null && v !== '');
87
+ const label = `${connectorId}.${pollTool}${hint != null ? ` ${hint}` : ''}`;
88
+
89
+ const w = createWatch({
90
+ session_id: sessionId,
91
+ label,
92
+ connector_id: connectorId,
93
+ poll_tool: pollTool,
94
+ poll_args: (a.poll_args && typeof a.poll_args === 'object') ? a.poll_args : {},
95
+ done_path: donePath,
96
+ done_match: doneMatch,
97
+ fail_path: typeof a.fail_path === 'string' && a.fail_path ? a.fail_path : null,
98
+ on_done: { mode: 'chat', message: String(a.message || `${label}: done.`) },
99
+ on_fail: { mode: 'chat', message: String(a.fail_message || `${label}: did not complete in time — please check.`) },
100
+ progress: { show: true, ...(a.progress_message ? { message: String(a.progress_message) } : {}) },
101
+ interval_sec: Math.max(MIN_INTERVAL_SEC, num(a.interval_sec, DEFAULT_INTERVAL_SEC)),
102
+ timeout_sec: num(a.timeout_sec, DEFAULT_TIMEOUT_SEC),
103
+ max_polls: num(a.max_polls, DEFAULT_MAX_POLLS),
104
+ chain_depth: DEFAULT_CHAIN_DEPTH,
105
+ now: Date.now(),
106
+ });
107
+ return JSON.stringify({
108
+ ok: true,
109
+ watch_id: w.id,
110
+ polling: poll,
111
+ note: 'Background watch registered. STOP polling in conversation — a completion message will arrive in this chat. The user can see/cancel it in the watch list.',
112
+ });
113
+ };
114
+
115
+ return { def, handle };
116
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tiny namespaced template resolver for watch specs. Replaces tokens
3
+ * like {args.x}, {result.fired_at}, {poll.build}, {settings.host} from a
4
+ * context of namespaces. Dot paths supported. Unknown tokens are left
5
+ * literal (so a typo is visible rather than silently empty). No eval.
6
+ */
7
+
8
+ export function getPath(obj: unknown, path: string): unknown {
9
+ let v: any = obj;
10
+ for (const p of path.split('.')) {
11
+ if (v == null || typeof v !== 'object') return undefined;
12
+ v = v[p];
13
+ }
14
+ return v;
15
+ }
16
+
17
+ export function resolveTemplate(str: string, ctx: Record<string, unknown>): string {
18
+ return str.replace(/\{([^{}]+)\}/g, (full, raw) => {
19
+ const key = String(raw).trim();
20
+ const dot = key.indexOf('.');
21
+ const ns = dot < 0 ? key : key.slice(0, dot);
22
+ const rest = dot < 0 ? '' : key.slice(dot + 1);
23
+ if (!(ns in ctx)) return full;
24
+ const v = rest ? getPath(ctx[ns], rest) : ctx[ns];
25
+ if (v == null) return full;
26
+ return typeof v === 'object' ? JSON.stringify(v) : String(v);
27
+ });
28
+ }
29
+
30
+ /** Resolve templates throughout a value (strings, arrays, plain objects). */
31
+ export function resolveDeep(val: unknown, ctx: Record<string, unknown>): unknown {
32
+ if (typeof val === 'string') return resolveTemplate(val, ctx);
33
+ if (Array.isArray(val)) return val.map((v) => resolveDeep(v, ctx));
34
+ if (val && typeof val === 'object') {
35
+ const out: Record<string, unknown> = {};
36
+ for (const [k, v] of Object.entries(val)) out[k] = resolveDeep(v, ctx);
37
+ return out;
38
+ }
39
+ return val;
40
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Watch runner — the background ticker that drives long-task watches.
3
+ *
4
+ * Runs inside chat-standalone (single instance, guarded). Each tick it
5
+ * polls every due active watch (single-flight, bounded concurrency),
6
+ * evaluates done/fail/timeout, and on a terminal state runs the watch's
7
+ * action: feed the result back into the originating chat session
8
+ * (mode=chat, via the runChat callback), chain a tool (mode=tool), or
9
+ * just record it (mode=none). Per-poll it emits ambient progress through
10
+ * onProgress — that lands as a status chip, NOT a chat message.
11
+ *
12
+ * Guards (see design doc §3): max_polls, timeout, max_lifetime, single-
13
+ * flight, consecutive-error cutoff, chain-depth on tool callbacks.
14
+ */
15
+
16
+ import { dispatchTool } from '../chat/tool-dispatcher';
17
+ import { resolveTemplate, resolveDeep, getPath } from './template';
18
+ import {
19
+ listDue, updateWatch, getWatch, type Watch, type WatchState,
20
+ } from './watch-store';
21
+
22
+ const TICK_MS = 20_000; // scan cadence; each watch paces itself via next_poll_at
23
+ const MAX_CONCURRENT = 8; // simultaneous in-flight polls
24
+ const MAX_CONSEC_ERRORS = 10; // device unreachable (e.g. mid-reboot) tolerated this many polls
25
+
26
+ export interface WatchRunnerHooks {
27
+ /** Emit ambient progress (status chip) for a watch's session. */
28
+ onProgress?: (sessionId: string, payload: Record<string, unknown>) => void;
29
+ /** Feed a completion message back into a chat session (assistant replies). */
30
+ runChat?: (sessionId: string, text: string) => void;
31
+ }
32
+
33
+ function truthy(v: unknown): boolean {
34
+ if (v == null || v === false || v === 0) return false;
35
+ if (typeof v === 'string') { const s = v.trim().toLowerCase(); return s !== '' && s !== 'false' && s !== '0' && s !== 'null'; }
36
+ return true;
37
+ }
38
+
39
+ function parseResult(content: string): any {
40
+ try { return JSON.parse(content); } catch { return { _raw: content }; }
41
+ }
42
+
43
+ const g = globalThis as any;
44
+
45
+ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
46
+ if (g.__forgeWatchRunner) return; // single instance per process
47
+ const running = new Set<string>(); // watch ids currently polling (single-flight)
48
+
49
+ const finish = (w: Watch, state: WatchState, obj: unknown, summary: string) => {
50
+ const now = Date.now();
51
+ updateWatch(w.id, { state, last_result: obj, last_text: summary }, now);
52
+ // Terminal watch_status — tells the UI to drop the progress chip
53
+ // immediately (otherwise it lingers until the 150s prune). The real
54
+ // completion text goes via on_done below; this is just the chip kill.
55
+ if (hooks.onProgress && w.session_id) {
56
+ hooks.onProgress(w.session_id, { watch_id: w.id, state, done: true, text: summary });
57
+ }
58
+ const action = state === 'done' ? w.on_done : w.on_fail;
59
+ const mode = action?.mode || 'chat';
60
+ if (mode === 'none' || !action) return;
61
+ const ctx = { poll: obj };
62
+ if (mode === 'chat') {
63
+ if (!w.session_id || !hooks.runChat) return;
64
+ const msg = action.message ? resolveTemplate(action.message, ctx) : summary;
65
+ const tag = state === 'done' ? '✅' : '⚠️';
66
+ hooks.runChat(w.session_id, `[background watch ${w.label}] ${tag} ${msg}`);
67
+ } else if (mode === 'tool' && action.tool) {
68
+ const input = (resolveDeep(action.args || {}, ctx) as Record<string, unknown>) || {};
69
+ void dispatchTool(
70
+ { id: `watch-chain-${w.id}`, name: action.tool, input },
71
+ { sessionId: w.session_id || undefined, chainDepth: Math.max(0, w.chain_depth - 1) } as any,
72
+ ).catch((e) => console.error('[watch] on_done tool chain failed', w.id, e));
73
+ }
74
+ };
75
+
76
+ const emitProgress = (w: Watch, obj: unknown, pollCount: number) => {
77
+ if (!w.session_id || !hooks.onProgress) return;
78
+ if (w.progress && w.progress.show === false) return;
79
+ const tmpl = w.progress?.message || 'Watching {label} … poll {poll_count}/{max_polls}';
80
+ const text = resolveTemplate(tmpl, { poll: obj, poll_count: pollCount, max_polls: w.max_polls, label: w.label });
81
+ hooks.onProgress(w.session_id, { watch_id: w.id, state: 'active', poll_count: pollCount, text });
82
+ };
83
+
84
+ const pollOne = async (w: Watch): Promise<void> => {
85
+ const now = Date.now();
86
+ // Hard lifetime backstop.
87
+ if (now - w.created_at > 2 * w.timeout_sec * 1000) {
88
+ return finish(w, 'timed_out', w.last_result, `${w.label}: watch lifetime exceeded — please verify manually.`);
89
+ }
90
+ let res;
91
+ try {
92
+ res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: `${w.connector_id}.${w.poll_tool}`, input: w.poll_args }, { noTruncation: false } as any);
93
+ } catch (e) {
94
+ res = { content: String(e), is_error: true };
95
+ }
96
+
97
+ if (res.is_error) {
98
+ // Poll failed (often expected: device rebooting). Tolerate up to N
99
+ // consecutive, then give up.
100
+ const errs = w.err_count + 1;
101
+ if (errs >= MAX_CONSEC_ERRORS) {
102
+ return finish(w, 'errored', { error: res.content }, `${w.label}: ${MAX_CONSEC_ERRORS} consecutive poll errors — giving up. Last: ${String(res.content).slice(0, 200)}`);
103
+ }
104
+ updateWatch(w.id, { err_count: errs, next_poll_at: now + w.interval_sec * 1000 }, now);
105
+ emitProgress(w, { _error: String(res.content).slice(0, 120) }, w.polls);
106
+ return;
107
+ }
108
+
109
+ const obj = parseResult(res.content);
110
+ const polls = w.polls + 1;
111
+
112
+ // fail check
113
+ if (w.fail_path && truthy(getPath(obj, w.fail_path))) {
114
+ return finish(w, 'failed', obj, `${w.label}: failure condition met.`);
115
+ }
116
+ // done check
117
+ let done = false;
118
+ if (w.done_match) {
119
+ const v = getPath(obj, w.done_match.path);
120
+ if (w.done_match.equals != null) done = String(v) === String(w.done_match.equals);
121
+ else if (w.done_match.contains != null) done = String(v ?? '').toLowerCase().includes(String(w.done_match.contains).toLowerCase());
122
+ } else if (w.done_path) {
123
+ done = truthy(getPath(obj, w.done_path));
124
+ }
125
+ if (done) {
126
+ return finish(w, 'done', obj, `${w.label}: done.`);
127
+ }
128
+ // not done — bound by polls / timeout, else reschedule
129
+ if (polls >= w.max_polls || now - w.created_at > w.timeout_sec * 1000) {
130
+ return finish(w, 'timed_out', obj, `${w.label}: not done within ${w.max_polls} polls / ${w.timeout_sec}s — please verify manually.`);
131
+ }
132
+ updateWatch(w.id, { polls, err_count: 0, next_poll_at: now + w.interval_sec * 1000 }, now);
133
+ emitProgress(w, obj, polls);
134
+ };
135
+
136
+ const tick = async () => {
137
+ try {
138
+ const slots = MAX_CONCURRENT - running.size;
139
+ if (slots <= 0) return;
140
+ const due = listDue(Date.now()).filter((w) => !running.has(w.id)).slice(0, slots);
141
+ for (const w of due) {
142
+ running.add(w.id);
143
+ // Re-read inside to avoid acting on a row cancelled since listDue.
144
+ void Promise.resolve()
145
+ .then(() => { const cur = getWatch(w.id); return cur && cur.state === 'active' ? pollOne(cur) : undefined; })
146
+ .catch((e) => console.error('[watch] poll error', w.id, e))
147
+ .finally(() => running.delete(w.id));
148
+ }
149
+ } catch (e) {
150
+ console.error('[watch] tick error', e);
151
+ }
152
+ };
153
+
154
+ const timer = setInterval(() => { void tick(); }, TICK_MS);
155
+ if (typeof timer.unref === 'function') timer.unref();
156
+ g.__forgeWatchRunner = timer;
157
+ console.log('[watch] runner started (tick ' + TICK_MS / 1000 + 's)');
158
+ }