@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 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
- # Temporary Holding Version
1
+ # BirdDog (`@fungi.computer/bird-dog`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).
@@ -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
+ };