@fungi.computer/bird-dog 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +258 -2
- package/dist/effect.d.ts +191 -0
- package/dist/effect.js +181 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +8 -0
- package/dist/internal/promise-mail-room.d.ts +3 -0
- package/dist/internal/promise-mail-room.js +51 -0
- package/docs/api/README.md +14 -0
- package/docs/api/effect.md +481 -0
- package/docs/api/index.md +205 -0
- package/package.json +64 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,259 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BirdDog (`@fungi.computer/bird-dog`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
BirdDog is a durable mail room for AI agents. One agent sends work to another
|
|
4
|
+
agent's session, the work runs as a [Watchdog](../watchdog/README.md) job, and
|
|
5
|
+
when that job settles BirdDog delivers the reply to everyone on the envelope's
|
|
6
|
+
`replyTo` list. Replies sit in a durable outbox and retry on a bounded schedule
|
|
7
|
+
until the recipient accepts them.
|
|
8
|
+
|
|
9
|
+
It does two jobs:
|
|
10
|
+
|
|
11
|
+
- It translates addressed agent work into an opaque Watchdog job, and decodes
|
|
12
|
+
that job's payload back after Watchdog claims it.
|
|
13
|
+
- It fans out replies when the job settles, stores them in your outbox, and
|
|
14
|
+
delivers them with retries through Carrier's Post outbox
|
|
15
|
+
([`@fungi.computer/carrier/post`](../carrier/README.md)).
|
|
16
|
+
|
|
17
|
+
Every sender and recipient is an ordinary agent session. A parent and a child
|
|
18
|
+
agent are both plain agents to BirdDog. Their relationship lives in your code,
|
|
19
|
+
and all agent work uses Watchdog's `default` lane. You own storage, transport,
|
|
20
|
+
the clock and wake scheduling. BirdDog owns the envelope format, the reply keys
|
|
21
|
+
and the retry schedule.
|
|
22
|
+
|
|
23
|
+
## Install or set up
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @fungi.computer/bird-dog
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
BirdDog is ESM-only. It depends on `@fungi.computer/watchdog`,
|
|
30
|
+
`@fungi.computer/carrier` and `effect`, which npm installs for you. It does no
|
|
31
|
+
I/O of its own, so it runs in Node, Cloudflare Workers or any runtime that can
|
|
32
|
+
give it an outbox and a `post` function.
|
|
33
|
+
|
|
34
|
+
Entrypoints:
|
|
35
|
+
|
|
36
|
+
- `@fungi.computer/bird-dog`: the Promise API on `BirdDog` and its types.
|
|
37
|
+
- `@fungi.computer/bird-dog/effect`: the Effect-native mail room (`make`),
|
|
38
|
+
`settlementReplies`, the Effect Schema parsers and `BirdDogError`.
|
|
39
|
+
`settlementReplies` builds the same reply envelopes for a host-owned Post
|
|
40
|
+
transport.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
This sends work from a parent agent to a child agent, settles it, and delivers
|
|
45
|
+
the reply. The outbox uses Carrier’s deterministic memory helper, and `post`
|
|
46
|
+
prints the reply instead of sending it anywhere.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { BirdDog, type BirdDogMail } from "@fungi.computer/bird-dog";
|
|
50
|
+
import { createMemoryOutbox } from "@fungi.computer/carrier/testing";
|
|
51
|
+
|
|
52
|
+
const parent = {
|
|
53
|
+
kind: "agent",
|
|
54
|
+
agentId: "parent",
|
|
55
|
+
sessionId: "parent-session",
|
|
56
|
+
} as const;
|
|
57
|
+
const child = {
|
|
58
|
+
kind: "agent",
|
|
59
|
+
agentId: "child",
|
|
60
|
+
sessionId: "child-session",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
const room = await BirdDog.make({
|
|
64
|
+
outbox: createMemoryOutbox(),
|
|
65
|
+
post: async (mail: BirdDogMail) => {
|
|
66
|
+
console.log(`to ${mail.to.agentId}:\n${mail.content}`);
|
|
67
|
+
return true; // the recipient accepted it
|
|
68
|
+
},
|
|
69
|
+
scheduleWake: async (at) => {
|
|
70
|
+
console.log(`wake me at ${at}`);
|
|
71
|
+
},
|
|
72
|
+
now: () => Date.now(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 1. Turn accepted work into a Watchdog job. Hand this to Watchdog's ingest.
|
|
76
|
+
const job = BirdDog.agentJob({
|
|
77
|
+
work: {
|
|
78
|
+
jobId: "job-1",
|
|
79
|
+
sessionId: child.sessionId,
|
|
80
|
+
commandFactId: "command-1",
|
|
81
|
+
},
|
|
82
|
+
mail: {
|
|
83
|
+
key: "delegation-1",
|
|
84
|
+
to: child,
|
|
85
|
+
from: parent,
|
|
86
|
+
replyTo: [parent],
|
|
87
|
+
content: "Find out why the build is red",
|
|
88
|
+
createdAt: Date.now(),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 2. Your Watchdog executor decodes the payload and runs the child's work.
|
|
93
|
+
const payload = BirdDog.decodeAgentJob(job);
|
|
94
|
+
console.log(payload.work.commandFactId); // "command-1"
|
|
95
|
+
|
|
96
|
+
// 3. When Watchdog settles the job, queue the replies, then deliver them.
|
|
97
|
+
await room.fanOut({
|
|
98
|
+
job: { ...job, state: "settled", outcome: "completed" },
|
|
99
|
+
content: "A test fixture was missing. Fixed it.",
|
|
100
|
+
});
|
|
101
|
+
await room.drainOutbox();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
In a real host, the settled job comes from Watchdog's `tick()` result and
|
|
105
|
+
`drainOutbox()` runs from the wake you scheduled.
|
|
106
|
+
|
|
107
|
+
## Core concepts
|
|
108
|
+
|
|
109
|
+
**Addresses.** An agent address is `{ kind: "agent", agentId, sessionId }`. IDs
|
|
110
|
+
are 1 to 256 characters. There is no special kind for subagents.
|
|
111
|
+
|
|
112
|
+
**Mail.** A `BirdDogMail` has a stable `key`, a `to` and `from` address, a
|
|
113
|
+
`replyTo` list, string `content` and a `createdAt` time in milliseconds. The key
|
|
114
|
+
must identify the request across retries. BirdDog derives every reply key from
|
|
115
|
+
it.
|
|
116
|
+
|
|
117
|
+
**Agent jobs.** `BirdDog.agentJob({ work, mail? })` validates the input and
|
|
118
|
+
returns a queued Watchdog job. The job's `queue` is the target `sessionId`, its
|
|
119
|
+
lane is `default`, its priority is `0`, and its payload carries the `sessionId`,
|
|
120
|
+
the `commandFactId` and the optional mail. `work.recovery` passes straight
|
|
121
|
+
through to Watchdog's recovery policy. Because it is synchronous, you can call
|
|
122
|
+
it inside an owner transaction and pass the result to Watchdog's
|
|
123
|
+
`transactional.enqueueAcceptedJob`. `BirdDog.decodeAgentJob(job)` parses the
|
|
124
|
+
payload back on the executor side. Watchdog never reads it.
|
|
125
|
+
|
|
126
|
+
**Fan-out.** `fanOut({ job, content, diagnostic? })` takes a settled job, the
|
|
127
|
+
content owed to correspondents and the Session's terminal diagnostic. It writes
|
|
128
|
+
one reply per `replyTo` address into the outbox, keyed
|
|
129
|
+
`outbox:<mail key>:reply:<index>`, then schedules one wake for the earliest.
|
|
130
|
+
Each reply's `from` is the mail's recipient, its `content` is your content
|
|
131
|
+
verbatim (or, when it is empty,
|
|
132
|
+
`The delegated work ended <outcome> without reply text.`, so a reply is always
|
|
133
|
+
admissible text), and its typed `reply` field carries the settlement facts:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
{
|
|
137
|
+
commandId: "command-1", // the settled work's commandFactId
|
|
138
|
+
outcome: "failed", // the exact Watchdog outcome
|
|
139
|
+
diagnostic: "provider_error: 401 Authorization: Bearer [redacted]",
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`outcome` is exactly the job's: `completed`, `failed`, `interrupted`,
|
|
144
|
+
`cancelled` or `outcome_unknown`. Only `failed`, `interrupted` and
|
|
145
|
+
`outcome_unknown` replies carry a diagnostic. `boundedDiagnostic` makes it one
|
|
146
|
+
line, masks credential-shaped values (authorization-scheme credentials, named
|
|
147
|
+
keys, tokens, secrets and passwords, and long letter-and-digit runs), then cuts
|
|
148
|
+
it to `BIRD_DOG_DIAGNOSTIC_MAX_LENGTH` (200) characters. A `cancelled` job
|
|
149
|
+
delivers the supplied cancellation result as its content. A settlement's `meta`
|
|
150
|
+
is for your own fan-out wrapper, such as naming who cancelled; replies never
|
|
151
|
+
carry it. A job without mail sends nothing.
|
|
152
|
+
|
|
153
|
+
The recipient's host owns authentication and presentation. It trusts the `reply`
|
|
154
|
+
facts only after checking the reply against the mail it sent, then decides who
|
|
155
|
+
authors the line and how its model sees it.
|
|
156
|
+
|
|
157
|
+
**Draining.** `drainOutbox()` reads one page of up to 64 outbox rows and calls
|
|
158
|
+
`post` for each due reply. `post` returns `true` when the recipient accepts the
|
|
159
|
+
mail. A `false` or a throw schedules a retry. If more rows remain, BirdDog
|
|
160
|
+
schedules an immediate wake and continues from the same place on the next drain.
|
|
161
|
+
|
|
162
|
+
**Receipts.** A delivered reply's row is replaced by a compact completion
|
|
163
|
+
receipt under the same key. Replaying the same settlement after a restart finds
|
|
164
|
+
the receipt and does not post the reply again. Keep those receipts as long as
|
|
165
|
+
source settlements can replay. BirdDog does not expire them.
|
|
166
|
+
|
|
167
|
+
**Host ports.** `BirdDog.make` takes four things:
|
|
168
|
+
|
|
169
|
+
- `outbox`: string rows with `put`, `read`, ordered `list(limit, afterKey?)` and
|
|
170
|
+
`delete`. Use the same key ordering for sorting and the exclusive `afterKey`
|
|
171
|
+
filter so paging cannot skip or repeat rows.
|
|
172
|
+
- `post(mail)`: delivers one reply and reports acceptance.
|
|
173
|
+
- `scheduleWake(at)`: arms a durable timer for `drainOutbox`.
|
|
174
|
+
- `now()`: the current time in milliseconds.
|
|
175
|
+
|
|
176
|
+
## API reference
|
|
177
|
+
|
|
178
|
+
The generated reference is in [docs/api](docs/api/README.md).
|
|
179
|
+
|
|
180
|
+
- `@fungi.computer/bird-dog`: `BirdDog` with `agentJob`, `decodeAgentJob` and
|
|
181
|
+
`make`, plus the `BirdDogMail`, `BirdDogAgentReference`, `BirdDogAgentWork`,
|
|
182
|
+
`BirdDogAgentJobPayload`, `BirdDogReply`, `BirdDogOutbox`, `BirdDogOptions`,
|
|
183
|
+
`BirdDogSettlement` and `BirdDogRuntime` types.
|
|
184
|
+
- `@fungi.computer/bird-dog/effect`: `make`, `agentJobFromInput`,
|
|
185
|
+
`decodeAgentJob`, `settlementReplies`, `boundedDiagnostic`,
|
|
186
|
+
`BIRD_DOG_DIAGNOSTIC_MAX_LENGTH`, `BirdDogError`, `EffectBirdDogOptions`,
|
|
187
|
+
`BirdDogEffect` and the schemas `BirdDogAgentReferenceSchema`,
|
|
188
|
+
`BirdDogReplySchema`, `BirdDogMailSchema`, `BirdDogAgentWorkSchema`,
|
|
189
|
+
`BirdDogAgentJobPayloadSchema` and `BirdDogAgentJobInputSchema`.
|
|
190
|
+
|
|
191
|
+
## Examples and checks
|
|
192
|
+
|
|
193
|
+
The Effect entrypoint takes the same ports as Effect values:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import * as Effect from "effect/Effect";
|
|
197
|
+
import { make } from "@fungi.computer/bird-dog/effect";
|
|
198
|
+
|
|
199
|
+
const room = await Effect.runPromise(
|
|
200
|
+
make({
|
|
201
|
+
outbox: {
|
|
202
|
+
put: () => Effect.void,
|
|
203
|
+
read: () => Effect.succeed(undefined),
|
|
204
|
+
list: () => Effect.succeed([]),
|
|
205
|
+
delete: () => Effect.void,
|
|
206
|
+
},
|
|
207
|
+
post: () => Effect.succeed(true),
|
|
208
|
+
scheduleWake: () => Effect.void,
|
|
209
|
+
now: () => Date.now(),
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
await Effect.runPromise(room.drainOutbox());
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Use the schemas to parse mail that arrives from outside your process:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import * as Schema from "effect/Schema";
|
|
219
|
+
import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
|
|
220
|
+
|
|
221
|
+
const mail = Schema.decodeUnknownSync(BirdDogMailSchema)(JSON.parse(body));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The package tests cover job translation, payload decoding, replayed settlement
|
|
225
|
+
after a restart, explicit cancellation replies, exact failure outcomes with
|
|
226
|
+
bounded and redacted diagnostics, retry re-arming after reopen, bounded paging
|
|
227
|
+
past full pages, retry exhaustion, receipt walks, wake failure after fan-out,
|
|
228
|
+
and parity between the Promise and Effect schemas. From the package directory:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
pnpm test
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Limits and errors
|
|
235
|
+
|
|
236
|
+
- **Retry schedule.** A failed delivery retries after 2 seconds, doubling each
|
|
237
|
+
time, capped at 5 minutes. After 20 attempts the reply is exhausted. Its row
|
|
238
|
+
stays in the outbox, and BirdDog stops posting it and stops waking for it.
|
|
239
|
+
- **At-least-once.** If the recipient accepts a reply but recording the receipt
|
|
240
|
+
fails, the retry uses the same mail key. Recipients must admit a repeated key
|
|
241
|
+
idempotently.
|
|
242
|
+
- **Malformed rows.** A row that does not parse is deleted during a drain. A
|
|
243
|
+
malformed row met during fan-out fails that fan-out.
|
|
244
|
+
- **Fan-out writes first.** Every reply is persisted before the wake is
|
|
245
|
+
scheduled. If `scheduleWake` fails, `fanOut` rejects but the replies are
|
|
246
|
+
already in the outbox, so replaying the settlement is safe.
|
|
247
|
+
- **Invalid input.** `agentJob` and `decodeAgentJob` throw a schema error on
|
|
248
|
+
input that does not match. `fanOut` rejects with
|
|
249
|
+
`BirdDogError("invalid agent job payload")` for a job whose payload is not
|
|
250
|
+
BirdDog's.
|
|
251
|
+
- **Host failures.** The Promise mail room wraps a failing port in a
|
|
252
|
+
`BirdDogError` and keeps your original error as its `cause`. The messages are
|
|
253
|
+
`outbox read failed`, `outbox write failed`, `outbox delete failed`,
|
|
254
|
+
`mail transport failed` and `wake scheduling failed`. A `post` that throws
|
|
255
|
+
during a drain is logged and retried, not rethrown.
|
|
256
|
+
|
|
257
|
+
## Contribute and license
|
|
258
|
+
|
|
259
|
+
BirdDog is MIT licensed. See [LICENSE](./LICENSE).
|
package/dist/effect.d.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { WatchdogQueuedJob, WatchdogStoredJob } from "@fungi.computer/watchdog";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import type { BirdDogAgentJobInput, BirdDogAgentJobPayload, BirdDogMail, BirdDogSettlement } from "./index.js";
|
|
5
|
+
/** Parser for an ordinary agent Session address. */
|
|
6
|
+
export declare const BirdDogAgentReferenceSchema: Schema.Struct<{
|
|
7
|
+
readonly kind: Schema.Literal<"agent">;
|
|
8
|
+
readonly agentId: Schema.String;
|
|
9
|
+
readonly sessionId: Schema.String;
|
|
10
|
+
}>;
|
|
11
|
+
/** The most characters a reply's failure diagnostic may carry. */
|
|
12
|
+
export declare const BIRD_DOG_DIAGNOSTIC_MAX_LENGTH = 200;
|
|
13
|
+
/**
|
|
14
|
+
* Parser for the settlement facts a reply carries beside its content: the
|
|
15
|
+
* replying Session's command, its exact Watchdog outcome and, for work that did
|
|
16
|
+
* not complete, the Session's bounded terminal diagnostic.
|
|
17
|
+
*/
|
|
18
|
+
export declare const BirdDogReplySchema: Schema.Struct<{
|
|
19
|
+
readonly commandId: Schema.String;
|
|
20
|
+
readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
|
|
21
|
+
readonly diagnostic: Schema.optionalKey<Schema.String>;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* One secret-safe line of at most `BIRD_DOG_DIAGNOSTIC_MAX_LENGTH` characters
|
|
25
|
+
* from a Session's terminal diagnostic, or nothing when it is blank. Control
|
|
26
|
+
* characters and line breaks become single spaces, so the text cannot shape
|
|
27
|
+
* the reply around it, and credential-shaped values are masked before the
|
|
28
|
+
* line is cut, so a cut never exposes part of one.
|
|
29
|
+
*/
|
|
30
|
+
export declare function boundedDiagnostic(text: string): string | undefined;
|
|
31
|
+
/** Parser for addressed agent correspondence. */
|
|
32
|
+
export declare const BirdDogMailSchema: Schema.Struct<{
|
|
33
|
+
readonly key: Schema.String;
|
|
34
|
+
readonly to: Schema.Struct<{
|
|
35
|
+
readonly kind: Schema.Literal<"agent">;
|
|
36
|
+
readonly agentId: Schema.String;
|
|
37
|
+
readonly sessionId: Schema.String;
|
|
38
|
+
}>;
|
|
39
|
+
readonly from: Schema.Struct<{
|
|
40
|
+
readonly kind: Schema.Literal<"agent">;
|
|
41
|
+
readonly agentId: Schema.String;
|
|
42
|
+
readonly sessionId: Schema.String;
|
|
43
|
+
}>;
|
|
44
|
+
readonly replyTo: Schema.$Array<Schema.Struct<{
|
|
45
|
+
readonly kind: Schema.Literal<"agent">;
|
|
46
|
+
readonly agentId: Schema.String;
|
|
47
|
+
readonly sessionId: Schema.String;
|
|
48
|
+
}>>;
|
|
49
|
+
readonly content: Schema.String;
|
|
50
|
+
readonly createdAt: Schema.Number;
|
|
51
|
+
/** The sender's opaque host metadata; BirdDog never reads it. */
|
|
52
|
+
readonly meta: Schema.optionalKey<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
53
|
+
/** Settlement facts, present only on a reply BirdDog built. */
|
|
54
|
+
readonly reply: Schema.optionalKey<Schema.Struct<{
|
|
55
|
+
readonly commandId: Schema.String;
|
|
56
|
+
readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
|
|
57
|
+
readonly diagnostic: Schema.optionalKey<Schema.String>;
|
|
58
|
+
}>>;
|
|
59
|
+
}>;
|
|
60
|
+
/** Parser for accepted Session work. */
|
|
61
|
+
export declare const BirdDogAgentWorkSchema: Schema.Struct<{
|
|
62
|
+
readonly jobId: Schema.String;
|
|
63
|
+
readonly sessionId: Schema.String;
|
|
64
|
+
readonly commandFactId: Schema.String;
|
|
65
|
+
readonly recovery: Schema.optional<Schema.Struct<{
|
|
66
|
+
readonly maxRecoveries: Schema.Int;
|
|
67
|
+
}>>;
|
|
68
|
+
}>;
|
|
69
|
+
/** Parser for the payload that Watchdog stores without interpretation. */
|
|
70
|
+
export declare const BirdDogAgentJobPayloadSchema: Schema.Struct<{
|
|
71
|
+
readonly work: Schema.Struct<{
|
|
72
|
+
readonly sessionId: Schema.String;
|
|
73
|
+
readonly commandFactId: Schema.String;
|
|
74
|
+
}>;
|
|
75
|
+
readonly mail: Schema.optional<Schema.Struct<{
|
|
76
|
+
readonly key: Schema.String;
|
|
77
|
+
readonly to: Schema.Struct<{
|
|
78
|
+
readonly kind: Schema.Literal<"agent">;
|
|
79
|
+
readonly agentId: Schema.String;
|
|
80
|
+
readonly sessionId: Schema.String;
|
|
81
|
+
}>;
|
|
82
|
+
readonly from: Schema.Struct<{
|
|
83
|
+
readonly kind: Schema.Literal<"agent">;
|
|
84
|
+
readonly agentId: Schema.String;
|
|
85
|
+
readonly sessionId: Schema.String;
|
|
86
|
+
}>;
|
|
87
|
+
readonly replyTo: Schema.$Array<Schema.Struct<{
|
|
88
|
+
readonly kind: Schema.Literal<"agent">;
|
|
89
|
+
readonly agentId: Schema.String;
|
|
90
|
+
readonly sessionId: Schema.String;
|
|
91
|
+
}>>;
|
|
92
|
+
readonly content: Schema.String;
|
|
93
|
+
readonly createdAt: Schema.Number;
|
|
94
|
+
/** The sender's opaque host metadata; BirdDog never reads it. */
|
|
95
|
+
readonly meta: Schema.optionalKey<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
96
|
+
/** Settlement facts, present only on a reply BirdDog built. */
|
|
97
|
+
readonly reply: Schema.optionalKey<Schema.Struct<{
|
|
98
|
+
readonly commandId: Schema.String;
|
|
99
|
+
readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
|
|
100
|
+
readonly diagnostic: Schema.optionalKey<Schema.String>;
|
|
101
|
+
}>>;
|
|
102
|
+
}>>;
|
|
103
|
+
}>;
|
|
104
|
+
/** Parser for accepted work entering BirdDog's transactional projection. */
|
|
105
|
+
export declare const BirdDogAgentJobInputSchema: Schema.Struct<{
|
|
106
|
+
readonly work: Schema.Struct<{
|
|
107
|
+
readonly jobId: Schema.String;
|
|
108
|
+
readonly sessionId: Schema.String;
|
|
109
|
+
readonly commandFactId: Schema.String;
|
|
110
|
+
readonly recovery: Schema.optional<Schema.Struct<{
|
|
111
|
+
readonly maxRecoveries: Schema.Int;
|
|
112
|
+
}>>;
|
|
113
|
+
}>;
|
|
114
|
+
readonly mail: Schema.optional<Schema.Struct<{
|
|
115
|
+
readonly key: Schema.String;
|
|
116
|
+
readonly to: Schema.Struct<{
|
|
117
|
+
readonly kind: Schema.Literal<"agent">;
|
|
118
|
+
readonly agentId: Schema.String;
|
|
119
|
+
readonly sessionId: Schema.String;
|
|
120
|
+
}>;
|
|
121
|
+
readonly from: Schema.Struct<{
|
|
122
|
+
readonly kind: Schema.Literal<"agent">;
|
|
123
|
+
readonly agentId: Schema.String;
|
|
124
|
+
readonly sessionId: Schema.String;
|
|
125
|
+
}>;
|
|
126
|
+
readonly replyTo: Schema.$Array<Schema.Struct<{
|
|
127
|
+
readonly kind: Schema.Literal<"agent">;
|
|
128
|
+
readonly agentId: Schema.String;
|
|
129
|
+
readonly sessionId: Schema.String;
|
|
130
|
+
}>>;
|
|
131
|
+
readonly content: Schema.String;
|
|
132
|
+
readonly createdAt: Schema.Number;
|
|
133
|
+
/** The sender's opaque host metadata; BirdDog never reads it. */
|
|
134
|
+
readonly meta: Schema.optionalKey<Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
135
|
+
/** Settlement facts, present only on a reply BirdDog built. */
|
|
136
|
+
readonly reply: Schema.optionalKey<Schema.Struct<{
|
|
137
|
+
readonly commandId: Schema.String;
|
|
138
|
+
readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
|
|
139
|
+
readonly diagnostic: Schema.optionalKey<Schema.String>;
|
|
140
|
+
}>>;
|
|
141
|
+
}>>;
|
|
142
|
+
}>;
|
|
143
|
+
/** BirdDog could not complete one owned store or transport operation. */
|
|
144
|
+
export declare class BirdDogError extends Error {
|
|
145
|
+
/** Stable discriminator for BirdDog-owned effect failures. */
|
|
146
|
+
readonly _tag: "BirdDogError";
|
|
147
|
+
constructor(message: string, options?: ErrorOptions);
|
|
148
|
+
}
|
|
149
|
+
/** Effect host ports for the durable mail room. */
|
|
150
|
+
export type EffectBirdDogOptions = Readonly<{
|
|
151
|
+
outbox: Readonly<{
|
|
152
|
+
/** Store one encoded mail row under its durable key. */
|
|
153
|
+
put: (key: string, value: string) => Effect.Effect<void, BirdDogError>;
|
|
154
|
+
/** Read one encoded mail row, if it exists. */
|
|
155
|
+
read: (key: string) => Effect.Effect<string | undefined, BirdDogError>;
|
|
156
|
+
/** Read an ordered page of encoded mail rows. */
|
|
157
|
+
list: (limit: number, afterKey?: string) => Effect.Effect<readonly Readonly<{
|
|
158
|
+
key: string;
|
|
159
|
+
value: string;
|
|
160
|
+
}>[], BirdDogError>;
|
|
161
|
+
/** Remove an invalid encoded row; completed deliveries retain receipts. */
|
|
162
|
+
delete: (key: string) => Effect.Effect<void, BirdDogError>;
|
|
163
|
+
}>;
|
|
164
|
+
/** Deliver one reply mail and report whether it was accepted. */
|
|
165
|
+
post: (mail: BirdDogMail) => Effect.Effect<boolean, BirdDogError>;
|
|
166
|
+
/** Arrange a future wake for pending delivery work. */
|
|
167
|
+
scheduleWake: (at: number) => Effect.Effect<void, BirdDogError>;
|
|
168
|
+
/** Return the host's current wall-clock time in milliseconds. */
|
|
169
|
+
now: () => number;
|
|
170
|
+
}>;
|
|
171
|
+
/** Effect-native durable reply delivery. */
|
|
172
|
+
export type BirdDogEffect = Readonly<{
|
|
173
|
+
/** Queue replies owed by a settled agent job. */
|
|
174
|
+
fanOut: (settlement: BirdDogSettlement) => Effect.Effect<void, BirdDogError>;
|
|
175
|
+
/** Deliver due replies and schedule any required retry wake. */
|
|
176
|
+
drainOutbox: () => Effect.Effect<void, BirdDogError>;
|
|
177
|
+
}>;
|
|
178
|
+
/** Validate and translate agent work into Watchdog's opaque job grammar. */
|
|
179
|
+
export declare const agentJobFromInput: (input: BirdDogAgentJobInput) => WatchdogQueuedJob;
|
|
180
|
+
/** Decode the opaque payload at BirdDog's side of the Watchdog seam. */
|
|
181
|
+
export declare const decodeAgentJob: (job: WatchdogStoredJob) => BirdDogAgentJobPayload;
|
|
182
|
+
/**
|
|
183
|
+
* Build the replies owed by one settlement for a host-owned Post transport.
|
|
184
|
+
* Each reply carries the settled content verbatim (or, when there is none, one
|
|
185
|
+
* sentence naming the outcome, so the reply is always admissible text) and its
|
|
186
|
+
* settlement facts as typed fields; the recipient's host authenticates them and decides how they
|
|
187
|
+
* are presented. A reply never carries the settled work's host metadata.
|
|
188
|
+
*/
|
|
189
|
+
export declare function settlementReplies(settlement: BirdDogSettlement, createdAt: number): readonly BirdDogMail[];
|
|
190
|
+
/** Construct BirdDog's Effect-native mail room over Post's shared outbox. */
|
|
191
|
+
export declare const make: (options: EffectBirdDogOptions) => Effect.Effect<BirdDogEffect>;
|
package/dist/effect.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { makePostOutbox } from "@fungi.computer/carrier/post";
|
|
4
|
+
const Identifier = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
5
|
+
/** Parser for an ordinary agent Session address. */
|
|
6
|
+
export const BirdDogAgentReferenceSchema = Schema.Struct({
|
|
7
|
+
kind: Schema.Literal("agent"),
|
|
8
|
+
agentId: Identifier,
|
|
9
|
+
sessionId: Identifier,
|
|
10
|
+
});
|
|
11
|
+
/** The most characters a reply's failure diagnostic may carry. */
|
|
12
|
+
export const BIRD_DOG_DIAGNOSTIC_MAX_LENGTH = 200;
|
|
13
|
+
/**
|
|
14
|
+
* Parser for the settlement facts a reply carries beside its content: the
|
|
15
|
+
* replying Session's command, its exact Watchdog outcome and, for work that did
|
|
16
|
+
* not complete, the Session's bounded terminal diagnostic.
|
|
17
|
+
*/
|
|
18
|
+
export const BirdDogReplySchema = Schema.Struct({
|
|
19
|
+
commandId: Identifier,
|
|
20
|
+
outcome: Schema.Literals([
|
|
21
|
+
"completed",
|
|
22
|
+
"failed",
|
|
23
|
+
"interrupted",
|
|
24
|
+
"cancelled",
|
|
25
|
+
"outcome_unknown",
|
|
26
|
+
]),
|
|
27
|
+
diagnostic: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(BIRD_DOG_DIAGNOSTIC_MAX_LENGTH))),
|
|
28
|
+
});
|
|
29
|
+
const REDACTED = "[redacted]";
|
|
30
|
+
// An authorization scheme's credential, e.g. `Bearer <token>`.
|
|
31
|
+
const schemeCredential = /\b(bearer|basic)\s+[^\s"',;]+/giu;
|
|
32
|
+
// A named credential's value, e.g. `api_key=<value>` or `"token": "<value>"`.
|
|
33
|
+
const namedCredential = /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization)["']?\s*[:=]\s*["']?)(?!(?:bearer|basic)\s)[^\s"',;&]+/giu;
|
|
34
|
+
// A long opaque run of letters and digits, the shape of a key or token.
|
|
35
|
+
const opaqueToken = /[A-Za-z0-9][\w-]{23,}/gu;
|
|
36
|
+
function redactCredentials(text) {
|
|
37
|
+
return text
|
|
38
|
+
.replace(schemeCredential, `$1 ${REDACTED}`)
|
|
39
|
+
.replace(namedCredential, `$1${REDACTED}`)
|
|
40
|
+
.replace(opaqueToken, (token) => /\d/u.test(token) && /[A-Za-z]/u.test(token) ? REDACTED : token);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One secret-safe line of at most `BIRD_DOG_DIAGNOSTIC_MAX_LENGTH` characters
|
|
44
|
+
* from a Session's terminal diagnostic, or nothing when it is blank. Control
|
|
45
|
+
* characters and line breaks become single spaces, so the text cannot shape
|
|
46
|
+
* the reply around it, and credential-shaped values are masked before the
|
|
47
|
+
* line is cut, so a cut never exposes part of one.
|
|
48
|
+
*/
|
|
49
|
+
export function boundedDiagnostic(text) {
|
|
50
|
+
const line = redactCredentials(text.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, " ").trim())
|
|
51
|
+
.slice(0, BIRD_DOG_DIAGNOSTIC_MAX_LENGTH)
|
|
52
|
+
.trimEnd();
|
|
53
|
+
return line.length === 0 ? undefined : line;
|
|
54
|
+
}
|
|
55
|
+
/** Parser for addressed agent correspondence. */
|
|
56
|
+
export const BirdDogMailSchema = Schema.Struct({
|
|
57
|
+
key: Identifier,
|
|
58
|
+
to: BirdDogAgentReferenceSchema,
|
|
59
|
+
from: BirdDogAgentReferenceSchema,
|
|
60
|
+
replyTo: Schema.Array(BirdDogAgentReferenceSchema),
|
|
61
|
+
content: Schema.String,
|
|
62
|
+
createdAt: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
63
|
+
/** The sender's opaque host metadata; BirdDog never reads it. */
|
|
64
|
+
meta: Schema.optionalKey(Schema.Json),
|
|
65
|
+
/** Settlement facts, present only on a reply BirdDog built. */
|
|
66
|
+
reply: Schema.optionalKey(BirdDogReplySchema),
|
|
67
|
+
});
|
|
68
|
+
/** Parser for accepted Session work. */
|
|
69
|
+
export const BirdDogAgentWorkSchema = Schema.Struct({
|
|
70
|
+
jobId: Identifier,
|
|
71
|
+
sessionId: Identifier,
|
|
72
|
+
commandFactId: Identifier,
|
|
73
|
+
recovery: Schema.optional(Schema.Struct({
|
|
74
|
+
maxRecoveries: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
75
|
+
})),
|
|
76
|
+
});
|
|
77
|
+
/** Parser for the payload that Watchdog stores without interpretation. */
|
|
78
|
+
export const BirdDogAgentJobPayloadSchema = Schema.Struct({
|
|
79
|
+
work: Schema.Struct({
|
|
80
|
+
sessionId: Identifier,
|
|
81
|
+
commandFactId: Identifier,
|
|
82
|
+
}),
|
|
83
|
+
mail: Schema.optional(BirdDogMailSchema),
|
|
84
|
+
});
|
|
85
|
+
/** Parser for accepted work entering BirdDog's transactional projection. */
|
|
86
|
+
export const BirdDogAgentJobInputSchema = Schema.Struct({
|
|
87
|
+
work: BirdDogAgentWorkSchema,
|
|
88
|
+
mail: Schema.optional(BirdDogMailSchema),
|
|
89
|
+
});
|
|
90
|
+
/** BirdDog could not complete one owned store or transport operation. */
|
|
91
|
+
export class BirdDogError extends Error {
|
|
92
|
+
/** Stable discriminator for BirdDog-owned effect failures. */
|
|
93
|
+
_tag = "BirdDogError";
|
|
94
|
+
constructor(message, options) {
|
|
95
|
+
super(message, options);
|
|
96
|
+
this.name = "BirdDogError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Validate and translate agent work into Watchdog's opaque job grammar. */
|
|
100
|
+
export const agentJobFromInput = (input) => {
|
|
101
|
+
const accepted = Schema.decodeUnknownSync(BirdDogAgentJobInputSchema)(input);
|
|
102
|
+
const work = {
|
|
103
|
+
sessionId: accepted.work.sessionId,
|
|
104
|
+
commandFactId: accepted.work.commandFactId,
|
|
105
|
+
};
|
|
106
|
+
const payload = accepted.mail === undefined ? { work } : { work, mail: accepted.mail };
|
|
107
|
+
const job = {
|
|
108
|
+
jobId: accepted.work.jobId,
|
|
109
|
+
queue: accepted.work.sessionId,
|
|
110
|
+
lane: "default",
|
|
111
|
+
priority: 0,
|
|
112
|
+
payload,
|
|
113
|
+
state: "queued",
|
|
114
|
+
};
|
|
115
|
+
return accepted.work.recovery === undefined
|
|
116
|
+
? job
|
|
117
|
+
: { ...job, recovery: accepted.work.recovery };
|
|
118
|
+
};
|
|
119
|
+
/** Decode the opaque payload at BirdDog's side of the Watchdog seam. */
|
|
120
|
+
export const decodeAgentJob = (job) => Schema.decodeUnknownSync(BirdDogAgentJobPayloadSchema)(job.payload);
|
|
121
|
+
/**
|
|
122
|
+
* Build the replies owed by one settlement for a host-owned Post transport.
|
|
123
|
+
* Each reply carries the settled content verbatim (or, when there is none, one
|
|
124
|
+
* sentence naming the outcome, so the reply is always admissible text) and its
|
|
125
|
+
* settlement facts as typed fields; the recipient's host authenticates them and decides how they
|
|
126
|
+
* are presented. A reply never carries the settled work's host metadata.
|
|
127
|
+
*/
|
|
128
|
+
export function settlementReplies(settlement, createdAt) {
|
|
129
|
+
const { work, mail } = decodeAgentJob(settlement.job);
|
|
130
|
+
if (mail === undefined)
|
|
131
|
+
return [];
|
|
132
|
+
const outcome = settlement.job.outcome;
|
|
133
|
+
const diagnostic = outcome === "completed" ||
|
|
134
|
+
outcome === "cancelled" ||
|
|
135
|
+
settlement.diagnostic === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: boundedDiagnostic(settlement.diagnostic);
|
|
138
|
+
const reply = Schema.decodeUnknownSync(BirdDogReplySchema)(diagnostic === undefined
|
|
139
|
+
? { commandId: work.commandFactId, outcome }
|
|
140
|
+
: { commandId: work.commandFactId, outcome, diagnostic });
|
|
141
|
+
return mail.replyTo.map((destination, index) => ({
|
|
142
|
+
key: `${mail.key}:reply:${String(index)}`,
|
|
143
|
+
to: destination,
|
|
144
|
+
from: mail.to,
|
|
145
|
+
replyTo: [],
|
|
146
|
+
content: settlement.content.length > 0
|
|
147
|
+
? settlement.content
|
|
148
|
+
: `The delegated work ended ${outcome} without reply text.`,
|
|
149
|
+
createdAt,
|
|
150
|
+
reply,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
/** Construct BirdDog's Effect-native mail room over Post's shared outbox. */
|
|
154
|
+
export const make = (options) => {
|
|
155
|
+
const post = makePostOutbox({
|
|
156
|
+
message: BirdDogMailSchema,
|
|
157
|
+
key: (mail) => `outbox:${mail.key}`,
|
|
158
|
+
outbox: options.outbox,
|
|
159
|
+
deliver: options.post,
|
|
160
|
+
scheduleWake: options.scheduleWake,
|
|
161
|
+
now: options.now,
|
|
162
|
+
invalidRow: () => new BirdDogError("invalid outbox row"),
|
|
163
|
+
encodingFailed: () => new BirdDogError("could not encode outbox row"),
|
|
164
|
+
});
|
|
165
|
+
const fanOut = (settlement) => Effect.gen(function* () {
|
|
166
|
+
const createdAt = options.now();
|
|
167
|
+
const replies = yield* Effect.try({
|
|
168
|
+
try: () => settlementReplies(settlement, createdAt),
|
|
169
|
+
catch: () => new BirdDogError("invalid agent job payload"),
|
|
170
|
+
});
|
|
171
|
+
let wake;
|
|
172
|
+
for (const reply of replies) {
|
|
173
|
+
const hint = yield* post.enqueue(reply, createdAt);
|
|
174
|
+
if (hint !== undefined)
|
|
175
|
+
wake = wake === undefined ? hint : Math.min(wake, hint);
|
|
176
|
+
}
|
|
177
|
+
if (wake !== undefined)
|
|
178
|
+
yield* options.scheduleWake(wake);
|
|
179
|
+
});
|
|
180
|
+
return Effect.succeed({ fanOut, drainOutbox: post.drain });
|
|
181
|
+
};
|