@gjermundgaraba/effect-actions 0.16.0 → 0.17.0
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/dist/Action.d.mts +3 -5
- package/dist/Action.mjs +3 -1
- package/dist/ActionCli.d.mts +2 -2
- package/dist/ActionCli.mjs +2 -1
- package/dist/ActionHttp.d.mts +3 -2
- package/dist/ActionHttp.mjs +3 -1
- package/dist/ActionMcp.d.mts +1 -1
- package/dist/ActionMcp.mjs +4 -1
- package/dist/ActionRpc.d.mts +5 -3
- package/dist/ActionRpc.mjs +2 -1
- package/dist/ActionToolkit.d.mts +3 -2
- package/dist/ActionToolkit.mjs +3 -1
- package/dist/Authentication.d.mts +1 -1
- package/dist/Authentication.mjs +1 -1
- package/dist/authentication/provider.d.mts +2 -2
- package/dist/authentication/refusal.mjs +1 -1
- package/dist/cli/commands.d.mts +3 -2
- package/dist/cli/commands.mjs +2 -1
- package/dist/contract/ast.mjs +10 -0
- package/dist/contract/call.d.mts +1 -1
- package/dist/contract/caller.d.mts +9 -0
- package/dist/contract/caller.mjs +12 -0
- package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
- package/dist/contract/declared.mjs +42 -0
- package/dist/contract/hints.mjs +14 -0
- package/dist/contract/implementation.d.mts +4 -3
- package/dist/contract/implementation.mjs +4 -8
- package/dist/contract/names.mjs +25 -0
- package/dist/contract/status.mjs +18 -0
- package/dist/http/client.d.mts +3 -2
- package/dist/mcp/client.d.mts +40 -7
- package/dist/mcp/client.mjs +60 -17
- package/dist/mcp/protocol.mjs +1 -1
- package/docs/Action.md +17 -3
- package/docs/ActionCli.md +1 -1
- package/docs/ActionMcp.md +2 -2
- package/docs/ActionToolkit.md +25 -2
- package/docs/Authentication-failures.md +1 -1
- package/docs/CONTEXT.md +2 -1
- package/docs/README.md +4 -4
- package/docs/Testing.md +3 -1
- package/examples/README.md +10 -2
- package/examples/assistant/README.md +24 -20
- package/examples/assistant/src/agent.ts +11 -7
- package/examples/assistant/src/cli.ts +2 -2
- package/examples/assistant/src/platform/tasks-file.ts +38 -0
- package/examples/assistant/src/tasks/store.ts +6 -38
- package/examples/assistant/src/toolkit.ts +11 -1
- package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
- package/examples/mcp-tools/README.md +14 -11
- package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
- package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
- package/examples/mcp-tools/src/printers/farm.ts +6 -122
- package/examples/mcp-tools/src/server.ts +4 -4
- package/examples/mcp-tools/src/stdio.ts +4 -4
- package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
- package/examples/mcp-tools/test/routes.test.ts +4 -4
- package/examples/notes-service/README.md +32 -0
- package/examples/notes-service/src/identity/authorize.ts +1 -0
- package/examples/notes-service/src/notes/Notes.ts +22 -5
- package/examples/notes-service/src/notes/handlers.ts +2 -2
- package/examples/notes-service/src/notes/store.ts +36 -6
- package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
- package/examples/notes-service/test/routes.test.ts +40 -1
- package/package.json +5 -5
- package/dist/_virtual/_rolldown/runtime.mjs +0 -13
- package/dist/contract/rules.mjs +0 -100
|
@@ -91,9 +91,11 @@ and no `name` or `arguments`.
|
|
|
91
91
|
src/
|
|
92
92
|
├── printers/
|
|
93
93
|
│ ├── Printers.ts contract: the printer, the one declared error, the five tools
|
|
94
|
-
│ ├── farm.ts PrintFarm, the upstream
|
|
95
|
-
│ ├── fake.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
|
|
94
|
+
│ ├── farm.ts PrintFarm, the upstream as a service: what the handlers call
|
|
96
95
|
│ └── handlers.ts Action.implement: tool to service
|
|
96
|
+
├── platform/
|
|
97
|
+
│ ├── print-farm.ts printFarm, PrintFarm's Layer over the farm's HTTP API
|
|
98
|
+
│ └── fake-farm.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
|
|
97
99
|
├── instructions.ts the server's name, version and its instructions for the model
|
|
98
100
|
├── routes.ts the HTTP surface: /mcp and /healthz
|
|
99
101
|
├── server.ts entrypoint: MCP over HTTP
|
|
@@ -103,13 +105,14 @@ test/
|
|
|
103
105
|
└── routes.test.ts over MCP in memory, with Testing.layer
|
|
104
106
|
```
|
|
105
107
|
|
|
106
|
-
- **The upstream is a service, built once.** `
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
- **The upstream is a service, built once.** `farm.ts` declares what the handlers need of the
|
|
109
|
+
farm, and `platform/print-farm.ts` is its Layer over the farm's HTTP API: it reads its URL and
|
|
110
|
+
credentials from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in
|
|
111
|
+
while it is built. The handlers get it already signed in and only delegate: `handlers.ts`
|
|
112
|
+
maps each tool onto a method of `PrintFarm`, which knows nothing of tools or HTTP, so a second
|
|
113
|
+
caller of the farm, a job or another surface, takes the service alone.
|
|
111
114
|
- **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
|
|
112
|
-
another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
|
|
115
|
+
another shape and one that never comes each leave `platform/print-farm.ts` as a `PrintFarmError` with a
|
|
113
116
|
message a model can act on. No handler catches anything.
|
|
114
117
|
- **What a model sends is checked by the contract.** A printer id becomes part of the farm's
|
|
115
118
|
URL, so its schema admits only what is safe there, and anything else is refused before a
|
|
@@ -128,14 +131,14 @@ test/
|
|
|
128
131
|
| One declared error shared by every tool | `src/printers/Printers.ts` |
|
|
129
132
|
| An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
|
|
130
133
|
| A tool answering an image beside other fields | `src/printers/Printers.ts` |
|
|
131
|
-
| An upstream client reading `Config`, signing in once | `src/
|
|
132
|
-
| An upstream's failures mapped to the declared error | `src/
|
|
134
|
+
| An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
|
|
135
|
+
| An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
|
|
133
136
|
| A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
|
|
134
137
|
| Server `instructions`, shared by both transports | `src/instructions.ts` |
|
|
135
138
|
| Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
|
|
136
139
|
| A request body limit, and where an unauthenticated server binds | `src/server.ts` |
|
|
137
140
|
| A stdio server whose logs go to stderr | `src/stdio.ts` |
|
|
138
|
-
| A fake upstream provided as a Layer, to a test or an entrypoint | `src/
|
|
141
|
+
| A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
|
|
139
142
|
| Testing tools and each upstream failure's message in process | `test/printers/printers.test.ts` |
|
|
140
143
|
| Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
|
|
141
144
|
| Testing CORS for one configured origin and for several | `test/routes.test.ts` |
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { Effect, Layer, Option, Schema } from "effect";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
-
import type { Printer } from "
|
|
4
|
+
import type { Printer } from "../printers/Printers.js";
|
|
5
5
|
|
|
6
6
|
// DEMO ONLY: the farm's HTTP API answered in memory, so the example and its tests run without
|
|
7
|
-
// a farm. It is an `HttpClient`, so `
|
|
7
|
+
// a farm. It is an `HttpClient`, so `printFarm` sends it the requests it would send over
|
|
8
8
|
// the network. A real application has no such file: its entrypoints provide a real client,
|
|
9
9
|
// `FetchHttpClient.layer` from `effect/http`.
|
|
10
10
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Config, Effect, Layer, Redacted, Schema } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
HttpBody,
|
|
4
|
+
HttpClient,
|
|
5
|
+
type HttpClientError,
|
|
6
|
+
HttpClientRequest,
|
|
7
|
+
HttpClientResponse,
|
|
8
|
+
} from "effect/http";
|
|
9
|
+
import { PrintFarm } from "../printers/farm.js";
|
|
10
|
+
import { Printer, PrintFarmError } from "../printers/Printers.js";
|
|
11
|
+
|
|
12
|
+
const fail = (message: string) => Effect.fail(new PrintFarmError({ message }));
|
|
13
|
+
|
|
14
|
+
const unreadable = "The print farm sent an answer this server could not read.";
|
|
15
|
+
|
|
16
|
+
// The farm answers a request it refuses with a status that is not 2xx and `{ "error": "..." }`.
|
|
17
|
+
// Its reason is passed on to the model, which can act on "The printer is busy with a job".
|
|
18
|
+
const refuseUnlessOk = HttpClientResponse.matchStatus({
|
|
19
|
+
"2xx": (response) => Effect.succeed(response),
|
|
20
|
+
orElse: (response) =>
|
|
21
|
+
HttpClientResponse.schemaBodyJson(Schema.Struct({ error: Schema.String }))(response).pipe(
|
|
22
|
+
Effect.matchEffect({
|
|
23
|
+
onFailure: () => fail(`The print farm refused the request (HTTP ${response.status}).`),
|
|
24
|
+
onSuccess: ({ error }) => fail(`The print farm refused: ${error}`),
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Every other way a call to the farm fails, as the same declared error: no connection, an
|
|
30
|
+
// answer of another shape than expected, or none in time. Nothing of the HTTP client's own
|
|
31
|
+
// errors leaves this file, so no tool can fail with something its contract does not declare.
|
|
32
|
+
const asPrintFarmError = <A>(
|
|
33
|
+
effect: Effect.Effect<A, PrintFarmError | HttpClientError.HttpClientError | Schema.SchemaError>,
|
|
34
|
+
) =>
|
|
35
|
+
effect.pipe(
|
|
36
|
+
Effect.catchTag("HttpClientError", ({ response }) =>
|
|
37
|
+
fail(response === undefined ? "The print farm could not be reached." : unreadable),
|
|
38
|
+
),
|
|
39
|
+
Effect.catchTag("SchemaError", () => fail(unreadable)),
|
|
40
|
+
Effect.timeoutOrElse({
|
|
41
|
+
duration: "30 seconds",
|
|
42
|
+
orElse: () => fail("The print farm did not answer in time."),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const asImage = (response: HttpClientResponse.HttpClientResponse) =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const mimeType = response.headers["content-type"];
|
|
49
|
+
|
|
50
|
+
if (mimeType === undefined || !mimeType.startsWith("image/")) return yield* fail(unreadable);
|
|
51
|
+
|
|
52
|
+
return { data: new Uint8Array(yield* response.arrayBuffer), mimeType };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Built once, when the process starts. It requires an `HttpClient` and reads its settings
|
|
56
|
+
// from `Config`: the entrypoint decides which client, and the environment says which farm.
|
|
57
|
+
// Not `fetch`: as a service, a test replaces the network alone and opens no port
|
|
58
|
+
// (test/printers/printers.test.ts).
|
|
59
|
+
export const printFarm = Layer.effect(
|
|
60
|
+
PrintFarm,
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
const { url, user, password } = yield* Config.all({
|
|
63
|
+
url: Config.URL("FARM_URL"),
|
|
64
|
+
user: Config.NonEmptyString("FARM_USER"),
|
|
65
|
+
password: Config.Redacted("FARM_PASSWORD"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const http = (yield* HttpClient.HttpClient).pipe(
|
|
69
|
+
HttpClient.mapRequest(HttpClientRequest.prependUrl(url.href)),
|
|
70
|
+
HttpClient.transformResponse(Effect.flatMap(refuseUnlessOk)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Signs in once, here, and every call below reuses the token, kept `Redacted` so no log
|
|
74
|
+
// prints it. A farm that refuses the sign-in stops the process at startup, with its
|
|
75
|
+
// reason, before any tool is listed. A real client also signs in again when a call
|
|
76
|
+
// answers 401, as tokens expire.
|
|
77
|
+
const session = yield* http
|
|
78
|
+
.post("/api/login", {
|
|
79
|
+
body: HttpBody.jsonUnsafe({ user, password: Redacted.value(password) }),
|
|
80
|
+
})
|
|
81
|
+
.pipe(
|
|
82
|
+
Effect.flatMap(
|
|
83
|
+
HttpClientResponse.schemaBodyJson(
|
|
84
|
+
Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
asPrintFarmError,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
yield* Effect.logInfo(`Signed in to the print farm at ${url.host} as ${user}.`);
|
|
91
|
+
|
|
92
|
+
const api = HttpClient.mapRequest(http, HttpClientRequest.bearerToken(session.token));
|
|
93
|
+
|
|
94
|
+
// An id is written into the path as it is: the contract admits only letters, digits,
|
|
95
|
+
// `_` and `-`, so it is always one segment. Encoding would not make other text safe:
|
|
96
|
+
// `encodeURIComponent` leaves `..` alone, and a URL parser then walks up the path.
|
|
97
|
+
const at = (id: string, rest = "") => `/api/printers/${id}${rest}`;
|
|
98
|
+
|
|
99
|
+
const printer = (answer: ReturnType<typeof api.get>) =>
|
|
100
|
+
answer.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Printer)), asPrintFarmError);
|
|
101
|
+
|
|
102
|
+
return PrintFarm.of({
|
|
103
|
+
printers: api.get("/api/printers").pipe(
|
|
104
|
+
Effect.flatMap(
|
|
105
|
+
HttpClientResponse.schemaBodyJson(Schema.Struct({ printers: Schema.Array(Printer) })),
|
|
106
|
+
),
|
|
107
|
+
Effect.map(({ printers }) => printers),
|
|
108
|
+
asPrintFarmError,
|
|
109
|
+
),
|
|
110
|
+
printer: (id) => printer(api.get(at(id))),
|
|
111
|
+
snapshot: (id) => api.get(at(id, "/camera")).pipe(Effect.flatMap(asImage), asPrintFarmError),
|
|
112
|
+
start: (id, file) =>
|
|
113
|
+
printer(api.post(at(id, "/job"), { body: HttpBody.jsonUnsafe({ file }) })),
|
|
114
|
+
setPaused: (id, paused) => printer(api.post(at(id, paused ? "/pause" : "/resume"))),
|
|
115
|
+
cancel: (id) => printer(api.del(at(id, "/job"))),
|
|
116
|
+
});
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
@@ -1,61 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
HttpBody,
|
|
4
|
-
HttpClient,
|
|
5
|
-
type HttpClientError,
|
|
6
|
-
HttpClientRequest,
|
|
7
|
-
HttpClientResponse,
|
|
8
|
-
} from "effect/http";
|
|
1
|
+
import { Context, type Effect } from "effect";
|
|
9
2
|
import type * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
10
|
-
import { Printer, PrintFarmError } from "./Printers.js";
|
|
11
|
-
|
|
12
|
-
const fail = (message: string) => Effect.fail(new PrintFarmError({ message }));
|
|
13
|
-
|
|
14
|
-
const unreadable = "The print farm sent an answer this server could not read.";
|
|
15
|
-
|
|
16
|
-
// The farm answers a request it refuses with a status that is not 2xx and `{ "error": "..." }`.
|
|
17
|
-
// Its reason is passed on to the model, which can act on "The printer is busy with a job".
|
|
18
|
-
const refuseUnlessOk = HttpClientResponse.matchStatus({
|
|
19
|
-
"2xx": (response) => Effect.succeed(response),
|
|
20
|
-
orElse: (response) =>
|
|
21
|
-
HttpClientResponse.schemaBodyJson(Schema.Struct({ error: Schema.String }))(response).pipe(
|
|
22
|
-
Effect.matchEffect({
|
|
23
|
-
onFailure: () => fail(`The print farm refused the request (HTTP ${response.status}).`),
|
|
24
|
-
onSuccess: ({ error }) => fail(`The print farm refused: ${error}`),
|
|
25
|
-
}),
|
|
26
|
-
),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Every other way a call to the farm fails, as the same declared error: no connection, an
|
|
30
|
-
// answer of another shape than expected, or none in time. Nothing of the HTTP client's own
|
|
31
|
-
// errors leaves this file, so no tool can fail with something its contract does not declare.
|
|
32
|
-
const asPrintFarmError = <A>(
|
|
33
|
-
effect: Effect.Effect<A, PrintFarmError | HttpClientError.HttpClientError | Schema.SchemaError>,
|
|
34
|
-
) =>
|
|
35
|
-
effect.pipe(
|
|
36
|
-
Effect.catchTag("HttpClientError", ({ response }) =>
|
|
37
|
-
fail(response === undefined ? "The print farm could not be reached." : unreadable),
|
|
38
|
-
),
|
|
39
|
-
Effect.catchTag("SchemaError", () => fail(unreadable)),
|
|
40
|
-
Effect.timeoutOrElse({
|
|
41
|
-
duration: "30 seconds",
|
|
42
|
-
orElse: () => fail("The print farm did not answer in time."),
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
const asImage = (response: HttpClientResponse.HttpClientResponse) =>
|
|
47
|
-
Effect.gen(function* () {
|
|
48
|
-
const mimeType = response.headers["content-type"];
|
|
49
|
-
|
|
50
|
-
if (mimeType === undefined || !mimeType.startsWith("image/")) return yield* fail(unreadable);
|
|
51
|
-
|
|
52
|
-
return { data: new Uint8Array(yield* response.arrayBuffer), mimeType };
|
|
53
|
-
});
|
|
3
|
+
import type { Printer, PrintFarmError } from "./Printers.js";
|
|
54
4
|
|
|
55
5
|
/**
|
|
56
|
-
* The print farm
|
|
57
|
-
*
|
|
58
|
-
* farm's URL and the session.
|
|
6
|
+
* The print farm, the one upstream of this server. Its methods require nothing and fail only
|
|
7
|
+
* with the error the tools declare: its layer, `printFarm` in src/platform/print-farm.ts, has
|
|
8
|
+
* captured the HTTP client, the farm's URL and the session.
|
|
59
9
|
*/
|
|
60
10
|
export class PrintFarm extends Context.Service<
|
|
61
11
|
PrintFarm,
|
|
@@ -67,70 +17,4 @@ export class PrintFarm extends Context.Service<
|
|
|
67
17
|
readonly setPaused: (id: string, paused: boolean) => Effect.Effect<Printer, PrintFarmError>;
|
|
68
18
|
readonly cancel: (id: string) => Effect.Effect<Printer, PrintFarmError>;
|
|
69
19
|
}
|
|
70
|
-
>()("mcp-tools/PrintFarm") {
|
|
71
|
-
// Built once, when the process starts. It requires an `HttpClient` and reads its settings
|
|
72
|
-
// from `Config`: the entrypoint decides which client, and the environment says which farm.
|
|
73
|
-
// Not `fetch`: as a service, a test replaces the network alone and opens no port
|
|
74
|
-
// (test/printers/printers.test.ts).
|
|
75
|
-
static readonly layer = Layer.effect(
|
|
76
|
-
PrintFarm,
|
|
77
|
-
Effect.gen(function* () {
|
|
78
|
-
const { url, user, password } = yield* Config.all({
|
|
79
|
-
url: Config.URL("FARM_URL"),
|
|
80
|
-
user: Config.NonEmptyString("FARM_USER"),
|
|
81
|
-
password: Config.Redacted("FARM_PASSWORD"),
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const http = (yield* HttpClient.HttpClient).pipe(
|
|
85
|
-
HttpClient.mapRequest(HttpClientRequest.prependUrl(url.href)),
|
|
86
|
-
HttpClient.transformResponse(Effect.flatMap(refuseUnlessOk)),
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
// Signs in once, here, and every call below reuses the token, kept `Redacted` so no log
|
|
90
|
-
// prints it. A farm that refuses the sign-in stops the process at startup, with its
|
|
91
|
-
// reason, before any tool is listed. A real client also signs in again when a call
|
|
92
|
-
// answers 401, as tokens expire.
|
|
93
|
-
const session = yield* http
|
|
94
|
-
.post("/api/login", {
|
|
95
|
-
body: HttpBody.jsonUnsafe({ user, password: Redacted.value(password) }),
|
|
96
|
-
})
|
|
97
|
-
.pipe(
|
|
98
|
-
Effect.flatMap(
|
|
99
|
-
HttpClientResponse.schemaBodyJson(
|
|
100
|
-
Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
|
|
101
|
-
),
|
|
102
|
-
),
|
|
103
|
-
asPrintFarmError,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
yield* Effect.logInfo(`Signed in to the print farm at ${url.host} as ${user}.`);
|
|
107
|
-
|
|
108
|
-
const api = HttpClient.mapRequest(http, HttpClientRequest.bearerToken(session.token));
|
|
109
|
-
|
|
110
|
-
// An id is written into the path as it is: the contract admits only letters, digits,
|
|
111
|
-
// `_` and `-`, so it is always one segment. Encoding would not make other text safe:
|
|
112
|
-
// `encodeURIComponent` leaves `..` alone, and a URL parser then walks up the path.
|
|
113
|
-
const at = (id: string, rest = "") => `/api/printers/${id}${rest}`;
|
|
114
|
-
|
|
115
|
-
const printer = (answer: ReturnType<typeof api.get>) =>
|
|
116
|
-
answer.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Printer)), asPrintFarmError);
|
|
117
|
-
|
|
118
|
-
return PrintFarm.of({
|
|
119
|
-
printers: api.get("/api/printers").pipe(
|
|
120
|
-
Effect.flatMap(
|
|
121
|
-
HttpClientResponse.schemaBodyJson(Schema.Struct({ printers: Schema.Array(Printer) })),
|
|
122
|
-
),
|
|
123
|
-
Effect.map(({ printers }) => printers),
|
|
124
|
-
asPrintFarmError,
|
|
125
|
-
),
|
|
126
|
-
printer: (id) => printer(api.get(at(id))),
|
|
127
|
-
snapshot: (id) =>
|
|
128
|
-
api.get(at(id, "/camera")).pipe(Effect.flatMap(asImage), asPrintFarmError),
|
|
129
|
-
start: (id, file) =>
|
|
130
|
-
printer(api.post(at(id, "/job"), { body: HttpBody.jsonUnsafe({ file }) })),
|
|
131
|
-
setPaused: (id, paused) => printer(api.post(at(id, paused ? "/pause" : "/resume"))),
|
|
132
|
-
cancel: (id) => printer(api.del(at(id, "/job"))),
|
|
133
|
-
});
|
|
134
|
-
}),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
20
|
+
>()("mcp-tools/PrintFarm") {}
|
|
@@ -2,8 +2,8 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
3
3
|
import { ByteSize, Config, Effect, Layer } from "effect";
|
|
4
4
|
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
5
|
-
import { fakeFarm } from "./
|
|
6
|
-
import {
|
|
5
|
+
import { fakeFarm } from "./platform/fake-farm.js";
|
|
6
|
+
import { printFarm } from "./platform/print-farm.js";
|
|
7
7
|
import { routes } from "./routes.js";
|
|
8
8
|
|
|
9
9
|
// Entrypoint: `node --import tsx examples/mcp-tools/src/server.ts`, with FARM_URL, FARM_USER
|
|
@@ -22,8 +22,8 @@ const server = Layer.unwrap(
|
|
|
22
22
|
// short fields, so anything larger is not one.
|
|
23
23
|
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.kibibytes(64))),
|
|
24
24
|
// DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
|
|
25
|
-
// `FetchHttpClient.layer` in its place, and `
|
|
26
|
-
Layer.provide(
|
|
25
|
+
// `FetchHttpClient.layer` in its place, and `printFarm` calls the farm at FARM_URL.
|
|
26
|
+
Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
|
|
27
27
|
);
|
|
28
28
|
}),
|
|
29
29
|
);
|
|
@@ -3,8 +3,8 @@ import { Effect, Layer } from "effect";
|
|
|
3
3
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
4
4
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
5
5
|
import { serverInfo } from "./instructions.js";
|
|
6
|
-
import { fakeFarm } from "./
|
|
7
|
-
import {
|
|
6
|
+
import { fakeFarm } from "./platform/fake-farm.js";
|
|
7
|
+
import { printFarm } from "./platform/print-farm.js";
|
|
8
8
|
import { printers } from "./printers/handlers.js";
|
|
9
9
|
|
|
10
10
|
// Entrypoint: an MCP server a local agent starts as a subprocess, with FARM_URL, FARM_USER and
|
|
@@ -17,10 +17,10 @@ ActionMcp.runStdio(printers, serverInfo).pipe(
|
|
|
17
17
|
NodeStdio.layer,
|
|
18
18
|
// DEMO ONLY: `fakeFarm` answers the farm's API in memory. A real server provides
|
|
19
19
|
// `FetchHttpClient.layer` in its place.
|
|
20
|
-
|
|
20
|
+
printFarm.pipe(Layer.provide(fakeFarm)),
|
|
21
21
|
]),
|
|
22
22
|
// stdout carries the protocol alone. Applied last, this sends every log to stderr, the
|
|
23
|
-
// sign-in line `
|
|
23
|
+
// sign-in line `printFarm` logs included, and a startup failure too.
|
|
24
24
|
ActionCli.logToStderr,
|
|
25
25
|
NodeRuntime.runMain,
|
|
26
26
|
);
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
} from "effect/http";
|
|
9
9
|
import { TestClock } from "effect/testing";
|
|
10
10
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
11
|
-
import { fakeFarm } from "../../src/
|
|
12
|
-
import {
|
|
11
|
+
import { fakeFarm } from "../../src/platform/fake-farm.js";
|
|
12
|
+
import { printFarm } from "../../src/platform/print-farm.js";
|
|
13
13
|
import { printers } from "../../src/printers/handlers.js";
|
|
14
14
|
import { PrintFarmError } from "../../src/printers/Printers.js";
|
|
15
15
|
|
|
@@ -19,7 +19,7 @@ import { PrintFarmError } from "../../src/printers/Printers.js";
|
|
|
19
19
|
// The real `PrintFarm` over an upstream of the test's: its requests, its sign-in and its error
|
|
20
20
|
// mapping all run, and only the network is replaced. Its settings come from a Layer too.
|
|
21
21
|
const farmOver = (upstream: Layer.Layer<HttpClient.HttpClient>) =>
|
|
22
|
-
|
|
22
|
+
printFarm.pipe(
|
|
23
23
|
Layer.provide(upstream),
|
|
24
24
|
Layer.provide(
|
|
25
25
|
ConfigProvider.layer(
|
|
@@ -3,8 +3,8 @@ import { ConfigProvider, Effect, Layer, Schema } from "effect";
|
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
4
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
5
5
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
6
|
-
import { fakeFarm, picture } from "../src/
|
|
7
|
-
import {
|
|
6
|
+
import { fakeFarm, picture } from "../src/platform/fake-farm.js";
|
|
7
|
+
import { printFarm } from "../src/platform/print-farm.js";
|
|
8
8
|
import { PrintFarmError, Printers } from "../src/printers/Printers.js";
|
|
9
9
|
import { routes } from "../src/routes.js";
|
|
10
10
|
|
|
@@ -15,10 +15,10 @@ import { routes } from "../src/routes.js";
|
|
|
15
15
|
const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
|
|
16
16
|
|
|
17
17
|
// `Testing.layer` gives the test an `HttpClient` of its own, which reaches the routes. The
|
|
18
|
-
// fake farm is another `HttpClient`, provided to `
|
|
18
|
+
// fake farm is another `HttpClient`, provided to `printFarm` alone.
|
|
19
19
|
const servedWith = (environment: Record<string, string>) =>
|
|
20
20
|
Testing.layer(routes).pipe(
|
|
21
|
-
Layer.provide(
|
|
21
|
+
Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
|
|
22
22
|
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
23
23
|
);
|
|
24
24
|
|
|
@@ -50,6 +50,37 @@ prints one message on stderr saying to set it, and exits 1. `--help` needs none.
|
|
|
50
50
|
|
|
51
51
|
The tokens are fixed names and the store is memory: replace both before deploying anything.
|
|
52
52
|
|
|
53
|
+
## Retry a creation
|
|
54
|
+
|
|
55
|
+
`createNote({ title, body })` creates a new note on every call. To retry safely, add an optional
|
|
56
|
+
`requestId`. Generate it once for a new creation, for example with `crypto.randomUUID()`, and
|
|
57
|
+
keep it with the pending operation outside any retry loop. After a lost response, send the
|
|
58
|
+
same key and content again. For the CLI, pass `--request-id` and keep it when repeating the
|
|
59
|
+
command:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --request-id plan-1 --title Plan
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The key belongs to the signed-in caller and the `createNote` operation. Another caller can use
|
|
66
|
+
it independently. The same caller repeating the key and content gets the original result;
|
|
67
|
+
different content fails with `RequestConflict`. A new key creates a new note, even when its
|
|
68
|
+
content is identical. Every attempt still runs authorization.
|
|
69
|
+
|
|
70
|
+
The replay result is a snapshot of the creation. Updating or deleting the note leaves that
|
|
71
|
+
snapshot intact: replay neither changes the note back nor recreates it. `importNotes` has no
|
|
72
|
+
request key; each import creates new notes. Calls without a key are not idempotent, so the
|
|
73
|
+
creation tool does not advertise `idempotentHint: true`.
|
|
74
|
+
|
|
75
|
+
[src/notes/store.ts](src/notes/store.ts) owns creation and replay recording.
|
|
76
|
+
|
|
77
|
+
**DEMO ONLY:** notes and replay records live in this process's memory, without eviction. They
|
|
78
|
+
do not survive a restart or coordinate separate server instances. A deployed store must commit
|
|
79
|
+
the mutation and replay record in one transaction, enforce uniqueness of caller, operation and
|
|
80
|
+
key, and define a retention window. An expired key no longer protects a retry. External side
|
|
81
|
+
effects need the upstream system's cooperation: a local record cannot make a separate network
|
|
82
|
+
call atomic. The library supplies neither replay storage nor automatic retries.
|
|
83
|
+
|
|
53
84
|
## Layout
|
|
54
85
|
|
|
55
86
|
```text
|
|
@@ -102,6 +133,7 @@ test/
|
|
|
102
133
|
| A contract with declared errors and their statuses | `src/notes/Notes.ts` |
|
|
103
134
|
| Input fields described, for `--help` and a tool's schema | `src/notes/Notes.ts` |
|
|
104
135
|
| Handlers getting a service once and the caller per call | `src/notes/handlers.ts` |
|
|
136
|
+
| A write safe to retry with the same caller and request key | `src/notes/store.ts` |
|
|
105
137
|
| A public action beside protected ones | `src/status/` |
|
|
106
138
|
| Authorization from `readOnly`, refusing with a scope | `src/identity/authorize.ts` |
|
|
107
139
|
| A token verifier and OAuth discovery read from config | `src/identity/verifier.ts` |
|
|
@@ -8,6 +8,7 @@ import { CurrentUser, scopes } from "./Identity.js";
|
|
|
8
8
|
// answered a challenge, which only a server publishing discovery (verifier.ts) can follow up.
|
|
9
9
|
// Where tokens are fixed, such as API keys, name no scope: the refusal is then a plain one.
|
|
10
10
|
// So `scopes` below goes when `protectedResource` in verifier.ts does: copy both or neither.
|
|
11
|
+
// This named span measures authorization within the action's span, including a refusal.
|
|
11
12
|
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
12
13
|
const scope = action.readOnly ? scopes.read : scopes.write;
|
|
13
14
|
const user = yield* CurrentUser;
|
|
@@ -25,6 +25,12 @@ export class NoteChanged extends Schema.TaggedError<NoteChanged>()(
|
|
|
25
25
|
{ httpApiStatus: 409 },
|
|
26
26
|
) {}
|
|
27
27
|
|
|
28
|
+
export class RequestConflict extends Schema.TaggedError<RequestConflict>()(
|
|
29
|
+
"RequestConflict",
|
|
30
|
+
{ requestId: Schema.String },
|
|
31
|
+
{ httpApiStatus: 409 },
|
|
32
|
+
) {}
|
|
33
|
+
|
|
28
34
|
// A description on an input field is that field's help on every surface: beside its flag or
|
|
29
35
|
// argument in a command's `--help`, and in the input schema a model reads from the MCP tool
|
|
30
36
|
// listing and the OpenAPI document. A field without one has a flag with no help.
|
|
@@ -43,6 +49,8 @@ const Title = Schema.String.check(
|
|
|
43
49
|
|
|
44
50
|
const Body = Schema.String.annotate({ description: "The note's whole text." });
|
|
45
51
|
|
|
52
|
+
const Content = Schema.Struct({ title: Title, body: Body });
|
|
53
|
+
|
|
46
54
|
export const ListNotes = Action.make("listNotes", {
|
|
47
55
|
description: "List your notes, without their bodies.",
|
|
48
56
|
success: { notes: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })) },
|
|
@@ -60,9 +68,17 @@ export const GetNote = Action.make("getNote", {
|
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
export const CreateNote = Action.make("createNote", {
|
|
63
|
-
description:
|
|
64
|
-
|
|
71
|
+
description:
|
|
72
|
+
"Create a note. To retry safely, supply a requestId and reuse it with the same content.",
|
|
73
|
+
input: {
|
|
74
|
+
requestId: Schema.optionalKey(Schema.NonEmptyString).annotate({
|
|
75
|
+
description:
|
|
76
|
+
"An optional key for this creation. Keep it for retries; without one, every call creates a note.",
|
|
77
|
+
}),
|
|
78
|
+
...Content.fields,
|
|
79
|
+
},
|
|
65
80
|
success: Note,
|
|
81
|
+
error: RequestConflict,
|
|
66
82
|
readOnly: false,
|
|
67
83
|
caller: CurrentUser,
|
|
68
84
|
mcp: { destructiveHint: false },
|
|
@@ -98,14 +114,15 @@ export const DeleteNote = Action.make("deleteNote", {
|
|
|
98
114
|
// Work every surface should offer is an action, a bulk import included: the HTTP API, the MCP
|
|
99
115
|
// tools and the CLI then all have it, and the whole list is decoded, so checked, before the
|
|
100
116
|
// handler writes any of it. A command of your own only reads the file and calls this once
|
|
101
|
-
// (commands.ts). `importNotes` adds
|
|
102
|
-
//
|
|
117
|
+
// (commands.ts). `importNotes` only adds notes, so `destructiveHint` is `false`.
|
|
118
|
+
// Imports share note content with `createNote`, but exclude request keys because bulk creation
|
|
119
|
+
// does not deduplicate.
|
|
103
120
|
export const ImportNotes = Action.make("importNotes", {
|
|
104
121
|
description:
|
|
105
122
|
"Create several notes at once, such as those `notes export` wrote. Every note is checked " +
|
|
106
123
|
"before any is written: an import holding one that is not valid writes none.",
|
|
107
124
|
input: {
|
|
108
|
-
notes: Schema.Array(
|
|
125
|
+
notes: Schema.Array(Content)
|
|
109
126
|
.check(
|
|
110
127
|
Schema.isMinLength(1, { message: "An import holds at least one note." }),
|
|
111
128
|
Schema.isMaxLength(100, { message: "An import holds at most 100 notes." }),
|
|
@@ -23,8 +23,8 @@ export const notes = Action.implement(
|
|
|
23
23
|
return { notes: owned.map(({ id, title }) => ({ id, title })) };
|
|
24
24
|
}),
|
|
25
25
|
getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
|
|
26
|
-
createNote: ({ title, body }) =>
|
|
27
|
-
Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body)),
|
|
26
|
+
createNote: ({ requestId, title, body }) =>
|
|
27
|
+
Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body, requestId)),
|
|
28
28
|
updateNote: ({ id, body, revision }) =>
|
|
29
29
|
Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
|
|
30
30
|
deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
|
-
import { type Note, NoteChanged, NoteNotFound } from "./Notes.js";
|
|
2
|
+
import { type Note, NoteChanged, NoteNotFound, RequestConflict } from "./Notes.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Where notes live, each owned by one user. Ownership is checked here, in the data access, so no
|
|
@@ -10,7 +10,12 @@ export class NoteStore extends Context.Service<
|
|
|
10
10
|
{
|
|
11
11
|
readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
|
|
12
12
|
readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
|
|
13
|
-
readonly create: (
|
|
13
|
+
readonly create: (
|
|
14
|
+
owner: string,
|
|
15
|
+
title: string,
|
|
16
|
+
body: string,
|
|
17
|
+
requestId?: string,
|
|
18
|
+
) => Effect.Effect<Note, RequestConflict>;
|
|
14
19
|
/** Every note or none: a database does this in one transaction. */
|
|
15
20
|
readonly createAll: (
|
|
16
21
|
owner: string,
|
|
@@ -26,12 +31,14 @@ export class NoteStore extends Context.Service<
|
|
|
26
31
|
readonly count: Effect.Effect<number>;
|
|
27
32
|
}
|
|
28
33
|
>()("notes-service/NoteStore") {
|
|
29
|
-
// DEMO ONLY:
|
|
30
|
-
// service, chosen by the entrypoint.
|
|
34
|
+
// DEMO ONLY: notes and replay records live in this process's memory, without eviction.
|
|
31
35
|
static readonly layerMemory = Layer.sync(NoteStore, () => {
|
|
32
36
|
// One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
|
|
33
37
|
const notes = new Map<string, Map<string, Note>>();
|
|
34
38
|
|
|
39
|
+
// Keep the original result even after the note is updated or deleted.
|
|
40
|
+
const creations = new Map<string, Map<string, Note>>();
|
|
41
|
+
|
|
35
42
|
const ownedBy = (owner: string) => notes.get(owner) ?? new Map<string, Note>();
|
|
36
43
|
|
|
37
44
|
const save = (owner: string, note: Note) => {
|
|
@@ -51,8 +58,31 @@ export class NoteStore extends Context.Service<
|
|
|
51
58
|
return NoteStore.of({
|
|
52
59
|
list: (owner) => Effect.sync(() => Array.from(ownedBy(owner).values())),
|
|
53
60
|
get,
|
|
54
|
-
create: (owner, title, body)
|
|
55
|
-
|
|
61
|
+
create: Effect.fn("NoteStore.create")(function* (owner, title, body, requestId) {
|
|
62
|
+
if (requestId !== undefined) {
|
|
63
|
+
const recorded = creations.get(owner)?.get(requestId);
|
|
64
|
+
|
|
65
|
+
if (recorded !== undefined) {
|
|
66
|
+
if (recorded.title !== title || recorded.body !== body) {
|
|
67
|
+
return yield* new RequestConflict({ requestId });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return recorded;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// No yield between lookup, mutation and recording: concurrent calls cannot interleave
|
|
75
|
+
// or interruption leave a note without its result.
|
|
76
|
+
const note = save(owner, { id: crypto.randomUUID(), title, body, revision: 1 });
|
|
77
|
+
|
|
78
|
+
if (requestId !== undefined) {
|
|
79
|
+
const requests = creations.get(owner) ?? new Map<string, Note>();
|
|
80
|
+
requests.set(requestId, note);
|
|
81
|
+
creations.set(owner, requests);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return note;
|
|
85
|
+
}),
|
|
56
86
|
createAll: (owner, all) =>
|
|
57
87
|
Effect.sync(() =>
|
|
58
88
|
all.map(({ title, body }) =>
|