@getfamiliar/shared 0.1.2
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/build/AgentRun.d.ts +187 -0
- package/build/AgentRun.js +1 -0
- package/build/AgentRunBus.d.ts +147 -0
- package/build/AgentRunBus.js +362 -0
- package/build/Calendar.d.ts +450 -0
- package/build/Calendar.js +37 -0
- package/build/ChatMessage.d.ts +53 -0
- package/build/ChatMessage.js +11 -0
- package/build/ChatMessageBus.d.ts +110 -0
- package/build/ChatMessageBus.js +257 -0
- package/build/Config.d.ts +83 -0
- package/build/Config.js +1 -0
- package/build/ContainerTool.d.ts +34 -0
- package/build/ContainerTool.js +1 -0
- package/build/CronExpression.d.ts +53 -0
- package/build/CronExpression.js +77 -0
- package/build/Event.d.ts +280 -0
- package/build/Event.js +14 -0
- package/build/EventBus.d.ts +198 -0
- package/build/EventBus.js +482 -0
- package/build/HandlerCatalog.d.ts +75 -0
- package/build/HandlerCatalog.js +165 -0
- package/build/InferenceEvent.d.ts +30 -0
- package/build/InferenceEvent.js +1 -0
- package/build/InferenceEventBus.d.ts +39 -0
- package/build/InferenceEventBus.js +80 -0
- package/build/Mail.d.ts +250 -0
- package/build/Mail.js +73 -0
- package/build/MailStyleTemplate.d.ts +71 -0
- package/build/MailStyleTemplate.js +47 -0
- package/build/ModelMetaData.d.ts +63 -0
- package/build/ModelMetaData.js +12 -0
- package/build/PathGlob.d.ts +20 -0
- package/build/PathGlob.js +33 -0
- package/build/Plugin.d.ts +709 -0
- package/build/Plugin.js +29 -0
- package/build/PostgresConnection.d.ts +75 -0
- package/build/PostgresConnection.js +162 -0
- package/build/ScheduledSubagent.d.ts +49 -0
- package/build/ScheduledSubagent.js +1 -0
- package/build/ScheduledSubagentBus.d.ts +94 -0
- package/build/ScheduledSubagentBus.js +187 -0
- package/build/Schema.d.ts +84 -0
- package/build/Schema.js +570 -0
- package/build/StepResult.d.ts +103 -0
- package/build/StepResult.js +1 -0
- package/build/StepResultBus.d.ts +64 -0
- package/build/StepResultBus.js +169 -0
- package/build/Timezone.d.ts +58 -0
- package/build/Timezone.js +84 -0
- package/build/ToolCall.d.ts +25 -0
- package/build/ToolCall.js +1 -0
- package/build/ToolCallBus.d.ts +65 -0
- package/build/ToolCallBus.js +110 -0
- package/build/ToolLevel.d.ts +20 -0
- package/build/ToolLevel.js +2 -0
- package/build/ToolRunner.d.ts +118 -0
- package/build/ToolRunner.js +192 -0
- package/build/ToolsExpressionUtils.d.ts +80 -0
- package/build/ToolsExpressionUtils.js +102 -0
- package/build/WorkspaceFile.d.ts +104 -0
- package/build/WorkspaceFile.js +7 -0
- package/build/estimateTokens.d.ts +16 -0
- package/build/estimateTokens.js +18 -0
- package/build/index.d.ts +37 -0
- package/build/index.js +26 -0
- package/build/logging/Logger.d.ts +66 -0
- package/build/logging/Logger.js +90 -0
- package/build/markdownHtml.d.ts +14 -0
- package/build/markdownHtml.js +21 -0
- package/build/markdownTerminal.d.ts +14 -0
- package/build/markdownTerminal.js +376 -0
- package/build/testing/MockAgentRunBus.d.ts +52 -0
- package/build/testing/MockAgentRunBus.js +175 -0
- package/build/testing/MockBusStore.d.ts +22 -0
- package/build/testing/MockBusStore.js +22 -0
- package/build/testing/MockEventBus.d.ts +20 -0
- package/build/testing/MockEventBus.js +57 -0
- package/build/testing/MockLogger.d.ts +52 -0
- package/build/testing/MockLogger.js +65 -0
- package/build/testing/index.d.ts +4 -0
- package/build/testing/index.js +4 -0
- package/package.json +63 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { EVENTS_NEW_CHANNEL, SCHEMA_SQL } from "./Schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link EventBus.add} when an event's `idempotencyKey`
|
|
4
|
+
* collides with one already in the `events` table (Postgres unique
|
|
5
|
+
* violation `23505` on `events_idempotency_key_key`).
|
|
6
|
+
*
|
|
7
|
+
* The error is deliberately typed rather than swallowed: an emitter
|
|
8
|
+
* that re-derives the same key on a re-walk (the ms365 mail delta poll,
|
|
9
|
+
* the calendar poll, chat compaction, …) is expected to `catch` this
|
|
10
|
+
* and treat the collision as a no-op — the event already exists. Other
|
|
11
|
+
* emitters that should never collide let it propagate as a real fault.
|
|
12
|
+
*/
|
|
13
|
+
export class DuplicateIdempotencyKeyError extends Error {
|
|
14
|
+
/** The colliding idempotency key, for logging by the catching emitter. */
|
|
15
|
+
idempotencyKey;
|
|
16
|
+
/**
|
|
17
|
+
* @param idempotencyKey - The key that already existed in `events`.
|
|
18
|
+
*/
|
|
19
|
+
constructor(idempotencyKey) {
|
|
20
|
+
super(`event with idempotency key "${idempotencyKey}" already exists`);
|
|
21
|
+
this.name = "DuplicateIdempotencyKeyError";
|
|
22
|
+
this.idempotencyKey = idempotencyKey;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True when `err` is the Postgres unique-constraint violation raised by
|
|
27
|
+
* inserting a duplicate `events.idempotency_key`. Mirrors the detection
|
|
28
|
+
* idiom previously hand-rolled in `ChatCompactor`/`WhatsAppDaemon`:
|
|
29
|
+
* pg error code `23505`, narrowed to the idempotency-key constraint so
|
|
30
|
+
* an unrelated unique violation isn't misclassified.
|
|
31
|
+
*
|
|
32
|
+
* @param err - The error thrown by the INSERT.
|
|
33
|
+
* @returns Whether it is the idempotency-key duplicate violation.
|
|
34
|
+
*/
|
|
35
|
+
function isIdempotencyKeyViolation(err) {
|
|
36
|
+
if (!(err instanceof Error)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const code = err.code;
|
|
40
|
+
if (code !== "23505") {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const constraint = err.constraint;
|
|
44
|
+
if (constraint) {
|
|
45
|
+
return constraint === "events_idempotency_key_key";
|
|
46
|
+
}
|
|
47
|
+
return err.message.includes("idempotency_key");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Domain client for the `events` table that backs the host ↔ container bus.
|
|
51
|
+
*
|
|
52
|
+
* Uses an injected {@link PostgresConnection} for pool + LISTEN access;
|
|
53
|
+
* does not own connection lifecycle. Several `EventBus` instances can
|
|
54
|
+
* share a connection, and the connection can be reused by other domain
|
|
55
|
+
* clients (e.g. {@link AgentRunBus}) without opening more sockets.
|
|
56
|
+
*
|
|
57
|
+
* Listens on `events_new` for wake-ups — the channel that fires on
|
|
58
|
+
* INSERT into `events`. State updates (which fire on `events_state`)
|
|
59
|
+
* are not consumed here; host plugins that need to wait for an event to
|
|
60
|
+
* settle should subscribe to that channel directly via
|
|
61
|
+
* {@link PostgresConnection.listen}.
|
|
62
|
+
*
|
|
63
|
+
* `waitForNext()` does an initial query before sleeping on the listen
|
|
64
|
+
* channel, so events that already existed when the call started are
|
|
65
|
+
* picked up immediately.
|
|
66
|
+
*
|
|
67
|
+
* The bus is *not* a multi-consumer queue: two waiters may observe the
|
|
68
|
+
* same event. Caller is responsible for transitioning state via
|
|
69
|
+
* {@link update} after handling.
|
|
70
|
+
*/
|
|
71
|
+
export class EventBus {
|
|
72
|
+
connection;
|
|
73
|
+
log;
|
|
74
|
+
notifyWaiters = [];
|
|
75
|
+
listenInstalled = false;
|
|
76
|
+
listenHandler = (payload) => {
|
|
77
|
+
this.log?.debug({ channel: EVENTS_NEW_CHANNEL, payload }, "NOTIFY events_new");
|
|
78
|
+
const waiters = this.notifyWaiters.splice(0);
|
|
79
|
+
for (const wake of waiters) {
|
|
80
|
+
wake();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* @param connection - Shared pg pool / LISTEN handle.
|
|
85
|
+
* @param log - Optional logger; when set, NOTIFY arrivals are
|
|
86
|
+
* debug-logged. Omitted on the host-side bus that runs inside
|
|
87
|
+
* one-shot CLI commands so they stay quiet by default.
|
|
88
|
+
*/
|
|
89
|
+
constructor(connection, log) {
|
|
90
|
+
this.connection = connection;
|
|
91
|
+
this.log = log;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply the events + agentruns schema (idempotent). Safe to call on
|
|
95
|
+
* every daemon start. The same SQL bundle is consumed by
|
|
96
|
+
* {@link AgentRunBus.installSchema}, so calling either is sufficient.
|
|
97
|
+
*/
|
|
98
|
+
async installSchema() {
|
|
99
|
+
await this.connection.getPool().query(SCHEMA_SQL);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Insert a new event and return the persisted row.
|
|
103
|
+
*
|
|
104
|
+
* Transactional: when `event.isChat === true`, a `chatmessages`
|
|
105
|
+
* row (role `'user'`) carrying the same `prompt` text is inserted
|
|
106
|
+
* in the same transaction. Both NOTIFY triggers fire post-COMMIT,
|
|
107
|
+
* so listeners always observe consistent state.
|
|
108
|
+
*
|
|
109
|
+
* Validation: `prompt` must be a non-empty string after trimming —
|
|
110
|
+
* for any event, chat or not. The single check exists at this
|
|
111
|
+
* boundary so plugins that bypass the type system (untyped JS,
|
|
112
|
+
* unsafe casts) still surface the bug at emit time instead of
|
|
113
|
+
* leaving the AgentRunner with an empty messages array.
|
|
114
|
+
*
|
|
115
|
+
* `beforeCommit` runs **inside the transaction** after the event
|
|
116
|
+
* row (and any chat mirror) is inserted but before COMMIT. Use it
|
|
117
|
+
* for filesystem side-effects that must be atomic with the INSERT
|
|
118
|
+
* from the watcher's perspective — most importantly, staging
|
|
119
|
+
* `event.files` under `<scratchDir>/<row.id>/` so the container
|
|
120
|
+
* never observes an event without its scratch files in place.
|
|
121
|
+
* Throwing from `beforeCommit` rolls the INSERT back; cleaning up
|
|
122
|
+
* any partial filesystem state is the callback's responsibility.
|
|
123
|
+
*
|
|
124
|
+
* @throws {DuplicateIdempotencyKeyError} If `idempotencyKey` collides
|
|
125
|
+
* with an existing event. Emitters that re-derive stable keys on a
|
|
126
|
+
* re-walk are expected to catch this and treat it as a no-op.
|
|
127
|
+
* @throws If `topic` does not match `\w+(:\w+)*`, if `prompt` is
|
|
128
|
+
* missing or whitespace-only, or if `beforeCommit` throws.
|
|
129
|
+
*/
|
|
130
|
+
async add(event, beforeCommit) {
|
|
131
|
+
if (typeof event.prompt !== "string" || event.prompt.trim().length === 0) {
|
|
132
|
+
throw new Error("EventBus.add: every event requires a non-empty `prompt` (the agent-visible text)");
|
|
133
|
+
}
|
|
134
|
+
const isChat = event.isChat === true;
|
|
135
|
+
const prompt = event.prompt;
|
|
136
|
+
const client = await this.connection.getPool().connect();
|
|
137
|
+
try {
|
|
138
|
+
await client.query("BEGIN");
|
|
139
|
+
const result = await client.query(`INSERT INTO events
|
|
140
|
+
(topic, payload, priority, state, idempotency_key,
|
|
141
|
+
is_chat, preferred_chat_channel_id, prompt, privileged,
|
|
142
|
+
start_handler, output_chat_on_failure)
|
|
143
|
+
VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
144
|
+
RETURNING *`, [
|
|
145
|
+
event.topic,
|
|
146
|
+
JSON.stringify(event.payload ?? {}),
|
|
147
|
+
event.priority ?? 50,
|
|
148
|
+
event.state ?? "pending",
|
|
149
|
+
event.idempotencyKey ?? null,
|
|
150
|
+
isChat,
|
|
151
|
+
event.preferredChatChannelId ?? null,
|
|
152
|
+
prompt,
|
|
153
|
+
event.privileged ?? false,
|
|
154
|
+
event.startHandler ?? null,
|
|
155
|
+
event.outputChatOnFailure ?? false,
|
|
156
|
+
]);
|
|
157
|
+
const row = mapRow(result.rows[0]);
|
|
158
|
+
// Atomic user-chatmessage insert. Two modes:
|
|
159
|
+
// - `userChatMessage` set: insert that text verbatim,
|
|
160
|
+
// irrespective of isChat. Used by plugins (cli-chat
|
|
161
|
+
// direct calls) that want a chatmessage text different
|
|
162
|
+
// from `prompt`.
|
|
163
|
+
// - else `isChat: true`: insert `prompt` (back-compat
|
|
164
|
+
// with chat-event emitters that don't know about the
|
|
165
|
+
// new field).
|
|
166
|
+
//
|
|
167
|
+
// Doing the INSERT here, inside the same transaction as the
|
|
168
|
+
// event INSERT, ensures the FK row-lock is released before
|
|
169
|
+
// NOTIFY events_new fires post-commit — otherwise the
|
|
170
|
+
// input-event watcher's `FOR UPDATE SKIP LOCKED` claim can
|
|
171
|
+
// race with the chatmessages lock and leave the event
|
|
172
|
+
// stuck `pending` forever (the NOTIFY waiter is consumed
|
|
173
|
+
// even though `claim()` returned zero rows).
|
|
174
|
+
const chatMessageText = event.userChatMessage ?? (isChat ? prompt : undefined);
|
|
175
|
+
if (chatMessageText !== undefined) {
|
|
176
|
+
await client.query(`INSERT INTO chatmessages (event_id, role, text_content)
|
|
177
|
+
VALUES ($1, 'user', $2)`, [row.id, chatMessageText]);
|
|
178
|
+
}
|
|
179
|
+
if (beforeCommit) {
|
|
180
|
+
await beforeCommit(row);
|
|
181
|
+
}
|
|
182
|
+
await client.query("COMMIT");
|
|
183
|
+
return row;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
try {
|
|
187
|
+
await client.query("ROLLBACK");
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// best-effort rollback; preserve original error
|
|
191
|
+
}
|
|
192
|
+
// Translate the raw pg unique violation into a typed error so
|
|
193
|
+
// emitters that legitimately re-derive the same idempotency
|
|
194
|
+
// key (delta-poll re-walks, chat compaction races) can catch
|
|
195
|
+
// it by type instead of string-matching the pg message.
|
|
196
|
+
if (event.idempotencyKey && isIdempotencyKeyViolation(err)) {
|
|
197
|
+
throw new DuplicateIdempotencyKeyError(event.idempotencyKey);
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
client.release();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Fetch one event by id. Returns `undefined` if not found. Mirrors
|
|
207
|
+
* {@link import("./StepResultBus").StepResultBus.getById} so
|
|
208
|
+
* notification consumers can promote a payload-only id into a full
|
|
209
|
+
* typed row without a hand-written query.
|
|
210
|
+
*/
|
|
211
|
+
async getById(id) {
|
|
212
|
+
const result = await this.connection
|
|
213
|
+
.getPool()
|
|
214
|
+
.query(`SELECT * FROM events WHERE id = $1`, [id]);
|
|
215
|
+
return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Fetch all event rows whose `updated_at` is `>= since`, ordered by
|
|
219
|
+
* `(updated_at, id)`. Used by the polling-based report layer to
|
|
220
|
+
* pick up new inserts and state transitions without subscribing to
|
|
221
|
+
* NOTIFY. Returns an empty array when nothing changed since the
|
|
222
|
+
* anchor.
|
|
223
|
+
*/
|
|
224
|
+
async listSince(since) {
|
|
225
|
+
const result = await this.connection.getPool().query(`SELECT * FROM events
|
|
226
|
+
WHERE updated_at >= $1
|
|
227
|
+
ORDER BY updated_at, id`, [since]);
|
|
228
|
+
return result.rows.map(mapRow);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Fetch the most recently created N event rows, newest first. Used by
|
|
232
|
+
* `./cli.sh events list` to surface recent bus activity for operators
|
|
233
|
+
* looking for an event id to inspect with `events report <id>`.
|
|
234
|
+
*
|
|
235
|
+
* @param limit Maximum number of rows to return. Must be a positive integer.
|
|
236
|
+
* @returns Up to `limit` event rows ordered by descending `(created_at, id)`.
|
|
237
|
+
*/
|
|
238
|
+
async listLatest(limit) {
|
|
239
|
+
const result = await this.connection.getPool().query(`SELECT * FROM events
|
|
240
|
+
ORDER BY created_at DESC, id DESC
|
|
241
|
+
LIMIT $1`, [limit]);
|
|
242
|
+
return result.rows.map(mapRow);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Fetch the most recently created N event rows whose topic, start_handler,
|
|
246
|
+
* prompt, or payload (cast to text) case-insensitively contains `needle`.
|
|
247
|
+
* Used by `./cli.sh events list <search>` so the operator can grep the
|
|
248
|
+
* recent bus without piping through `grep`.
|
|
249
|
+
*
|
|
250
|
+
* Substring match via `ILIKE '%needle%'`; the needle is escaped for the
|
|
251
|
+
* SQL `LIKE` metacharacters `%`, `_`, and `\`. No index is added — at
|
|
252
|
+
* interactive scale a sequential scan is fine.
|
|
253
|
+
*
|
|
254
|
+
* @param limit Maximum number of rows to return. Must be a positive integer.
|
|
255
|
+
* @param needle Case-insensitive substring to search for.
|
|
256
|
+
* @returns Up to `limit` matching event rows ordered by descending `(created_at, id)`.
|
|
257
|
+
*/
|
|
258
|
+
async searchLatest(limit, needle) {
|
|
259
|
+
const pattern = `%${needle.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
260
|
+
const result = await this.connection.getPool().query(`SELECT * FROM events
|
|
261
|
+
WHERE topic ILIKE $2
|
|
262
|
+
OR coalesce(start_handler, '') ILIKE $2
|
|
263
|
+
OR coalesce(prompt, '') ILIKE $2
|
|
264
|
+
OR payload::text ILIKE $2
|
|
265
|
+
ORDER BY created_at DESC, id DESC
|
|
266
|
+
LIMIT $1`, [limit, pattern]);
|
|
267
|
+
return result.rows.map(mapRow);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Latest N event rows matching every supplied filter. Drives the
|
|
271
|
+
* agent-facing `event_list` reflection tool, which lets the agent
|
|
272
|
+
* narrow by state, date window, and free-text needle simultaneously
|
|
273
|
+
* — combinations the older {@link listLatest} / {@link searchLatest}
|
|
274
|
+
* pair can't express. Both of those stay as the CLI's call sites.
|
|
275
|
+
*
|
|
276
|
+
* Substring match (when `search` is set) is the same ILIKE-against-
|
|
277
|
+
* `(topic, start_handler, prompt, payload::text)` predicate
|
|
278
|
+
* {@link searchLatest} uses. `dateFrom` is inclusive, `dateTo` is
|
|
279
|
+
* exclusive (`created_at >= $from AND created_at < $to`), so the
|
|
280
|
+
* agent can pass two midnights to get a single local day without
|
|
281
|
+
* worrying about the boundary instant.
|
|
282
|
+
*/
|
|
283
|
+
async listFiltered(filter) {
|
|
284
|
+
const conditions = [];
|
|
285
|
+
const values = [];
|
|
286
|
+
let n = 1;
|
|
287
|
+
if (filter.search !== undefined && filter.search.length > 0) {
|
|
288
|
+
const pattern = `%${filter.search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
289
|
+
conditions.push(`(topic ILIKE $${n} OR coalesce(start_handler, '') ILIKE $${n} ` +
|
|
290
|
+
`OR coalesce(prompt, '') ILIKE $${n} OR payload::text ILIKE $${n})`);
|
|
291
|
+
values.push(pattern);
|
|
292
|
+
n++;
|
|
293
|
+
}
|
|
294
|
+
if (filter.state !== undefined) {
|
|
295
|
+
conditions.push(`state = $${n++}`);
|
|
296
|
+
values.push(filter.state);
|
|
297
|
+
}
|
|
298
|
+
if (filter.dateFrom !== undefined) {
|
|
299
|
+
conditions.push(`created_at >= $${n++}`);
|
|
300
|
+
values.push(filter.dateFrom);
|
|
301
|
+
}
|
|
302
|
+
if (filter.dateTo !== undefined) {
|
|
303
|
+
conditions.push(`created_at < $${n++}`);
|
|
304
|
+
values.push(filter.dateTo);
|
|
305
|
+
}
|
|
306
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
307
|
+
values.push(filter.limit);
|
|
308
|
+
const limitParam = `$${n}`;
|
|
309
|
+
const result = await this.connection.getPool().query(`SELECT * FROM events
|
|
310
|
+
${where}
|
|
311
|
+
ORDER BY created_at DESC, id DESC
|
|
312
|
+
LIMIT ${limitParam}`, values);
|
|
313
|
+
return result.rows.map(mapRow);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Patch one or more fields on an existing event by id. Always bumps
|
|
317
|
+
* `updated_at`. No-op if `patch` contains no recognized fields.
|
|
318
|
+
*/
|
|
319
|
+
async update(id, patch) {
|
|
320
|
+
const sets = ["updated_at = now()"];
|
|
321
|
+
const values = [];
|
|
322
|
+
let n = 1;
|
|
323
|
+
if (patch.state !== undefined) {
|
|
324
|
+
sets.push(`state = $${n++}`);
|
|
325
|
+
values.push(patch.state);
|
|
326
|
+
}
|
|
327
|
+
if (patch.payload !== undefined) {
|
|
328
|
+
sets.push(`payload = $${n++}::jsonb`);
|
|
329
|
+
values.push(JSON.stringify(patch.payload));
|
|
330
|
+
}
|
|
331
|
+
if (patch.priority !== undefined) {
|
|
332
|
+
sets.push(`priority = $${n++}`);
|
|
333
|
+
values.push(patch.priority);
|
|
334
|
+
}
|
|
335
|
+
if (patch.preferredChatChannelId !== undefined) {
|
|
336
|
+
sets.push(`preferred_chat_channel_id = $${n++}`);
|
|
337
|
+
values.push(patch.preferredChatChannelId);
|
|
338
|
+
}
|
|
339
|
+
values.push(id);
|
|
340
|
+
const idParam = `$${n}`;
|
|
341
|
+
await this.connection
|
|
342
|
+
.getPool()
|
|
343
|
+
.query(`UPDATE events SET ${sets.join(", ")} WHERE id = ${idParam}`, values);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Block until an event matching `filter` is in the table. Returns
|
|
347
|
+
* the highest-priority such event (FIFO within priority). Subscribes
|
|
348
|
+
* to `LISTEN events_new` on first call so subsequent waits sleep
|
|
349
|
+
* until a NOTIFY arrives instead of polling.
|
|
350
|
+
*
|
|
351
|
+
* **Read-only**: does not mutate the row. For multi-consumer queues,
|
|
352
|
+
* use {@link waitAndClaim} instead, which atomically transitions the
|
|
353
|
+
* row's state on read.
|
|
354
|
+
*
|
|
355
|
+
* @param filter - Topic / state filter; default `{ states: ["pending"] }`.
|
|
356
|
+
* @param signal - Optional AbortSignal to cancel the wait.
|
|
357
|
+
* @throws If aborted via `signal`.
|
|
358
|
+
*/
|
|
359
|
+
async waitForNext(filter = {}, signal) {
|
|
360
|
+
await this.ensureListening();
|
|
361
|
+
for (;;) {
|
|
362
|
+
if (signal?.aborted) {
|
|
363
|
+
throw new Error("waitForNext aborted");
|
|
364
|
+
}
|
|
365
|
+
const row = await this.queryOne(filter);
|
|
366
|
+
if (row) {
|
|
367
|
+
return row;
|
|
368
|
+
}
|
|
369
|
+
await this.waitForNotification(signal);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Atomically transition the highest-priority event in `fromState` to
|
|
374
|
+
* `toState` and return the updated row. Race-safe across multiple
|
|
375
|
+
* concurrent workers via `FOR UPDATE SKIP LOCKED`.
|
|
376
|
+
*
|
|
377
|
+
* Returns `undefined` if no row is in `fromState`. Non-blocking — for
|
|
378
|
+
* blocking semantics, use {@link waitAndClaim}.
|
|
379
|
+
*/
|
|
380
|
+
async claim(fromState, toState) {
|
|
381
|
+
const result = await this.connection.getPool().query(`UPDATE events
|
|
382
|
+
SET state = $1, updated_at = now()
|
|
383
|
+
WHERE id = (
|
|
384
|
+
SELECT id FROM events
|
|
385
|
+
WHERE state = $2
|
|
386
|
+
ORDER BY priority DESC, id ASC
|
|
387
|
+
FOR UPDATE SKIP LOCKED
|
|
388
|
+
LIMIT 1
|
|
389
|
+
)
|
|
390
|
+
RETURNING *`, [toState, fromState]);
|
|
391
|
+
return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Block until an event in `fromState` is available, then atomically
|
|
395
|
+
* claim it (transition to `toState`) and return it. Combines
|
|
396
|
+
* {@link claim} with the LISTEN/NOTIFY wait loop used by
|
|
397
|
+
* {@link waitForNext}.
|
|
398
|
+
*
|
|
399
|
+
* @throws If aborted via `signal`.
|
|
400
|
+
*/
|
|
401
|
+
async waitAndClaim(fromState, toState, signal) {
|
|
402
|
+
await this.ensureListening();
|
|
403
|
+
for (;;) {
|
|
404
|
+
if (signal?.aborted) {
|
|
405
|
+
throw new Error("waitAndClaim aborted");
|
|
406
|
+
}
|
|
407
|
+
const row = await this.claim(fromState, toState);
|
|
408
|
+
if (row) {
|
|
409
|
+
return row;
|
|
410
|
+
}
|
|
411
|
+
await this.waitForNotification(signal);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Subscribe this bus's wake-handler to {@link EVENTS_NEW_CHANNEL}. */
|
|
415
|
+
async ensureListening() {
|
|
416
|
+
if (this.listenInstalled) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.listenInstalled = true;
|
|
420
|
+
await this.connection.listen(EVENTS_NEW_CHANNEL, this.listenHandler);
|
|
421
|
+
}
|
|
422
|
+
/** Resolve as soon as the next NOTIFY arrives or `signal` aborts. */
|
|
423
|
+
waitForNotification(signal) {
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const wake = () => {
|
|
426
|
+
if (signal) {
|
|
427
|
+
signal.removeEventListener("abort", onAbort);
|
|
428
|
+
}
|
|
429
|
+
resolve();
|
|
430
|
+
};
|
|
431
|
+
const onAbort = () => {
|
|
432
|
+
this.notifyWaiters = this.notifyWaiters.filter((w) => w !== wake);
|
|
433
|
+
reject(new Error("waitForNext aborted"));
|
|
434
|
+
};
|
|
435
|
+
this.notifyWaiters.push(wake);
|
|
436
|
+
if (signal) {
|
|
437
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/** Single SELECT for the highest-priority matching pending event. */
|
|
442
|
+
async queryOne(filter) {
|
|
443
|
+
const states = filter.states ?? ["pending"];
|
|
444
|
+
const conditions = ["state = ANY($1)"];
|
|
445
|
+
const values = [states];
|
|
446
|
+
let n = 2;
|
|
447
|
+
if (filter.topics && filter.topics.length > 0) {
|
|
448
|
+
conditions.push(`topic = ANY($${n++})`);
|
|
449
|
+
values.push(filter.topics);
|
|
450
|
+
}
|
|
451
|
+
const result = await this.connection.getPool().query(`SELECT * FROM events
|
|
452
|
+
WHERE ${conditions.join(" AND ")}
|
|
453
|
+
ORDER BY priority DESC, id ASC
|
|
454
|
+
LIMIT 1`, values);
|
|
455
|
+
return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Convert a snake_case raw row into the camelCase {@link EventRow}.
|
|
460
|
+
*
|
|
461
|
+
* The DB column is `text` with no CHECK constraint on the value, so
|
|
462
|
+
* `raw.state` is trusted to be a known {@link EventState} (we control
|
|
463
|
+
* every writer).
|
|
464
|
+
*/
|
|
465
|
+
function mapRow(raw) {
|
|
466
|
+
return {
|
|
467
|
+
id: raw.id,
|
|
468
|
+
topic: raw.topic,
|
|
469
|
+
priority: raw.priority,
|
|
470
|
+
state: raw.state,
|
|
471
|
+
payload: raw.payload,
|
|
472
|
+
idempotencyKey: raw.idempotency_key,
|
|
473
|
+
isChat: raw.is_chat,
|
|
474
|
+
preferredChatChannelId: raw.preferred_chat_channel_id,
|
|
475
|
+
prompt: raw.prompt ?? "",
|
|
476
|
+
startHandler: raw.start_handler,
|
|
477
|
+
privileged: raw.privileged,
|
|
478
|
+
outputChatOnFailure: raw.output_chat_on_failure,
|
|
479
|
+
createdAt: raw.created_at,
|
|
480
|
+
updatedAt: raw.updated_at,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One handler entry surfaced by {@link HandlerCatalog.list}.
|
|
3
|
+
*
|
|
4
|
+
* Encodes the same notion as the container's `HandlerFile`: a topic
|
|
5
|
+
* (one or more `:`-separated segments) plus a basename. `slashPath`
|
|
6
|
+
* is the user-facing form the cli-chat prompt accepts as a direct
|
|
7
|
+
* handler call (e.g. `/grocery/fruits/order`).
|
|
8
|
+
*/
|
|
9
|
+
export interface HandlerPath {
|
|
10
|
+
/** Colon-separated topic, e.g. `chat:telegram`. */
|
|
11
|
+
readonly topic: string;
|
|
12
|
+
/** Handler basename without `.md`, e.g. `index`, `analyze`. */
|
|
13
|
+
readonly handler: string;
|
|
14
|
+
/** Absolute path on disk of the handler file. */
|
|
15
|
+
readonly absolutePath: string;
|
|
16
|
+
/** Workspace-relative posix path, e.g. `chat/telegram/index.md`. */
|
|
17
|
+
readonly relativePath: string;
|
|
18
|
+
/** User-facing slash form, e.g. `/chat/telegram/index`. */
|
|
19
|
+
readonly slashPath: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read-only view over the `workspace/` directory's handler files.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the resolution semantics implemented by
|
|
25
|
+
* `container/src/HandlerFile.ts` so host-side code (CLI, cron
|
|
26
|
+
* scheduler introspection, tab-completion) can answer "does this
|
|
27
|
+
* handler exist?" and "list every handler" without depending on
|
|
28
|
+
* container internals or reimplementing the topic-walk inheritance.
|
|
29
|
+
*
|
|
30
|
+
* The catalog is stateless on the disk — every method re-scans —
|
|
31
|
+
* so live workspace edits are reflected without a daemon restart.
|
|
32
|
+
* At REPL frequencies this is cheap; if a hot path later needs
|
|
33
|
+
* caching it can wrap an instance.
|
|
34
|
+
*/
|
|
35
|
+
export declare class HandlerCatalog {
|
|
36
|
+
private readonly workspaceDir;
|
|
37
|
+
private readonly writablePathGlobs;
|
|
38
|
+
/**
|
|
39
|
+
* @param workspaceDir Absolute path of the `workspace/` directory.
|
|
40
|
+
* @param writablePathGlobs `core.writablePaths` globs (e.g. `wiki/**`).
|
|
41
|
+
* Files under these are writable by non-privileged runs and are
|
|
42
|
+
* therefore never handlers — they are excluded from {@link list}
|
|
43
|
+
* and {@link resolve} so they are never proposed or resolved as a
|
|
44
|
+
* handler. Mirrors the container's `HandlerFile.load` guard.
|
|
45
|
+
*/
|
|
46
|
+
constructor(workspaceDir: string, writablePathGlobs?: readonly string[]);
|
|
47
|
+
/**
|
|
48
|
+
* Walk `workspace/` and return one {@link HandlerPath} per
|
|
49
|
+
* candidate handler file. Excludes the global-context files at
|
|
50
|
+
* the workspace root (`SOUL.md`, `CONTEXT.md`)
|
|
51
|
+
* and the `skills/` subtree. Every other `.md` file under a topic
|
|
52
|
+
* directory is included, even if it's a knowledge file (e.g.
|
|
53
|
+
* `people/anna.md`) — `HandlerCatalog` does not police what is or
|
|
54
|
+
* isn't a "proper" handler; that's a handler-loading concern.
|
|
55
|
+
*
|
|
56
|
+
* Sorted by `slashPath` for stable display ordering in
|
|
57
|
+
* tab-completion. Returns an empty array when the workspace
|
|
58
|
+
* directory does not exist.
|
|
59
|
+
*/
|
|
60
|
+
list(): Promise<readonly HandlerPath[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a handler call: given a topic (e.g. `chat:telegram`)
|
|
63
|
+
* and a basename (e.g. `index`), return the absolute path of the
|
|
64
|
+
* deepest existing handler file, mirroring the container's
|
|
65
|
+
* inheritance walk.
|
|
66
|
+
*
|
|
67
|
+
* For topic `a:b:c` and basename `x` the resolution tries, in
|
|
68
|
+
* order: `a/b/c/x.md`, `a/b/x.md`, `a/x.md`. Returns the first
|
|
69
|
+
* existing path or `null` if none exist. The deepest existing
|
|
70
|
+
* file is what `HandlerFile.load` would treat as the leaf — for
|
|
71
|
+
* cli-chat existence checks, that's exactly the question we
|
|
72
|
+
* want answered.
|
|
73
|
+
*/
|
|
74
|
+
resolve(topic: string, handler: string): Promise<string | null>;
|
|
75
|
+
}
|