@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.
- package/RELEASE_NOTES.md +22 -4
- package/app/api/connectors/route.ts +1 -1
- package/app/api/watches/[id]/route.ts +25 -0
- package/app/api/watches/route.ts +17 -0
- package/app/chat/page.tsx +66 -4
- package/components/Dashboard.tsx +21 -5
- package/components/MonitorPanel.tsx +88 -0
- package/components/WatchesPanel.tsx +97 -0
- package/docs/forge-long-task-watch-design.md +223 -0
- package/docs/tp-automation-api.md +617 -0
- package/lib/browser-bridge-standalone.ts +13 -4
- package/lib/chat/agent-loop.ts +34 -4
- package/lib/chat/bridge-client.ts +2 -2
- package/lib/chat/protocols/ssh.ts +206 -0
- package/lib/chat/tool-dispatcher.ts +60 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +118 -2
- package/lib/help-docs/21-build-connector.md +42 -0
- package/lib/help-docs/24-watch.md +77 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/watch/register.ts +108 -0
- package/lib/watch/start-watch-tool.ts +116 -0
- package/lib/watch/template.ts +40 -0
- package/lib/watch/watch-runner.ts +158 -0
- package/lib/watch/watch-store.ts +218 -0
- package/package.json +1 -1
|
@@ -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.
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -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
|
+
}
|