@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
package/build/Plugin.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by `ctx.events.emit().settled` when the host daemon stops
|
|
3
|
+
* (or crashes) while a one-shot CLI command was waiting for the event
|
|
4
|
+
* to reach a terminal state. Catch and exit cleanly — there is no
|
|
5
|
+
* postgres on the other side to recover state from.
|
|
6
|
+
*/
|
|
7
|
+
export class DaemonStoppedError extends Error {
|
|
8
|
+
constructor(message = "Host daemon stopped") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "DaemonStoppedError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Identity function used by plugins to declare their manifest:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* export default definePlugin({
|
|
18
|
+
* id: "cli-chat",
|
|
19
|
+
* host: { commands: (ctx) => [sendCommand(ctx)] },
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Exists purely so TypeScript narrows literal types correctly without
|
|
24
|
+
* the caller writing a `satisfies PluginManifest` clause. Same pattern
|
|
25
|
+
* as Vite's `defineConfig` / Astro's `defineConfig`.
|
|
26
|
+
*/
|
|
27
|
+
export function definePlugin(plugin) {
|
|
28
|
+
return plugin;
|
|
29
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type Pool } from "pg";
|
|
2
|
+
/**
|
|
3
|
+
* Hardcoded user / database / network coordinates for the dev bus-state
|
|
4
|
+
* DB. Both ends of the bus import these so they don't drift.
|
|
5
|
+
*
|
|
6
|
+
* - {@link POSTGRES_HOST} is both the docker container `--name` (used by
|
|
7
|
+
* `PostgresContainer` on the host) and the DNS name the agent
|
|
8
|
+
* container resolves over `familiar-net`.
|
|
9
|
+
* - {@link POSTGRES_PORT} is the in-container postgres port — the
|
|
10
|
+
* *internal* side of the host's loopback `-p` mapping, and the port
|
|
11
|
+
* the agent container connects to over `familiar-net`.
|
|
12
|
+
*
|
|
13
|
+
* The password is *not* a constant — it lives in `.env`
|
|
14
|
+
* (`POSTGRES_PASSWORD`) and is passed explicitly to
|
|
15
|
+
* `PostgresConnection`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const POSTGRES_HOST = "familiar-postgres";
|
|
18
|
+
export declare const POSTGRES_PORT = 5432;
|
|
19
|
+
export declare const POSTGRES_USER = "familiar";
|
|
20
|
+
export declare const POSTGRES_DB = "familiar";
|
|
21
|
+
/** Connection coordinates for a postgres database. */
|
|
22
|
+
export interface PostgresConnectionConfig {
|
|
23
|
+
readonly host: string;
|
|
24
|
+
readonly port: number;
|
|
25
|
+
readonly user: string;
|
|
26
|
+
readonly password: string;
|
|
27
|
+
readonly database: string;
|
|
28
|
+
}
|
|
29
|
+
/** Callback fired when a NOTIFY arrives on a subscribed channel. */
|
|
30
|
+
export type NotificationHandler = (payload: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Wraps a postgres pool plus an optional dedicated `LISTEN` client.
|
|
33
|
+
*
|
|
34
|
+
* - `getPool()` lazily builds a normal connection pool for queries.
|
|
35
|
+
* - `listen()`/`unlisten()` lazily open a single long-lived client
|
|
36
|
+
* dedicated to receiving asynchronous NOTIFYs. Multiple consumers can
|
|
37
|
+
* subscribe to the same channel; each registered handler is invoked
|
|
38
|
+
* for every notification on that channel.
|
|
39
|
+
*
|
|
40
|
+
* Domain classes (e.g. `EventBus`) take a `PostgresConnection` rather
|
|
41
|
+
* than connection coordinates so they can share one pool / one listen
|
|
42
|
+
* client across the process.
|
|
43
|
+
*/
|
|
44
|
+
export declare class PostgresConnection {
|
|
45
|
+
private readonly poolConfig;
|
|
46
|
+
private pool;
|
|
47
|
+
private listenClient;
|
|
48
|
+
private listenClientReady;
|
|
49
|
+
private readonly handlersByChannel;
|
|
50
|
+
constructor(config: PostgresConnectionConfig);
|
|
51
|
+
/** Lazily build (and reuse) the connection pool for normal queries. */
|
|
52
|
+
getPool(): Pool;
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe to a postgres NOTIFY channel. The dedicated LISTEN
|
|
55
|
+
* connection is opened on first call. Multiple handlers per channel
|
|
56
|
+
* are supported; the same handler can be registered only once.
|
|
57
|
+
*
|
|
58
|
+
* @param channel - SQL identifier for the channel. Must already be
|
|
59
|
+
* safe to interpolate (validated to avoid injection).
|
|
60
|
+
* @param handler - Callback invoked with each notification's payload.
|
|
61
|
+
*/
|
|
62
|
+
listen(channel: string, handler: NotificationHandler): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Remove a previously registered handler. Issues `UNLISTEN` once the
|
|
65
|
+
* last handler for a channel is removed.
|
|
66
|
+
*/
|
|
67
|
+
unlisten(channel: string, handler: NotificationHandler): Promise<void>;
|
|
68
|
+
/** End the pool and the LISTEN client. Idempotent. */
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Ensure the dedicated LISTEN client is connected and dispatch
|
|
72
|
+
* notifications to all registered handlers for the channel.
|
|
73
|
+
*/
|
|
74
|
+
private ensureListenClient;
|
|
75
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { default as pg } from "pg";
|
|
2
|
+
const { Pool: PgPool, Client: PgClient } = pg;
|
|
3
|
+
/**
|
|
4
|
+
* Hardcoded user / database / network coordinates for the dev bus-state
|
|
5
|
+
* DB. Both ends of the bus import these so they don't drift.
|
|
6
|
+
*
|
|
7
|
+
* - {@link POSTGRES_HOST} is both the docker container `--name` (used by
|
|
8
|
+
* `PostgresContainer` on the host) and the DNS name the agent
|
|
9
|
+
* container resolves over `familiar-net`.
|
|
10
|
+
* - {@link POSTGRES_PORT} is the in-container postgres port — the
|
|
11
|
+
* *internal* side of the host's loopback `-p` mapping, and the port
|
|
12
|
+
* the agent container connects to over `familiar-net`.
|
|
13
|
+
*
|
|
14
|
+
* The password is *not* a constant — it lives in `.env`
|
|
15
|
+
* (`POSTGRES_PASSWORD`) and is passed explicitly to
|
|
16
|
+
* `PostgresConnection`.
|
|
17
|
+
*/
|
|
18
|
+
export const POSTGRES_HOST = "familiar-postgres";
|
|
19
|
+
export const POSTGRES_PORT = 5432;
|
|
20
|
+
export const POSTGRES_USER = "familiar";
|
|
21
|
+
export const POSTGRES_DB = "familiar";
|
|
22
|
+
/**
|
|
23
|
+
* Wraps a postgres pool plus an optional dedicated `LISTEN` client.
|
|
24
|
+
*
|
|
25
|
+
* - `getPool()` lazily builds a normal connection pool for queries.
|
|
26
|
+
* - `listen()`/`unlisten()` lazily open a single long-lived client
|
|
27
|
+
* dedicated to receiving asynchronous NOTIFYs. Multiple consumers can
|
|
28
|
+
* subscribe to the same channel; each registered handler is invoked
|
|
29
|
+
* for every notification on that channel.
|
|
30
|
+
*
|
|
31
|
+
* Domain classes (e.g. `EventBus`) take a `PostgresConnection` rather
|
|
32
|
+
* than connection coordinates so they can share one pool / one listen
|
|
33
|
+
* client across the process.
|
|
34
|
+
*/
|
|
35
|
+
export class PostgresConnection {
|
|
36
|
+
poolConfig;
|
|
37
|
+
pool;
|
|
38
|
+
listenClient;
|
|
39
|
+
listenClientReady;
|
|
40
|
+
handlersByChannel = new Map();
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.poolConfig = {
|
|
43
|
+
host: config.host,
|
|
44
|
+
port: config.port,
|
|
45
|
+
user: config.user,
|
|
46
|
+
password: config.password,
|
|
47
|
+
database: config.database,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Lazily build (and reuse) the connection pool for normal queries. */
|
|
51
|
+
getPool() {
|
|
52
|
+
if (!this.pool) {
|
|
53
|
+
this.pool = new PgPool(this.poolConfig);
|
|
54
|
+
}
|
|
55
|
+
return this.pool;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to a postgres NOTIFY channel. The dedicated LISTEN
|
|
59
|
+
* connection is opened on first call. Multiple handlers per channel
|
|
60
|
+
* are supported; the same handler can be registered only once.
|
|
61
|
+
*
|
|
62
|
+
* @param channel - SQL identifier for the channel. Must already be
|
|
63
|
+
* safe to interpolate (validated to avoid injection).
|
|
64
|
+
* @param handler - Callback invoked with each notification's payload.
|
|
65
|
+
*/
|
|
66
|
+
async listen(channel, handler) {
|
|
67
|
+
validateChannelName(channel);
|
|
68
|
+
const client = await this.ensureListenClient();
|
|
69
|
+
let handlers = this.handlersByChannel.get(channel);
|
|
70
|
+
const isFirstSubscriber = !handlers;
|
|
71
|
+
if (!handlers) {
|
|
72
|
+
handlers = new Set();
|
|
73
|
+
this.handlersByChannel.set(channel, handlers);
|
|
74
|
+
}
|
|
75
|
+
handlers.add(handler);
|
|
76
|
+
if (isFirstSubscriber) {
|
|
77
|
+
await client.query(`LISTEN ${channel}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Remove a previously registered handler. Issues `UNLISTEN` once the
|
|
82
|
+
* last handler for a channel is removed.
|
|
83
|
+
*/
|
|
84
|
+
async unlisten(channel, handler) {
|
|
85
|
+
validateChannelName(channel);
|
|
86
|
+
const handlers = this.handlersByChannel.get(channel);
|
|
87
|
+
if (!handlers) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
handlers.delete(handler);
|
|
91
|
+
if (handlers.size === 0) {
|
|
92
|
+
this.handlersByChannel.delete(channel);
|
|
93
|
+
if (this.listenClient) {
|
|
94
|
+
await this.listenClient.query(`UNLISTEN ${channel}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** End the pool and the LISTEN client. Idempotent. */
|
|
99
|
+
async close() {
|
|
100
|
+
this.handlersByChannel.clear();
|
|
101
|
+
if (this.listenClient) {
|
|
102
|
+
try {
|
|
103
|
+
await this.listenClient.end();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// best-effort
|
|
107
|
+
}
|
|
108
|
+
this.listenClient = undefined;
|
|
109
|
+
this.listenClientReady = undefined;
|
|
110
|
+
}
|
|
111
|
+
if (this.pool) {
|
|
112
|
+
await this.pool.end();
|
|
113
|
+
this.pool = undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Ensure the dedicated LISTEN client is connected and dispatch
|
|
118
|
+
* notifications to all registered handlers for the channel.
|
|
119
|
+
*/
|
|
120
|
+
async ensureListenClient() {
|
|
121
|
+
if (!this.listenClientReady) {
|
|
122
|
+
this.listenClientReady = (async () => {
|
|
123
|
+
const client = new PgClient(this.poolConfig);
|
|
124
|
+
await client.connect();
|
|
125
|
+
client.on("notification", (msg) => {
|
|
126
|
+
if (!msg.channel) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const handlers = this.handlersByChannel.get(msg.channel);
|
|
130
|
+
if (!handlers) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const handler of handlers) {
|
|
134
|
+
try {
|
|
135
|
+
handler(msg.payload ?? "");
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error(`PostgresConnection notification handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
client.on("error", (err) => {
|
|
143
|
+
console.error(`PostgresConnection listen client error: ${err.message}`);
|
|
144
|
+
});
|
|
145
|
+
this.listenClient = client;
|
|
146
|
+
return client;
|
|
147
|
+
})();
|
|
148
|
+
}
|
|
149
|
+
return this.listenClientReady;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const CHANNEL_NAME_RE = /^[a-z_][a-z0-9_]*$/i;
|
|
153
|
+
/**
|
|
154
|
+
* Reject channel names that aren't safe to interpolate into a `LISTEN`
|
|
155
|
+
* statement. Postgres identifiers can be quoted, but for simplicity we
|
|
156
|
+
* restrict to plain `[a-z_][a-z0-9_]*`.
|
|
157
|
+
*/
|
|
158
|
+
function validateChannelName(channel) {
|
|
159
|
+
if (!CHANNEL_NAME_RE.test(channel)) {
|
|
160
|
+
throw new Error(`Invalid channel name: ${channel}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A persisted row in `scheduled_subagents` — one future one-off wake-up
|
|
3
|
+
* the agent has asked the host to fire. Only produced when the agent
|
|
4
|
+
* called `schedule_subagent` with a future `when`; without `when`, the
|
|
5
|
+
* tool inserts a child agentrun directly instead and never touches
|
|
6
|
+
* this table. Lifecycle:
|
|
7
|
+
*
|
|
8
|
+
* 1. Container's `schedule_subagent` tool upserts (on `key`) a row with
|
|
9
|
+
* `fireAt` in UTC, copying the calling agentrun's `priority` and
|
|
10
|
+
* `privileged` flag.
|
|
11
|
+
* 2. Host's `ScheduledSubagentScheduler` reacts to the
|
|
12
|
+
* `scheduled_subagents_changed` NOTIFY and installs a Croner job for
|
|
13
|
+
* `fireAt` with `maxRuns: 1`.
|
|
14
|
+
* 3. When the job fires, the host atomically claims-and-deletes the
|
|
15
|
+
* row, then emits a fresh event with the row's topic / handler /
|
|
16
|
+
* prompt / payload / priority / privileged.
|
|
17
|
+
*
|
|
18
|
+
* `prompt` is optional at the table level so the agent can schedule
|
|
19
|
+
* "the handler knows what to do" wake-ups; the firing path supplies
|
|
20
|
+
* a generic default (`Scheduled subagent fired`) before calling
|
|
21
|
+
* `EventBus.add`, which requires a non-empty prompt.
|
|
22
|
+
*/
|
|
23
|
+
export interface ScheduledSubagentRow {
|
|
24
|
+
/** Agent-supplied unique id. UPSERT target. */
|
|
25
|
+
readonly key: string;
|
|
26
|
+
/** UTC ISO-8601 firing time (e.g. `2026-05-21T13:00:00Z`). */
|
|
27
|
+
readonly fireAt: string;
|
|
28
|
+
readonly topic: string;
|
|
29
|
+
readonly handler: string;
|
|
30
|
+
readonly prompt: string | null;
|
|
31
|
+
readonly payload: unknown;
|
|
32
|
+
readonly priority: number;
|
|
33
|
+
readonly privileged: boolean;
|
|
34
|
+
readonly createdAt: Date;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Insert shape for {@link ScheduledSubagentBus.upsert}. Mirrors
|
|
38
|
+
* {@link ScheduledSubagentRow} minus `createdAt` (filled by the DB).
|
|
39
|
+
*/
|
|
40
|
+
export interface NewScheduledSubagent {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly fireAt: string;
|
|
43
|
+
readonly topic: string;
|
|
44
|
+
readonly handler: string;
|
|
45
|
+
readonly prompt?: string | null;
|
|
46
|
+
readonly payload?: unknown;
|
|
47
|
+
readonly priority?: number;
|
|
48
|
+
readonly privileged?: boolean;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Logger } from "./logging/Logger.js";
|
|
2
|
+
import type { PostgresConnection } from "./PostgresConnection.js";
|
|
3
|
+
import type { NewScheduledSubagent, ScheduledSubagentRow } from "./ScheduledSubagent.js";
|
|
4
|
+
/** Disposer returned by {@link ScheduledSubagentBus.listen}. */
|
|
5
|
+
export type ScheduledSubagentUnsubscribe = () => Promise<void>;
|
|
6
|
+
/** Notification operation extracted from a NOTIFY payload. */
|
|
7
|
+
export type ScheduledSubagentOp = "u" | "d";
|
|
8
|
+
/** Parsed notification handed to {@link ScheduledSubagentBus.listen} subscribers. */
|
|
9
|
+
export interface ScheduledSubagentNotification {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly op: ScheduledSubagentOp;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Domain client for the `scheduled_subagents` table — agent-requested
|
|
15
|
+
* one-off wake-ups. Used by both sides of the bus:
|
|
16
|
+
*
|
|
17
|
+
* - Container: the `schedule_subagent` / `unschedule_subagent` /
|
|
18
|
+
* `get_scheduled_subagents` tools call {@link upsert} / {@link deleteByKey}
|
|
19
|
+
* / {@link listInRange}.
|
|
20
|
+
* - Host: the `ScheduledSubagentScheduler` calls {@link listFuture} at
|
|
21
|
+
* startup, listens via {@link listen} for live changes, and uses
|
|
22
|
+
* {@link claimAndDeleteForFiring} to consume a row at fire time.
|
|
23
|
+
*
|
|
24
|
+
* All times are stored and returned as UTC ISO-8601 strings; the
|
|
25
|
+
* caller is responsible for projecting into the user's
|
|
26
|
+
* `core.timezone` for agent-facing surfaces.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ScheduledSubagentBus {
|
|
29
|
+
private readonly connection;
|
|
30
|
+
private readonly log;
|
|
31
|
+
constructor(connection: PostgresConnection, log?: Logger);
|
|
32
|
+
/**
|
|
33
|
+
* Apply the bus-state schema (idempotent). Same SQL bundle as
|
|
34
|
+
* {@link import("./EventBus").EventBus.installSchema}, so calling
|
|
35
|
+
* any of the three install methods is sufficient on daemon start.
|
|
36
|
+
*/
|
|
37
|
+
installSchema(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Insert a scheduled subagent or replace the existing row with the
|
|
40
|
+
* same `key`. Returns the persisted row.
|
|
41
|
+
*
|
|
42
|
+
* Atomic UPSERT semantics: if the agent re-schedules a key, the
|
|
43
|
+
* old row is overwritten in one statement so the host scheduler
|
|
44
|
+
* sees exactly one NOTIFY and installs exactly one job.
|
|
45
|
+
*
|
|
46
|
+
* @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
|
|
47
|
+
*/
|
|
48
|
+
upsert(row: NewScheduledSubagent): Promise<ScheduledSubagentRow>;
|
|
49
|
+
/**
|
|
50
|
+
* Remove a row by key. Returns `true` when a row was removed,
|
|
51
|
+
* `false` when no row existed for the key.
|
|
52
|
+
*/
|
|
53
|
+
deleteByKey(key: string): Promise<boolean>;
|
|
54
|
+
/** Fetch a row by key, or `undefined` when missing. */
|
|
55
|
+
getByKey(key: string): Promise<ScheduledSubagentRow | undefined>;
|
|
56
|
+
/**
|
|
57
|
+
* Atomic claim-and-delete for the firing path: DELETE the row by
|
|
58
|
+
* key and return it (or `undefined` if it was already gone — the
|
|
59
|
+
* agent may have unscheduled the key in the window between the
|
|
60
|
+
* Croner trigger and this query).
|
|
61
|
+
*/
|
|
62
|
+
claimAndDeleteForFiring(key: string): Promise<ScheduledSubagentRow | undefined>;
|
|
63
|
+
/**
|
|
64
|
+
* List every row whose `fire_at` falls inside `[fromUtc, toUtc)`,
|
|
65
|
+
* ordered by `fire_at ASC`. Range is half-open so a `day` query
|
|
66
|
+
* built from {@link import("./Timezone").dayBoundsInZone} returns
|
|
67
|
+
* exactly the rows belonging to that day in the caller's zone.
|
|
68
|
+
*/
|
|
69
|
+
listInRange(fromUtc: string, toUtc: string): Promise<ScheduledSubagentRow[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Load every row whose `fire_at` is at or after `nowUtc`. Used by
|
|
72
|
+
* the host scheduler on daemon startup to re-install Croner jobs
|
|
73
|
+
* for every still-future scheduled subagent.
|
|
74
|
+
*/
|
|
75
|
+
listFuture(nowUtc: string): Promise<ScheduledSubagentRow[]>;
|
|
76
|
+
/**
|
|
77
|
+
* Delete every row whose `fire_at` is strictly before `nowUtc`.
|
|
78
|
+
* Returns the number of rows removed. Used by the host scheduler
|
|
79
|
+
* at startup to silently drop missed wake-ups (matches missed-cron
|
|
80
|
+
* behavior).
|
|
81
|
+
*/
|
|
82
|
+
deletePastDue(nowUtc: string): Promise<number>;
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to {@link SCHEDULED_SUBAGENTS_CHANNEL}. Parses the
|
|
85
|
+
* NOTIFY payload (`<key>:<op>`) and invokes `handler` with the
|
|
86
|
+
* parsed shape. Errors thrown by `handler` are logged and
|
|
87
|
+
* swallowed so one bad subscriber doesn't break others on the
|
|
88
|
+
* same channel.
|
|
89
|
+
*
|
|
90
|
+
* Returns a disposer; calling it unlistens. Idempotent.
|
|
91
|
+
*/
|
|
92
|
+
listen(handler: (notification: ScheduledSubagentNotification) => void | Promise<void>): Promise<ScheduledSubagentUnsubscribe>;
|
|
93
|
+
private dispatch;
|
|
94
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { SCHEDULED_SUBAGENTS_CHANNEL, SCHEMA_SQL } from "./Schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Domain client for the `scheduled_subagents` table — agent-requested
|
|
4
|
+
* one-off wake-ups. Used by both sides of the bus:
|
|
5
|
+
*
|
|
6
|
+
* - Container: the `schedule_subagent` / `unschedule_subagent` /
|
|
7
|
+
* `get_scheduled_subagents` tools call {@link upsert} / {@link deleteByKey}
|
|
8
|
+
* / {@link listInRange}.
|
|
9
|
+
* - Host: the `ScheduledSubagentScheduler` calls {@link listFuture} at
|
|
10
|
+
* startup, listens via {@link listen} for live changes, and uses
|
|
11
|
+
* {@link claimAndDeleteForFiring} to consume a row at fire time.
|
|
12
|
+
*
|
|
13
|
+
* All times are stored and returned as UTC ISO-8601 strings; the
|
|
14
|
+
* caller is responsible for projecting into the user's
|
|
15
|
+
* `core.timezone` for agent-facing surfaces.
|
|
16
|
+
*/
|
|
17
|
+
export class ScheduledSubagentBus {
|
|
18
|
+
connection;
|
|
19
|
+
log;
|
|
20
|
+
constructor(connection, log) {
|
|
21
|
+
this.connection = connection;
|
|
22
|
+
this.log = log;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Apply the bus-state schema (idempotent). Same SQL bundle as
|
|
26
|
+
* {@link import("./EventBus").EventBus.installSchema}, so calling
|
|
27
|
+
* any of the three install methods is sufficient on daemon start.
|
|
28
|
+
*/
|
|
29
|
+
async installSchema() {
|
|
30
|
+
await this.connection.getPool().query(SCHEMA_SQL);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Insert a scheduled subagent or replace the existing row with the
|
|
34
|
+
* same `key`. Returns the persisted row.
|
|
35
|
+
*
|
|
36
|
+
* Atomic UPSERT semantics: if the agent re-schedules a key, the
|
|
37
|
+
* old row is overwritten in one statement so the host scheduler
|
|
38
|
+
* sees exactly one NOTIFY and installs exactly one job.
|
|
39
|
+
*
|
|
40
|
+
* @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
|
|
41
|
+
*/
|
|
42
|
+
async upsert(row) {
|
|
43
|
+
const result = await this.connection.getPool().query(`INSERT INTO scheduled_subagents
|
|
44
|
+
(key, fire_at, topic, handler, prompt, payload, priority, privileged)
|
|
45
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
|
46
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
47
|
+
fire_at = EXCLUDED.fire_at,
|
|
48
|
+
topic = EXCLUDED.topic,
|
|
49
|
+
handler = EXCLUDED.handler,
|
|
50
|
+
prompt = EXCLUDED.prompt,
|
|
51
|
+
payload = EXCLUDED.payload,
|
|
52
|
+
priority = EXCLUDED.priority,
|
|
53
|
+
privileged = EXCLUDED.privileged
|
|
54
|
+
RETURNING *`, [
|
|
55
|
+
row.key,
|
|
56
|
+
row.fireAt,
|
|
57
|
+
row.topic,
|
|
58
|
+
row.handler,
|
|
59
|
+
row.prompt ?? null,
|
|
60
|
+
JSON.stringify(row.payload ?? {}),
|
|
61
|
+
row.priority ?? 50,
|
|
62
|
+
row.privileged ?? false,
|
|
63
|
+
]);
|
|
64
|
+
return mapRow(result.rows[0]);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Remove a row by key. Returns `true` when a row was removed,
|
|
68
|
+
* `false` when no row existed for the key.
|
|
69
|
+
*/
|
|
70
|
+
async deleteByKey(key) {
|
|
71
|
+
const result = await this.connection
|
|
72
|
+
.getPool()
|
|
73
|
+
.query(`DELETE FROM scheduled_subagents WHERE key = $1`, [key]);
|
|
74
|
+
return (result.rowCount ?? 0) > 0;
|
|
75
|
+
}
|
|
76
|
+
/** Fetch a row by key, or `undefined` when missing. */
|
|
77
|
+
async getByKey(key) {
|
|
78
|
+
const result = await this.connection
|
|
79
|
+
.getPool()
|
|
80
|
+
.query(`SELECT * FROM scheduled_subagents WHERE key = $1`, [key]);
|
|
81
|
+
return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Atomic claim-and-delete for the firing path: DELETE the row by
|
|
85
|
+
* key and return it (or `undefined` if it was already gone — the
|
|
86
|
+
* agent may have unscheduled the key in the window between the
|
|
87
|
+
* Croner trigger and this query).
|
|
88
|
+
*/
|
|
89
|
+
async claimAndDeleteForFiring(key) {
|
|
90
|
+
const result = await this.connection
|
|
91
|
+
.getPool()
|
|
92
|
+
.query(`DELETE FROM scheduled_subagents WHERE key = $1 RETURNING *`, [key]);
|
|
93
|
+
return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* List every row whose `fire_at` falls inside `[fromUtc, toUtc)`,
|
|
97
|
+
* ordered by `fire_at ASC`. Range is half-open so a `day` query
|
|
98
|
+
* built from {@link import("./Timezone").dayBoundsInZone} returns
|
|
99
|
+
* exactly the rows belonging to that day in the caller's zone.
|
|
100
|
+
*/
|
|
101
|
+
async listInRange(fromUtc, toUtc) {
|
|
102
|
+
const result = await this.connection.getPool().query(`SELECT * FROM scheduled_subagents
|
|
103
|
+
WHERE fire_at >= $1 AND fire_at < $2
|
|
104
|
+
ORDER BY fire_at ASC`, [fromUtc, toUtc]);
|
|
105
|
+
return result.rows.map(mapRow);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Load every row whose `fire_at` is at or after `nowUtc`. Used by
|
|
109
|
+
* the host scheduler on daemon startup to re-install Croner jobs
|
|
110
|
+
* for every still-future scheduled subagent.
|
|
111
|
+
*/
|
|
112
|
+
async listFuture(nowUtc) {
|
|
113
|
+
const result = await this.connection.getPool().query(`SELECT * FROM scheduled_subagents
|
|
114
|
+
WHERE fire_at >= $1
|
|
115
|
+
ORDER BY fire_at ASC`, [nowUtc]);
|
|
116
|
+
return result.rows.map(mapRow);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Delete every row whose `fire_at` is strictly before `nowUtc`.
|
|
120
|
+
* Returns the number of rows removed. Used by the host scheduler
|
|
121
|
+
* at startup to silently drop missed wake-ups (matches missed-cron
|
|
122
|
+
* behavior).
|
|
123
|
+
*/
|
|
124
|
+
async deletePastDue(nowUtc) {
|
|
125
|
+
const result = await this.connection
|
|
126
|
+
.getPool()
|
|
127
|
+
.query(`DELETE FROM scheduled_subagents WHERE fire_at < $1`, [nowUtc]);
|
|
128
|
+
return result.rowCount ?? 0;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Subscribe to {@link SCHEDULED_SUBAGENTS_CHANNEL}. Parses the
|
|
132
|
+
* NOTIFY payload (`<key>:<op>`) and invokes `handler` with the
|
|
133
|
+
* parsed shape. Errors thrown by `handler` are logged and
|
|
134
|
+
* swallowed so one bad subscriber doesn't break others on the
|
|
135
|
+
* same channel.
|
|
136
|
+
*
|
|
137
|
+
* Returns a disposer; calling it unlistens. Idempotent.
|
|
138
|
+
*/
|
|
139
|
+
async listen(handler) {
|
|
140
|
+
const wrapper = (payload) => {
|
|
141
|
+
this.log?.debug({ channel: SCHEDULED_SUBAGENTS_CHANNEL, payload }, "NOTIFY scheduled_subagents_changed");
|
|
142
|
+
void this.dispatch(payload, handler);
|
|
143
|
+
};
|
|
144
|
+
await this.connection.listen(SCHEDULED_SUBAGENTS_CHANNEL, wrapper);
|
|
145
|
+
let disposed = false;
|
|
146
|
+
return async () => {
|
|
147
|
+
if (disposed) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
disposed = true;
|
|
151
|
+
await this.connection.unlisten(SCHEDULED_SUBAGENTS_CHANNEL, wrapper);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async dispatch(payload, handler) {
|
|
155
|
+
const colon = payload.lastIndexOf(":");
|
|
156
|
+
if (colon < 0) {
|
|
157
|
+
this.log?.warn({ payload }, "ScheduledSubagentBus: malformed NOTIFY payload (no colon)");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const key = payload.slice(0, colon);
|
|
161
|
+
const op = payload.slice(colon + 1);
|
|
162
|
+
if (op !== "u" && op !== "d") {
|
|
163
|
+
this.log?.warn({ payload, op }, "ScheduledSubagentBus: unknown op in NOTIFY payload");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await handler({ key, op });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
this.log?.error({ key, op, err: err instanceof Error ? err.message : String(err) }, "ScheduledSubagentBus listen handler error");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Convert a snake_case raw row into the camelCase domain shape. */
|
|
175
|
+
function mapRow(raw) {
|
|
176
|
+
return {
|
|
177
|
+
key: raw.key,
|
|
178
|
+
fireAt: raw.fire_at.toISOString(),
|
|
179
|
+
topic: raw.topic,
|
|
180
|
+
handler: raw.handler,
|
|
181
|
+
prompt: raw.prompt,
|
|
182
|
+
payload: raw.payload,
|
|
183
|
+
priority: raw.priority,
|
|
184
|
+
privileged: raw.privileged,
|
|
185
|
+
createdAt: raw.created_at,
|
|
186
|
+
};
|
|
187
|
+
}
|