@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,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.20",
3
+ "version": "0.10.23",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {