@aion0/forge 0.10.22 → 0.10.25
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 +7 -4
- 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/SettingsModal.tsx +0 -6
- package/components/WatchesPanel.tsx +97 -0
- package/docs/forge-long-task-watch-design.md +15 -2
- package/docs/tp-automation-api-v2.md +482 -0
- package/lib/chat/agent-loop.ts +32 -2
- package/lib/chat/tool-dispatcher.ts +45 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +56 -0
- 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/pipeline.ts +2 -2
- package/lib/settings.ts +17 -6
- package/lib/task-manager.ts +4 -4
- package/lib/telegram-bot.ts +3 -3
- 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 +176 -0
- package/lib/watch/watch-store.ts +218 -0
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
// noTruncation=true so http-protocol polls skip the "HTTP 200 OK · GET …"
|
|
93
|
+
// preamble — the body comes back as raw JSON so done_path/done_match can
|
|
94
|
+
// see it. (Without this, every http-protocol watch would silently never
|
|
95
|
+
// hit its done condition, e.g. jenkins.get_build never resolving.)
|
|
96
|
+
res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: `${w.connector_id}.${w.poll_tool}`, input: w.poll_args }, { noTruncation: true } as any);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
res = { content: String(e), is_error: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (res.is_error) {
|
|
102
|
+
// Poll failed (often expected: device rebooting). Tolerate up to N
|
|
103
|
+
// consecutive, then give up.
|
|
104
|
+
const errs = w.err_count + 1;
|
|
105
|
+
if (errs >= MAX_CONSEC_ERRORS) {
|
|
106
|
+
return finish(w, 'errored', { error: res.content }, `${w.label}: ${MAX_CONSEC_ERRORS} consecutive poll errors — giving up. Last: ${String(res.content).slice(0, 200)}`);
|
|
107
|
+
}
|
|
108
|
+
updateWatch(w.id, { err_count: errs, next_poll_at: now + w.interval_sec * 1000 }, now);
|
|
109
|
+
emitProgress(w, { _error: String(res.content).slice(0, 120) }, w.polls);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const obj = parseResult(res.content);
|
|
114
|
+
const polls = w.polls + 1;
|
|
115
|
+
|
|
116
|
+
// fail check
|
|
117
|
+
if (w.fail_path && truthy(getPath(obj, w.fail_path))) {
|
|
118
|
+
return finish(w, 'failed', obj, `${w.label}: failure condition met.`);
|
|
119
|
+
}
|
|
120
|
+
// done check
|
|
121
|
+
let done = false;
|
|
122
|
+
if (w.done_match) {
|
|
123
|
+
const v = getPath(obj, w.done_match.path);
|
|
124
|
+
if (w.done_match.equals != null) done = String(v) === String(w.done_match.equals);
|
|
125
|
+
else if (w.done_match.contains != null) done = String(v ?? '').toLowerCase().includes(String(w.done_match.contains).toLowerCase());
|
|
126
|
+
} else if (w.done_path) {
|
|
127
|
+
done = truthy(getPath(obj, w.done_path));
|
|
128
|
+
}
|
|
129
|
+
if (done) {
|
|
130
|
+
return finish(w, 'done', obj, `${w.label}: done.`);
|
|
131
|
+
}
|
|
132
|
+
// not done — bound by polls / timeout, else reschedule
|
|
133
|
+
if (polls >= w.max_polls || now - w.created_at > w.timeout_sec * 1000) {
|
|
134
|
+
return finish(w, 'timed_out', obj, `${w.label}: not done within ${w.max_polls} polls / ${w.timeout_sec}s — please verify manually.`);
|
|
135
|
+
}
|
|
136
|
+
// Persist the latest poll result + a tiny preview text on EVERY poll
|
|
137
|
+
// (not just terminal) so the Monitor / DB shows what the watch is
|
|
138
|
+
// actually seeing — crucial for diagnosing "polling forever, no done":
|
|
139
|
+
// usually means the done condition refers to a field the poll's
|
|
140
|
+
// result doesn't actually have.
|
|
141
|
+
const previewKeys = obj && typeof obj === 'object' && !Array.isArray(obj)
|
|
142
|
+
? Object.keys(obj as Record<string, unknown>).slice(0, 8).join(', ')
|
|
143
|
+
: typeof obj;
|
|
144
|
+
updateWatch(w.id, {
|
|
145
|
+
polls,
|
|
146
|
+
err_count: 0,
|
|
147
|
+
next_poll_at: now + w.interval_sec * 1000,
|
|
148
|
+
last_result: obj,
|
|
149
|
+
last_text: `poll ${polls}/${w.max_polls} · keys: ${previewKeys}`,
|
|
150
|
+
}, now);
|
|
151
|
+
emitProgress(w, obj, polls);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const tick = async () => {
|
|
155
|
+
try {
|
|
156
|
+
const slots = MAX_CONCURRENT - running.size;
|
|
157
|
+
if (slots <= 0) return;
|
|
158
|
+
const due = listDue(Date.now()).filter((w) => !running.has(w.id)).slice(0, slots);
|
|
159
|
+
for (const w of due) {
|
|
160
|
+
running.add(w.id);
|
|
161
|
+
// Re-read inside to avoid acting on a row cancelled since listDue.
|
|
162
|
+
void Promise.resolve()
|
|
163
|
+
.then(() => { const cur = getWatch(w.id); return cur && cur.state === 'active' ? pollOne(cur) : undefined; })
|
|
164
|
+
.catch((e) => console.error('[watch] poll error', w.id, e))
|
|
165
|
+
.finally(() => running.delete(w.id));
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error('[watch] tick error', e);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const timer = setInterval(() => { void tick(); }, TICK_MS);
|
|
173
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
174
|
+
g.__forgeWatchRunner = timer;
|
|
175
|
+
console.log('[watch] runner started (tick ' + TICK_MS / 1000 + 's)');
|
|
176
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch store — sqlite-backed persistence for background "watches".
|
|
3
|
+
*
|
|
4
|
+
* A watch is a long-task poller: a tool flagged `async` registers one,
|
|
5
|
+
* the watch-runner polls it until a terminal state, then feeds the
|
|
6
|
+
* result back into the originating chat session. Persisted (not in
|
|
7
|
+
* memory) so a Forge restart resumes in-flight watches. Lives in
|
|
8
|
+
* <data>/workflow.db alongside sessions.
|
|
9
|
+
*
|
|
10
|
+
* Concurrency: the runner (chat-standalone) is the primary writer;
|
|
11
|
+
* the /api/watches routes (next-server) also write (cancel/delete).
|
|
12
|
+
* better-sqlite3 + WAL serialises writers via the file lock — fine at
|
|
13
|
+
* watch frequencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { getDb } from '../../src/core/db/database';
|
|
19
|
+
import { getDataDir } from '../dirs';
|
|
20
|
+
|
|
21
|
+
export type WatchState =
|
|
22
|
+
| 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
|
|
23
|
+
|
|
24
|
+
export interface WatchAction {
|
|
25
|
+
mode?: 'chat' | 'tool' | 'none';
|
|
26
|
+
message?: string;
|
|
27
|
+
tool?: string;
|
|
28
|
+
args?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Watch {
|
|
32
|
+
id: string;
|
|
33
|
+
session_id: string | null;
|
|
34
|
+
label: string;
|
|
35
|
+
connector_id: string;
|
|
36
|
+
poll_tool: string; // bare tool name (poll), dispatched as `${connector_id}.${poll_tool}`
|
|
37
|
+
poll_args: Record<string, unknown>;
|
|
38
|
+
done_path: string | null;
|
|
39
|
+
done_match: { path: string; equals?: string; contains?: string } | null;
|
|
40
|
+
fail_path: string | null;
|
|
41
|
+
on_done: WatchAction | null;
|
|
42
|
+
on_fail: WatchAction | null;
|
|
43
|
+
progress: { show?: boolean; message?: string } | null;
|
|
44
|
+
interval_sec: number;
|
|
45
|
+
timeout_sec: number;
|
|
46
|
+
max_polls: number;
|
|
47
|
+
chain_depth: number;
|
|
48
|
+
state: WatchState;
|
|
49
|
+
polls: number;
|
|
50
|
+
err_count: number;
|
|
51
|
+
created_at: number;
|
|
52
|
+
next_poll_at: number;
|
|
53
|
+
updated_at: number;
|
|
54
|
+
last_text: string | null;
|
|
55
|
+
last_result: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Row {
|
|
59
|
+
id: string; session_id: string | null; label: string; connector_id: string;
|
|
60
|
+
poll_tool: string; poll_args_json: string;
|
|
61
|
+
done_path: string | null; done_match_json: string | null; fail_path: string | null;
|
|
62
|
+
on_done_json: string | null; on_fail_json: string | null; progress_json: string | null;
|
|
63
|
+
interval_sec: number; timeout_sec: number; max_polls: number; chain_depth: number;
|
|
64
|
+
state: string; polls: number; err_count: number;
|
|
65
|
+
created_at: number; next_poll_at: number; updated_at: number;
|
|
66
|
+
last_text: string | null; last_result_json: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let ready = false;
|
|
70
|
+
function db() {
|
|
71
|
+
const d = getDb(join(getDataDir(), 'workflow.db'));
|
|
72
|
+
if (!ready) {
|
|
73
|
+
d.exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS connector_watches (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
session_id TEXT,
|
|
77
|
+
label TEXT NOT NULL DEFAULT '',
|
|
78
|
+
connector_id TEXT NOT NULL,
|
|
79
|
+
poll_tool TEXT NOT NULL,
|
|
80
|
+
poll_args_json TEXT NOT NULL DEFAULT '{}',
|
|
81
|
+
done_path TEXT,
|
|
82
|
+
done_match_json TEXT,
|
|
83
|
+
fail_path TEXT,
|
|
84
|
+
on_done_json TEXT,
|
|
85
|
+
on_fail_json TEXT,
|
|
86
|
+
progress_json TEXT,
|
|
87
|
+
interval_sec INTEGER NOT NULL DEFAULT 60,
|
|
88
|
+
timeout_sec INTEGER NOT NULL DEFAULT 1200,
|
|
89
|
+
max_polls INTEGER NOT NULL DEFAULT 40,
|
|
90
|
+
chain_depth INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
92
|
+
polls INTEGER NOT NULL DEFAULT 0,
|
|
93
|
+
err_count INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
created_at INTEGER NOT NULL,
|
|
95
|
+
next_poll_at INTEGER NOT NULL,
|
|
96
|
+
updated_at INTEGER NOT NULL,
|
|
97
|
+
last_text TEXT,
|
|
98
|
+
last_result_json TEXT
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_watches_state ON connector_watches(state, next_poll_at);
|
|
101
|
+
`);
|
|
102
|
+
ready = true;
|
|
103
|
+
}
|
|
104
|
+
return d;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rowToWatch(r: Row): Watch {
|
|
108
|
+
const j = <T,>(s: string | null, d: T): T => { if (s == null) return d; try { return JSON.parse(s) as T; } catch { return d; } };
|
|
109
|
+
return {
|
|
110
|
+
id: r.id, session_id: r.session_id, label: r.label, connector_id: r.connector_id,
|
|
111
|
+
poll_tool: r.poll_tool, poll_args: j(r.poll_args_json, {}),
|
|
112
|
+
done_path: r.done_path, done_match: j(r.done_match_json, null), fail_path: r.fail_path,
|
|
113
|
+
on_done: j(r.on_done_json, null), on_fail: j(r.on_fail_json, null), progress: j(r.progress_json, null),
|
|
114
|
+
interval_sec: r.interval_sec, timeout_sec: r.timeout_sec, max_polls: r.max_polls, chain_depth: r.chain_depth,
|
|
115
|
+
state: r.state as WatchState, polls: r.polls, err_count: r.err_count,
|
|
116
|
+
created_at: r.created_at, next_poll_at: r.next_poll_at, updated_at: r.updated_at,
|
|
117
|
+
last_text: r.last_text, last_result: j(r.last_result_json, null),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface NewWatch {
|
|
122
|
+
session_id: string | null;
|
|
123
|
+
label: string;
|
|
124
|
+
connector_id: string;
|
|
125
|
+
poll_tool: string;
|
|
126
|
+
poll_args: Record<string, unknown>;
|
|
127
|
+
done_path?: string | null;
|
|
128
|
+
done_match?: Watch['done_match'];
|
|
129
|
+
fail_path?: string | null;
|
|
130
|
+
on_done?: WatchAction | null;
|
|
131
|
+
on_fail?: WatchAction | null;
|
|
132
|
+
progress?: Watch['progress'];
|
|
133
|
+
interval_sec: number;
|
|
134
|
+
timeout_sec: number;
|
|
135
|
+
max_polls: number;
|
|
136
|
+
chain_depth: number;
|
|
137
|
+
now: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createWatch(w: NewWatch): Watch {
|
|
141
|
+
const id = 'w_' + randomUUID().slice(0, 12);
|
|
142
|
+
const next = w.now + w.interval_sec * 1000;
|
|
143
|
+
db().prepare(`
|
|
144
|
+
INSERT INTO connector_watches
|
|
145
|
+
(id, session_id, label, connector_id, poll_tool, poll_args_json,
|
|
146
|
+
done_path, done_match_json, fail_path, on_done_json, on_fail_json, progress_json,
|
|
147
|
+
interval_sec, timeout_sec, max_polls, chain_depth,
|
|
148
|
+
state, polls, err_count, created_at, next_poll_at, updated_at, last_text, last_result_json)
|
|
149
|
+
VALUES (@id,@session_id,@label,@connector_id,@poll_tool,@poll_args_json,
|
|
150
|
+
@done_path,@done_match_json,@fail_path,@on_done_json,@on_fail_json,@progress_json,
|
|
151
|
+
@interval_sec,@timeout_sec,@max_polls,@chain_depth,
|
|
152
|
+
'active',0,0,@created_at,@next_poll_at,@updated_at,NULL,NULL)
|
|
153
|
+
`).run({
|
|
154
|
+
id, session_id: w.session_id, label: w.label, connector_id: w.connector_id,
|
|
155
|
+
poll_tool: w.poll_tool, poll_args_json: JSON.stringify(w.poll_args || {}),
|
|
156
|
+
done_path: w.done_path ?? null, done_match_json: w.done_match ? JSON.stringify(w.done_match) : null,
|
|
157
|
+
fail_path: w.fail_path ?? null,
|
|
158
|
+
on_done_json: w.on_done ? JSON.stringify(w.on_done) : null,
|
|
159
|
+
on_fail_json: w.on_fail ? JSON.stringify(w.on_fail) : null,
|
|
160
|
+
progress_json: w.progress ? JSON.stringify(w.progress) : null,
|
|
161
|
+
interval_sec: w.interval_sec, timeout_sec: w.timeout_sec, max_polls: w.max_polls, chain_depth: w.chain_depth,
|
|
162
|
+
created_at: w.now, next_poll_at: next, updated_at: w.now,
|
|
163
|
+
});
|
|
164
|
+
return getWatch(id)!;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getWatch(id: string): Watch | null {
|
|
168
|
+
const r = db().prepare(`SELECT * FROM connector_watches WHERE id = ?`).get(id) as Row | undefined;
|
|
169
|
+
return r ? rowToWatch(r) : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function countActive(): number {
|
|
173
|
+
const r = db().prepare(`SELECT COUNT(*) AS n FROM connector_watches WHERE state = 'active'`).get() as { n: number };
|
|
174
|
+
return r.n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Active watches whose next poll is due (<= now). */
|
|
178
|
+
export function listDue(now: number): Watch[] {
|
|
179
|
+
const rows = db().prepare(
|
|
180
|
+
`SELECT * FROM connector_watches WHERE state = 'active' AND next_poll_at <= ? ORDER BY next_poll_at ASC`,
|
|
181
|
+
).all(now) as Row[];
|
|
182
|
+
return rows.map(rowToWatch);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** All watches (active first, then recent terminal), for the management UI. */
|
|
186
|
+
export function listWatches(limit = 50): Watch[] {
|
|
187
|
+
const rows = db().prepare(
|
|
188
|
+
`SELECT * FROM connector_watches ORDER BY (state='active') DESC, updated_at DESC LIMIT ?`,
|
|
189
|
+
).all(limit) as Row[];
|
|
190
|
+
return rows.map(rowToWatch);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function updateWatch(id: string, patch: Partial<{
|
|
194
|
+
state: WatchState; polls: number; err_count: number; next_poll_at: number;
|
|
195
|
+
last_text: string | null; last_result: unknown;
|
|
196
|
+
}>, now: number): void {
|
|
197
|
+
const sets: string[] = ['updated_at = @updated_at'];
|
|
198
|
+
const params: Record<string, unknown> = { id, updated_at: now };
|
|
199
|
+
if (patch.state !== undefined) { sets.push('state = @state'); params.state = patch.state; }
|
|
200
|
+
if (patch.polls !== undefined) { sets.push('polls = @polls'); params.polls = patch.polls; }
|
|
201
|
+
if (patch.err_count !== undefined) { sets.push('err_count = @err_count'); params.err_count = patch.err_count; }
|
|
202
|
+
if (patch.next_poll_at !== undefined) { sets.push('next_poll_at = @next_poll_at'); params.next_poll_at = patch.next_poll_at; }
|
|
203
|
+
if (patch.last_text !== undefined) { sets.push('last_text = @last_text'); params.last_text = patch.last_text; }
|
|
204
|
+
if (patch.last_result !== undefined) { sets.push('last_result_json = @last_result_json'); params.last_result_json = patch.last_result == null ? null : JSON.stringify(patch.last_result); }
|
|
205
|
+
db().prepare(`UPDATE connector_watches SET ${sets.join(', ')} WHERE id = @id`).run(params);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Cancel an active watch (terminal). Returns false if not found/active. */
|
|
209
|
+
export function cancelWatch(id: string, now: number): boolean {
|
|
210
|
+
const r = db().prepare(
|
|
211
|
+
`UPDATE connector_watches SET state='cancelled', updated_at=? WHERE id=? AND state='active'`,
|
|
212
|
+
).run(now, id);
|
|
213
|
+
return r.changes > 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function deleteWatch(id: string): boolean {
|
|
217
|
+
return db().prepare(`DELETE FROM connector_watches WHERE id = ?`).run(id).changes > 0;
|
|
218
|
+
}
|
package/package.json
CHANGED