@sigrun/ext-gateway 0.0.0-stage → 0.1.1
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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +111 -0
- package/dist/interactions/chat.d.ts +20 -0
- package/dist/interactions/chat.js +155 -0
- package/dist/interactions/prompts.d.ts +42 -0
- package/dist/interactions/prompts.js +96 -0
- package/dist/interactions/receipts.d.ts +6 -0
- package/dist/interactions/receipts.js +36 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +57 -0
- package/dist/outbound/delivery.d.ts +124 -0
- package/dist/outbound/delivery.js +387 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +80 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +8 -0
- package/dist/session-meta.js +21 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +61 -0
- package/dist/state/identities.js +131 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +152 -0
- package/dist/state/ledger.js +207 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- package/package.json +54 -5
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-channel supervisor (spec §8.12): a backoff state machine over
|
|
3
|
+
* ONE channel adapter. `start` resolves once the first attempt settled
|
|
4
|
+
* (running or backoff — a failing adapter never wedges boot); retries
|
|
5
|
+
* ride the injected timer.
|
|
6
|
+
*
|
|
7
|
+
* Pinned values (the plan's Global Constraints): the backoff delay
|
|
8
|
+
* starts at 1000 ms and doubles per consecutive failure, capped at
|
|
9
|
+
* 300000 ms, plus jitter from the injected `random()` — jitter only
|
|
10
|
+
* ever ADDS time (the delay stays within [base, min(2·base, cap)]), so
|
|
11
|
+
* a retry never fires early against the pinned floor. 600000 ms of
|
|
12
|
+
* running RESETS the delay (a healthy stretch earns a fresh 1000 ms).
|
|
13
|
+
*
|
|
14
|
+
* State machine: a `not_configured` adapter is never started (terminal
|
|
15
|
+
* state, the contract's rule). A rejected `start` and a later `ctx.fail`
|
|
16
|
+
* both enter `backoff` and schedule ONE retry — the failure path is
|
|
17
|
+
* event-driven, not a polling loop, so a healthy channel owns zero
|
|
18
|
+
* timers. `stop()` cancels the pending retry, waits for an in-flight
|
|
19
|
+
* attempt to settle, and awaits the adapter's optional `stop`.
|
|
20
|
+
*/
|
|
21
|
+
import type { ChannelContext, ChannelContribution } from "@sigrun/sigrun/extension";
|
|
22
|
+
import type { EngineLog } from "./log.js";
|
|
23
|
+
/** The supervision state machine's states (spec §8.12 status values).
|
|
24
|
+
* `failed` is the engine-side CONFIG-ERROR state — a supervisor never
|
|
25
|
+
* enters it (see engine.ts's static rows). */
|
|
26
|
+
export type ChannelState = "stopped" | "not_configured" | "connecting" | "running" | "backoff" | "failed";
|
|
27
|
+
/** The status-report callback a started adapter drives (the contract's
|
|
28
|
+
* `ChannelContext.status` parameter — only the states a LIVE adapter
|
|
29
|
+
* can report; `backoff`/`failed`/`stopped` are supervisor-owned). */
|
|
30
|
+
export type StatusSink = (state: {
|
|
31
|
+
readonly state: "connecting" | "running";
|
|
32
|
+
readonly detail?: string;
|
|
33
|
+
}) => void;
|
|
34
|
+
/** One channel's status row (the admin status route's shape). */
|
|
35
|
+
export interface ChannelStatus {
|
|
36
|
+
readonly state: ChannelState;
|
|
37
|
+
readonly detail?: string;
|
|
38
|
+
/** Clock ms when the row entered its current state. */
|
|
39
|
+
readonly since: number;
|
|
40
|
+
readonly lastError?: string;
|
|
41
|
+
readonly counts: {
|
|
42
|
+
readonly admitted: number;
|
|
43
|
+
readonly dropped: number;
|
|
44
|
+
readonly delivered: number;
|
|
45
|
+
readonly failed: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The supervisor's injectable dependencies. */
|
|
49
|
+
export interface SupervisorDeps {
|
|
50
|
+
/**
|
|
51
|
+
* Build the ChannelContext the adapter's `start` receives, wiring the
|
|
52
|
+
* engine-owned members (`receive`/`action`/`log`) and `status` to
|
|
53
|
+
* `report`. The supervisor wraps the result's `fail` (failures route
|
|
54
|
+
* here); every other member passes through verbatim.
|
|
55
|
+
*/
|
|
56
|
+
makeContext(signal: AbortSignal, report: StatusSink): ChannelContext;
|
|
57
|
+
/** Clock ms. */
|
|
58
|
+
now(): number;
|
|
59
|
+
/** [0, 1) — the backoff jitter source. */
|
|
60
|
+
random(): number;
|
|
61
|
+
/** Schedule `fn` at `ms`; the return value cancels it. */
|
|
62
|
+
setTimer(fn: () => void, ms: number): () => void;
|
|
63
|
+
readonly log: EngineLog;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* One channel adapter's supervisor: constructed per channel at engine
|
|
67
|
+
* `start`, `start()` awaited by the engine, `stop()` awaited at engine
|
|
68
|
+
* stop. Not restartable after `stop()` (the engine stops once per boot).
|
|
69
|
+
*/
|
|
70
|
+
export declare class Supervisor {
|
|
71
|
+
private readonly entry;
|
|
72
|
+
private readonly deps;
|
|
73
|
+
private state;
|
|
74
|
+
private stateSince;
|
|
75
|
+
private detail;
|
|
76
|
+
private lastError;
|
|
77
|
+
private readonly counts;
|
|
78
|
+
/** The delay head for the NEXT retry (doubling bookkeeping). */
|
|
79
|
+
private delayMs;
|
|
80
|
+
/** When the channel entered running (0 = not running now). */
|
|
81
|
+
private runningSince;
|
|
82
|
+
/**
|
|
83
|
+
* When the channel became unhealthy (the FIRST failure after a
|
|
84
|
+
* healthy stretch). Persists across retry cycles — a perpetually
|
|
85
|
+
* failing channel's health dwell must not reset per attempt — and
|
|
86
|
+
* clears only when a retry reaches running.
|
|
87
|
+
*/
|
|
88
|
+
private unhealthySince;
|
|
89
|
+
private cancelTimer;
|
|
90
|
+
private stopped;
|
|
91
|
+
private signal;
|
|
92
|
+
/** The in-flight start attempt (stop awaits it before settling). */
|
|
93
|
+
private attempt;
|
|
94
|
+
constructor(entry: {
|
|
95
|
+
extension: string;
|
|
96
|
+
channel: ChannelContribution;
|
|
97
|
+
}, deps: SupervisorDeps);
|
|
98
|
+
/** The channel id (the status route's key). */
|
|
99
|
+
get id(): string;
|
|
100
|
+
/** The extension slug that contributed the channel (logging). */
|
|
101
|
+
get extension(): string;
|
|
102
|
+
/** The current status row (a copy — callers never mutate internals). */
|
|
103
|
+
status(): ChannelStatus;
|
|
104
|
+
/**
|
|
105
|
+
* Begin supervision: resolves once the first attempt settled (running
|
|
106
|
+
* or backoff). A `not_configured` adapter resolves immediately and is
|
|
107
|
+
* never started.
|
|
108
|
+
*/
|
|
109
|
+
start(signal: AbortSignal): Promise<void>;
|
|
110
|
+
/** Stop supervision: cancel the retry, await the in-flight attempt and
|
|
111
|
+
* the adapter's optional `stop`. Idempotent. */
|
|
112
|
+
stop(): Promise<void>;
|
|
113
|
+
/** Count one admitted outcome (the engine's receive path reports). */
|
|
114
|
+
countAdmitted(): void;
|
|
115
|
+
/** Count one dropped outcome. */
|
|
116
|
+
countDropped(): void;
|
|
117
|
+
/** Count one delivered message (the outbound path, Task 5). */
|
|
118
|
+
countDelivered(): void;
|
|
119
|
+
/** Count one failed ledger entry (Task 5's worker reports). */
|
|
120
|
+
countFailed(): void;
|
|
121
|
+
/**
|
|
122
|
+
* Unhealthy dwell at `now`: time since the channel first became
|
|
123
|
+
* unhealthy WHILE it sits in `backoff` (the engine's §8.12 health
|
|
124
|
+
* input). 0 when healthy — a config-error channel never reaches here
|
|
125
|
+
* (it is an engine-side static row, never a supervisor).
|
|
126
|
+
*/
|
|
127
|
+
unhealthyMs(now: number): number;
|
|
128
|
+
/** Mark the channel running (the engine's own transitions, tests). */
|
|
129
|
+
markRunning(detail?: string): void;
|
|
130
|
+
/**
|
|
131
|
+
* One start attempt: connecting → `channel.start(ctx)` → running, or
|
|
132
|
+
* backoff through {@link onFailure}. Never throws.
|
|
133
|
+
*/
|
|
134
|
+
private runAttempt;
|
|
135
|
+
/**
|
|
136
|
+
* Build the adapter's context: the engine's `makeContext` result with
|
|
137
|
+
* `fail` intercepted (a post-connect failure routes into supervision,
|
|
138
|
+
* never into the engine's log-only base).
|
|
139
|
+
*/
|
|
140
|
+
private buildContext;
|
|
141
|
+
/** The adapter's own connecting/running report. */
|
|
142
|
+
private onReport;
|
|
143
|
+
/**
|
|
144
|
+
* A start rejection or `ctx.fail`: enter backoff and schedule exactly
|
|
145
|
+
* ONE retry. Duplicates (a second `ctx.fail` while a retry is already
|
|
146
|
+
* pending, or an in-flight start's rejection after its `ctx.fail`)
|
|
147
|
+
* are ignored — the pending retry owns the recovery.
|
|
148
|
+
*/
|
|
149
|
+
private onFailure;
|
|
150
|
+
/** Schedule the retry at the doubling delay plus jitter. */
|
|
151
|
+
private scheduleRetry;
|
|
152
|
+
private setState;
|
|
153
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-channel supervisor (spec §8.12): a backoff state machine over
|
|
3
|
+
* ONE channel adapter. `start` resolves once the first attempt settled
|
|
4
|
+
* (running or backoff — a failing adapter never wedges boot); retries
|
|
5
|
+
* ride the injected timer.
|
|
6
|
+
*
|
|
7
|
+
* Pinned values (the plan's Global Constraints): the backoff delay
|
|
8
|
+
* starts at 1000 ms and doubles per consecutive failure, capped at
|
|
9
|
+
* 300000 ms, plus jitter from the injected `random()` — jitter only
|
|
10
|
+
* ever ADDS time (the delay stays within [base, min(2·base, cap)]), so
|
|
11
|
+
* a retry never fires early against the pinned floor. 600000 ms of
|
|
12
|
+
* running RESETS the delay (a healthy stretch earns a fresh 1000 ms).
|
|
13
|
+
*
|
|
14
|
+
* State machine: a `not_configured` adapter is never started (terminal
|
|
15
|
+
* state, the contract's rule). A rejected `start` and a later `ctx.fail`
|
|
16
|
+
* both enter `backoff` and schedule ONE retry — the failure path is
|
|
17
|
+
* event-driven, not a polling loop, so a healthy channel owns zero
|
|
18
|
+
* timers. `stop()` cancels the pending retry, waits for an in-flight
|
|
19
|
+
* attempt to settle, and awaits the adapter's optional `stop`.
|
|
20
|
+
*/
|
|
21
|
+
/** Initial backoff delay (ms) — the spec's "1 s doubling". */
|
|
22
|
+
const INITIAL_BACKOFF_MS = 1_000;
|
|
23
|
+
/** Backoff cap (ms) — the spec's "5 min". */
|
|
24
|
+
const MAX_BACKOFF_MS = 300_000;
|
|
25
|
+
/** Running time (ms) after which the backoff delay resets — the spec's
|
|
26
|
+
* "reset after 10 min running". */
|
|
27
|
+
const RUNNING_RESET_MS = 600_000;
|
|
28
|
+
/**
|
|
29
|
+
* One channel adapter's supervisor: constructed per channel at engine
|
|
30
|
+
* `start`, `start()` awaited by the engine, `stop()` awaited at engine
|
|
31
|
+
* stop. Not restartable after `stop()` (the engine stops once per boot).
|
|
32
|
+
*/
|
|
33
|
+
export class Supervisor {
|
|
34
|
+
entry;
|
|
35
|
+
deps;
|
|
36
|
+
state = "stopped";
|
|
37
|
+
stateSince;
|
|
38
|
+
detail;
|
|
39
|
+
lastError;
|
|
40
|
+
counts = {
|
|
41
|
+
admitted: 0,
|
|
42
|
+
dropped: 0,
|
|
43
|
+
delivered: 0,
|
|
44
|
+
failed: 0,
|
|
45
|
+
};
|
|
46
|
+
/** The delay head for the NEXT retry (doubling bookkeeping). */
|
|
47
|
+
delayMs = INITIAL_BACKOFF_MS;
|
|
48
|
+
/** When the channel entered running (0 = not running now). */
|
|
49
|
+
runningSince = 0;
|
|
50
|
+
/**
|
|
51
|
+
* When the channel became unhealthy (the FIRST failure after a
|
|
52
|
+
* healthy stretch). Persists across retry cycles — a perpetually
|
|
53
|
+
* failing channel's health dwell must not reset per attempt — and
|
|
54
|
+
* clears only when a retry reaches running.
|
|
55
|
+
*/
|
|
56
|
+
unhealthySince;
|
|
57
|
+
cancelTimer;
|
|
58
|
+
stopped = false;
|
|
59
|
+
signal;
|
|
60
|
+
/** The in-flight start attempt (stop awaits it before settling). */
|
|
61
|
+
attempt;
|
|
62
|
+
constructor(entry, deps) {
|
|
63
|
+
this.entry = entry;
|
|
64
|
+
this.deps = deps;
|
|
65
|
+
this.stateSince = deps.now();
|
|
66
|
+
}
|
|
67
|
+
/** The channel id (the status route's key). */
|
|
68
|
+
get id() {
|
|
69
|
+
return this.entry.channel.id;
|
|
70
|
+
}
|
|
71
|
+
/** The extension slug that contributed the channel (logging). */
|
|
72
|
+
get extension() {
|
|
73
|
+
return this.entry.extension;
|
|
74
|
+
}
|
|
75
|
+
/** The current status row (a copy — callers never mutate internals). */
|
|
76
|
+
status() {
|
|
77
|
+
return {
|
|
78
|
+
state: this.state,
|
|
79
|
+
...(this.detail === undefined ? {} : { detail: this.detail }),
|
|
80
|
+
since: this.stateSince,
|
|
81
|
+
...(this.lastError === undefined ? {} : { lastError: this.lastError }),
|
|
82
|
+
counts: { ...this.counts },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Begin supervision: resolves once the first attempt settled (running
|
|
87
|
+
* or backoff). A `not_configured` adapter resolves immediately and is
|
|
88
|
+
* never started.
|
|
89
|
+
*/
|
|
90
|
+
async start(signal) {
|
|
91
|
+
this.signal = signal;
|
|
92
|
+
const readiness = await this.entry.channel.configured();
|
|
93
|
+
if (readiness.status !== "ready") {
|
|
94
|
+
// not_configured (the readiness union's only other member): the
|
|
95
|
+
// adapter is never started (the contract's rule) and the detail
|
|
96
|
+
// rides the status row.
|
|
97
|
+
this.setState("not_configured", readiness.detail);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await this.runAttempt();
|
|
101
|
+
}
|
|
102
|
+
/** Stop supervision: cancel the retry, await the in-flight attempt and
|
|
103
|
+
* the adapter's optional `stop`. Idempotent. */
|
|
104
|
+
async stop() {
|
|
105
|
+
if (this.stopped)
|
|
106
|
+
return;
|
|
107
|
+
this.stopped = true;
|
|
108
|
+
this.cancelTimer?.();
|
|
109
|
+
this.cancelTimer = undefined;
|
|
110
|
+
const attempt = this.attempt;
|
|
111
|
+
if (attempt !== undefined)
|
|
112
|
+
await attempt.catch(() => { });
|
|
113
|
+
this.runningSince = 0;
|
|
114
|
+
if (this.entry.channel.stop !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
await this.entry.channel.stop();
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.deps.log.error(`channel "${this.id}" stop failed`, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
this.setState("stopped");
|
|
123
|
+
}
|
|
124
|
+
/** Count one admitted outcome (the engine's receive path reports). */
|
|
125
|
+
countAdmitted() {
|
|
126
|
+
this.counts.admitted++;
|
|
127
|
+
}
|
|
128
|
+
/** Count one dropped outcome. */
|
|
129
|
+
countDropped() {
|
|
130
|
+
this.counts.dropped++;
|
|
131
|
+
}
|
|
132
|
+
/** Count one delivered message (the outbound path, Task 5). */
|
|
133
|
+
countDelivered() {
|
|
134
|
+
this.counts.delivered++;
|
|
135
|
+
}
|
|
136
|
+
/** Count one failed ledger entry (Task 5's worker reports). */
|
|
137
|
+
countFailed() {
|
|
138
|
+
this.counts.failed++;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Unhealthy dwell at `now`: time since the channel first became
|
|
142
|
+
* unhealthy WHILE it sits in `backoff` (the engine's §8.12 health
|
|
143
|
+
* input). 0 when healthy — a config-error channel never reaches here
|
|
144
|
+
* (it is an engine-side static row, never a supervisor).
|
|
145
|
+
*/
|
|
146
|
+
unhealthyMs(now) {
|
|
147
|
+
if (this.state !== "backoff")
|
|
148
|
+
return 0;
|
|
149
|
+
return now - (this.unhealthySince ?? this.stateSince);
|
|
150
|
+
}
|
|
151
|
+
/** Mark the channel running (the engine's own transitions, tests). */
|
|
152
|
+
markRunning(detail) {
|
|
153
|
+
if (this.runningSince === 0)
|
|
154
|
+
this.runningSince = this.deps.now();
|
|
155
|
+
this.unhealthySince = undefined;
|
|
156
|
+
this.setState("running", detail);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One start attempt: connecting → `channel.start(ctx)` → running, or
|
|
160
|
+
* backoff through {@link onFailure}. Never throws.
|
|
161
|
+
*/
|
|
162
|
+
async runAttempt() {
|
|
163
|
+
const signal = this.signal;
|
|
164
|
+
if (signal === undefined || this.stopped)
|
|
165
|
+
return;
|
|
166
|
+
this.setState("connecting");
|
|
167
|
+
const ctx = this.buildContext(signal);
|
|
168
|
+
const settled = (async () => {
|
|
169
|
+
try {
|
|
170
|
+
await this.entry.channel.start(ctx);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
this.onFailure(error);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (this.stopped)
|
|
177
|
+
return;
|
|
178
|
+
// A `ctx.fail` that fired while a hanging start later resolved:
|
|
179
|
+
// the scheduled retry stands — never overwrite backoff with
|
|
180
|
+
// running out from under it.
|
|
181
|
+
if (this.state === "connecting")
|
|
182
|
+
this.markRunning();
|
|
183
|
+
})();
|
|
184
|
+
this.attempt = settled;
|
|
185
|
+
try {
|
|
186
|
+
await settled;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// The inner function catches adapter failures; a throw past it
|
|
190
|
+
// is a supervision bug — logged, never propagated (the loop
|
|
191
|
+
// must keep serving the other channels).
|
|
192
|
+
this.deps.log.error(`channel "${this.id}" supervision error`, error);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
this.attempt = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build the adapter's context: the engine's `makeContext` result with
|
|
200
|
+
* `fail` intercepted (a post-connect failure routes into supervision,
|
|
201
|
+
* never into the engine's log-only base).
|
|
202
|
+
*/
|
|
203
|
+
buildContext(signal) {
|
|
204
|
+
const report = (state) => this.onReport(state);
|
|
205
|
+
const base = this.deps.makeContext(signal, report);
|
|
206
|
+
return {
|
|
207
|
+
...base,
|
|
208
|
+
fail: (error) => this.onFailure(error),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The adapter's own connecting/running report. */
|
|
212
|
+
onReport(state) {
|
|
213
|
+
if (this.stopped)
|
|
214
|
+
return;
|
|
215
|
+
if (state.state === "running")
|
|
216
|
+
this.markRunning(state.detail);
|
|
217
|
+
else
|
|
218
|
+
this.setState("connecting", state.detail);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A start rejection or `ctx.fail`: enter backoff and schedule exactly
|
|
222
|
+
* ONE retry. Duplicates (a second `ctx.fail` while a retry is already
|
|
223
|
+
* pending, or an in-flight start's rejection after its `ctx.fail`)
|
|
224
|
+
* are ignored — the pending retry owns the recovery.
|
|
225
|
+
*/
|
|
226
|
+
onFailure(error) {
|
|
227
|
+
if (this.stopped)
|
|
228
|
+
return;
|
|
229
|
+
if (this.state === "backoff")
|
|
230
|
+
return;
|
|
231
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
232
|
+
this.lastError = message;
|
|
233
|
+
this.deps.log.error(`channel "${this.id}" failed`, error);
|
|
234
|
+
// The running reset: a channel that held running for 600000 ms
|
|
235
|
+
// earns a fresh 1000 ms; anything shorter keeps the doubled delay.
|
|
236
|
+
if (this.runningSince !== 0) {
|
|
237
|
+
if (this.deps.now() - this.runningSince >= RUNNING_RESET_MS) {
|
|
238
|
+
this.delayMs = INITIAL_BACKOFF_MS;
|
|
239
|
+
}
|
|
240
|
+
this.runningSince = 0;
|
|
241
|
+
}
|
|
242
|
+
if (this.unhealthySince === undefined) {
|
|
243
|
+
this.unhealthySince = this.deps.now();
|
|
244
|
+
}
|
|
245
|
+
this.setState("backoff");
|
|
246
|
+
this.scheduleRetry();
|
|
247
|
+
// The doubling arms the NEXT failure (the retry this failure just
|
|
248
|
+
// scheduled rides the current, undoubled delay).
|
|
249
|
+
this.delayMs = Math.min(this.delayMs * 2, MAX_BACKOFF_MS);
|
|
250
|
+
}
|
|
251
|
+
/** Schedule the retry at the doubling delay plus jitter. */
|
|
252
|
+
scheduleRetry() {
|
|
253
|
+
const signal = this.signal;
|
|
254
|
+
if (signal === undefined)
|
|
255
|
+
return;
|
|
256
|
+
const base = Math.min(this.delayMs, MAX_BACKOFF_MS);
|
|
257
|
+
// Jitter ADDS up to the next doubling step: the delay stays in
|
|
258
|
+
// [base, min(2·base, cap)] — never below the pinned floor, never
|
|
259
|
+
// past the cap.
|
|
260
|
+
const ceiling = Math.min(2 * base, MAX_BACKOFF_MS);
|
|
261
|
+
const delay = base + this.deps.random() * (ceiling - base);
|
|
262
|
+
this.cancelTimer?.();
|
|
263
|
+
this.cancelTimer = this.deps.setTimer(() => {
|
|
264
|
+
this.cancelTimer = undefined;
|
|
265
|
+
void this.runAttempt();
|
|
266
|
+
}, delay);
|
|
267
|
+
}
|
|
268
|
+
setState(state, detail) {
|
|
269
|
+
this.state = state;
|
|
270
|
+
this.stateSince = this.deps.now();
|
|
271
|
+
this.detail = detail;
|
|
272
|
+
}
|
|
273
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat-facing agent tools (spec §8.9). Policy failures are ordinary
|
|
3
|
+
* error results, never exceptions. Sends persist to the delivery ledger
|
|
4
|
+
* before waiting for a receipt, so a timeout leaves honest queued work.
|
|
5
|
+
*/
|
|
6
|
+
import { type ToolRegistration } from "@earendil-works/pi-durable";
|
|
7
|
+
import type { ChannelContribution, SigrunHost } from "@sigrun/sigrun/extension";
|
|
8
|
+
import type { BindingStore } from "./state/bindings.js";
|
|
9
|
+
import type { IdentityStore } from "./state/identities.js";
|
|
10
|
+
import type { Ledger } from "./state/ledger.js";
|
|
11
|
+
import type { GatewayConfig } from "./config.js";
|
|
12
|
+
import type { EngineLog } from "./log.js";
|
|
13
|
+
export declare const RECEIPT_WAIT_MS = 30000;
|
|
14
|
+
export interface ToolDeps {
|
|
15
|
+
readonly host: SigrunHost;
|
|
16
|
+
readonly bindings: BindingStore;
|
|
17
|
+
readonly identities: IdentityStore;
|
|
18
|
+
readonly ledger: Ledger;
|
|
19
|
+
channelOf(channel: string): ChannelContribution | undefined;
|
|
20
|
+
readConfig(): Promise<GatewayConfig>;
|
|
21
|
+
readonly log: EngineLog;
|
|
22
|
+
/** Poke the channel's worker after enqueueing. */
|
|
23
|
+
readonly kick?: () => void;
|
|
24
|
+
readonly receiptWaitMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Build both tools over the engine's shared state. */
|
|
27
|
+
export declare function createGatewayTools(deps: ToolDeps): readonly ToolRegistration[];
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat-facing agent tools (spec §8.9). Policy failures are ordinary
|
|
3
|
+
* error results, never exceptions. Sends persist to the delivery ledger
|
|
4
|
+
* before waiting for a receipt, so a timeout leaves honest queued work.
|
|
5
|
+
*/
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { defineTool, } from "@earendil-works/pi-durable";
|
|
8
|
+
import { resolveTargets } from "./jobs.js";
|
|
9
|
+
export const RECEIPT_WAIT_MS = 30_000;
|
|
10
|
+
const RECEIPT_POLL_MS = 25;
|
|
11
|
+
/** Build both tools over the engine's shared state. */
|
|
12
|
+
export function createGatewayTools(deps) {
|
|
13
|
+
const sendMessage = defineTool({
|
|
14
|
+
name: "send_message",
|
|
15
|
+
description: "Send a message to one of the acting user's linked chats. `to` is a channel or channel:chatId; files are workspace paths.",
|
|
16
|
+
parameters: Type.Object({
|
|
17
|
+
to: Type.String({ description: "Target channel or channel:chatId" }),
|
|
18
|
+
text: Type.String({ description: "Message text" }),
|
|
19
|
+
files: Type.Optional(Type.Array(Type.String(), {
|
|
20
|
+
description: "Workspace paths of files to attach",
|
|
21
|
+
})),
|
|
22
|
+
}),
|
|
23
|
+
async execute(args, api, context) {
|
|
24
|
+
try {
|
|
25
|
+
const owner = await deps.host.identity.actingUser(api.conversationId);
|
|
26
|
+
if (owner === undefined)
|
|
27
|
+
return toolError("The acting user could not be resolved.");
|
|
28
|
+
if (deps.identities.homesOf(owner).length === 0) {
|
|
29
|
+
return toolError(`The acting user ${owner} has no linked chats.`);
|
|
30
|
+
}
|
|
31
|
+
const targets = await resolveTargets(deps, owner, [args.to]);
|
|
32
|
+
const target = targets.resolved[0];
|
|
33
|
+
if (target === undefined) {
|
|
34
|
+
return toolError(targets.rejected[0]?.reason ?? "The target chat is not available.");
|
|
35
|
+
}
|
|
36
|
+
const channel = deps.channelOf(target.channel);
|
|
37
|
+
if (channel === undefined) {
|
|
38
|
+
return toolError(`Channel "${target.channel}" is not running.`);
|
|
39
|
+
}
|
|
40
|
+
const config = await deps.readConfig();
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const path of args.files ?? []) {
|
|
43
|
+
const read = await deps.host.conversations.readFile(api.conversationId, path);
|
|
44
|
+
if (read.bytes.byteLength > config.media.maxBytes) {
|
|
45
|
+
return toolError(`File "${path}" is too large (limit ${config.media.maxBytes} bytes).`);
|
|
46
|
+
}
|
|
47
|
+
const kind = mediaKind(read.mime, path);
|
|
48
|
+
const supported = channel.capabilities.media ?? [];
|
|
49
|
+
if (!supported.includes(kind) && !supported.includes("document")) {
|
|
50
|
+
return toolError(`Channel "${channel.label}" does not support ${kind} files or document fallback.`);
|
|
51
|
+
}
|
|
52
|
+
files.push({
|
|
53
|
+
conversationId: api.conversationId,
|
|
54
|
+
path,
|
|
55
|
+
name: fileName(path),
|
|
56
|
+
...(read.mime === undefined ? {} : { mime: read.mime }),
|
|
57
|
+
bytes: read.bytes,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const entry = await deps.ledger.enqueue({
|
|
61
|
+
channel: target.channel,
|
|
62
|
+
chat: target.chat,
|
|
63
|
+
kind: "notice",
|
|
64
|
+
tag: `tool:${api.callId}`,
|
|
65
|
+
message: {
|
|
66
|
+
chat: target.chat,
|
|
67
|
+
text: args.text,
|
|
68
|
+
...(files.length === 0
|
|
69
|
+
? {}
|
|
70
|
+
: {
|
|
71
|
+
files: files.map((file) => ({
|
|
72
|
+
name: file.name,
|
|
73
|
+
...(file.mime === undefined ? {} : { mime: file.mime }),
|
|
74
|
+
bytes: file.bytes,
|
|
75
|
+
})),
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
...(files.length === 0 ? {} : { files }),
|
|
79
|
+
});
|
|
80
|
+
if (entry === undefined)
|
|
81
|
+
return toolError("The message could not be queued.");
|
|
82
|
+
deps.kick?.();
|
|
83
|
+
const receipt = await waitForReceipt(deps.ledger, entry, deps.receiptWaitMs ?? RECEIPT_WAIT_MS, context.abortSignal ?? new AbortController().signal);
|
|
84
|
+
if (receipt === "failed") {
|
|
85
|
+
return toolError("Delivery failed; the message was not sent.");
|
|
86
|
+
}
|
|
87
|
+
if (receipt === undefined) {
|
|
88
|
+
return toolSuccess({ delivered: false, queued: true });
|
|
89
|
+
}
|
|
90
|
+
return toolSuccess({ delivered: true, messageIds: receipt });
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// Transport and host errors are also model-recoverable tool
|
|
94
|
+
// outcomes; logging retains the diagnostic policy errors lack.
|
|
95
|
+
deps.log.error("send_message failed", error);
|
|
96
|
+
return toolError(error instanceof Error ? error.message : String(error));
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const listChats = defineTool({
|
|
101
|
+
name: "list_chats",
|
|
102
|
+
description: "List chats the acting user may target with send_message.",
|
|
103
|
+
parameters: Type.Object({}),
|
|
104
|
+
async execute(_args, api) {
|
|
105
|
+
try {
|
|
106
|
+
const owner = await deps.host.identity.actingUser(api.conversationId);
|
|
107
|
+
if (owner === undefined)
|
|
108
|
+
return toolError("The acting user could not be resolved.");
|
|
109
|
+
const chats = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const home of deps.identities.homesOf(owner)) {
|
|
112
|
+
const channel = deps.channelOf(home.channel);
|
|
113
|
+
if (channel === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(`${home.channel}:${home.chat.chatId}`);
|
|
116
|
+
chats.push({
|
|
117
|
+
to: home.channel,
|
|
118
|
+
label: `${channel.label} direct message ${home.chat.chatId}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
for (const [key, binding] of Object.entries(deps.bindings.entries())) {
|
|
122
|
+
if (binding.dead === true)
|
|
123
|
+
continue;
|
|
124
|
+
const channelId = key.split(":", 1)[0];
|
|
125
|
+
if (channelId === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
const channel = deps.channelOf(channelId);
|
|
128
|
+
if (channel === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
const access = await deps.host.access.accessOf({ user: owner }, await deps.host.conversations.meta(binding.conversationId));
|
|
131
|
+
if (access !== "write")
|
|
132
|
+
continue;
|
|
133
|
+
const target = `${channelId}:${binding.chat.chatId}`;
|
|
134
|
+
if (seen.has(target))
|
|
135
|
+
continue;
|
|
136
|
+
seen.add(target);
|
|
137
|
+
chats.push({
|
|
138
|
+
to: target,
|
|
139
|
+
label: `${channel.label} ${binding.chat.chatType === "dm" ? "direct message" : "group"} ${binding.chat.chatId}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (chats.length === 0) {
|
|
143
|
+
return toolError(`The acting user ${owner} has no linked chats.`);
|
|
144
|
+
}
|
|
145
|
+
return toolSuccess({ chats });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
deps.log.error("list_chats failed", error);
|
|
149
|
+
return toolError(error instanceof Error ? error.message : String(error));
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return [sendMessage, listChats];
|
|
154
|
+
}
|
|
155
|
+
function toolError(message) {
|
|
156
|
+
return { output: [{ type: "text", text: message }], isError: true };
|
|
157
|
+
}
|
|
158
|
+
function toolSuccess(value) {
|
|
159
|
+
return { output: [{ type: "text", text: JSON.stringify(value) }] };
|
|
160
|
+
}
|
|
161
|
+
/** Poll the durable row so a same-process worker receipt resolves quickly. */
|
|
162
|
+
async function waitForReceipt(ledger, entry, waitMs, signal) {
|
|
163
|
+
const deadline = Date.now() + waitMs;
|
|
164
|
+
while (!signal.aborted) {
|
|
165
|
+
const current = ledger.list().find((candidate) => candidate.id === entry.id);
|
|
166
|
+
if (current?.state === "delivered")
|
|
167
|
+
return current.receipt ?? [];
|
|
168
|
+
if (current?.state === "failed")
|
|
169
|
+
return "failed";
|
|
170
|
+
if (Date.now() >= deadline)
|
|
171
|
+
return undefined;
|
|
172
|
+
await new Promise((resolve) => {
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
signal.removeEventListener("abort", abort);
|
|
175
|
+
resolve();
|
|
176
|
+
}, Math.min(RECEIPT_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
177
|
+
const abort = () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
signal.removeEventListener("abort", abort);
|
|
180
|
+
resolve();
|
|
181
|
+
};
|
|
182
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
function fileName(path) {
|
|
188
|
+
return path.split(/[\\/]/).pop() ?? "attachment";
|
|
189
|
+
}
|
|
190
|
+
function mediaKind(mime, path) {
|
|
191
|
+
if (mime?.startsWith("image/") === true)
|
|
192
|
+
return "image";
|
|
193
|
+
if (mime?.startsWith("audio/") === true)
|
|
194
|
+
return "audio";
|
|
195
|
+
if (mime?.startsWith("video/") === true)
|
|
196
|
+
return "video";
|
|
197
|
+
if (mime !== undefined)
|
|
198
|
+
return "document";
|
|
199
|
+
const extension = path.split(".").pop()?.toLowerCase();
|
|
200
|
+
if (extension !== undefined && ["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(extension))
|
|
201
|
+
return "image";
|
|
202
|
+
if (extension !== undefined && ["ogg", "mp3", "wav", "m4a"].includes(extension))
|
|
203
|
+
return "audio";
|
|
204
|
+
if (extension !== undefined && ["mp4", "webm", "mov"].includes(extension))
|
|
205
|
+
return "video";
|
|
206
|
+
return "document";
|
|
207
|
+
}
|