@gjermundgaraba/effect-actions 0.16.0 → 0.18.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/README.md +3 -1
- package/dist/Action.d.mts +3 -5
- package/dist/Action.mjs +3 -1
- package/dist/ActionCli.d.mts +31 -42
- package/dist/ActionCli.mjs +6 -16
- package/dist/ActionHttp.d.mts +12 -8
- 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 +4 -3
- package/dist/cli/commands.mjs +6 -5
- 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 +19 -9
- package/dist/http/client.mjs +6 -1
- package/dist/mcp/client.d.mts +52 -12
- package/dist/mcp/client.mjs +62 -19
- package/dist/mcp/protocol.mjs +1 -1
- package/docs/Action.md +17 -3
- package/docs/ActionCli-failures.md +10 -9
- package/docs/ActionCli.md +22 -21
- package/docs/ActionHttp-failures.md +6 -0
- package/docs/ActionHttp.md +9 -4
- package/docs/ActionMcp.md +3 -3
- 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/kitchen-sink/src/cli.ts +19 -21
- 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 +34 -2
- package/examples/notes-service/src/cli.ts +3 -9
- package/examples/notes-service/src/commands.ts +40 -36
- 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/commands.test.ts +13 -19
- 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
|
@@ -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
|
|
@@ -73,7 +104,7 @@ src/
|
|
|
73
104
|
├── worker.ts entrypoint: a fetch handler for a serverless host
|
|
74
105
|
├── stdio.ts entrypoint: MCP for a local agent
|
|
75
106
|
├── commands.ts the commands calling the server, one written by hand, and their connection
|
|
76
|
-
├── cli.ts entrypoint: the commands
|
|
107
|
+
├── cli.ts entrypoint: the commands
|
|
77
108
|
└── browser.ts client module: promises for a web page
|
|
78
109
|
test/
|
|
79
110
|
├── notes/ in process, with Action.client
|
|
@@ -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` |
|
|
@@ -112,7 +144,7 @@ test/
|
|
|
112
144
|
| A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
|
|
113
145
|
| A command written by hand beside the derived ones | `src/commands.ts` |
|
|
114
146
|
| A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
|
|
115
|
-
| The CLI's connection, read from config when a command runs | `src/commands.ts
|
|
147
|
+
| The CLI's connection, read from config when a command runs | `src/commands.ts` |
|
|
116
148
|
| A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
|
|
117
149
|
| A promise client handling each declared error by its tag | `src/browser.ts` |
|
|
118
150
|
| Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { Command } from "effect/cli";
|
|
4
|
-
import { HttpClient } from "effect/http";
|
|
5
4
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
-
import { commands
|
|
5
|
+
import { commands } from "./commands.js";
|
|
7
6
|
|
|
8
7
|
// Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
|
|
9
|
-
// The commands of commands.ts,
|
|
10
|
-
const notes = commands.pipe(
|
|
11
|
-
// Provided on the command, never around `Command.run`: the URL and the token are then read
|
|
12
|
-
// when a command runs, so `--help` and a mistyped flag need neither.
|
|
13
|
-
Command.provideEffect(HttpClient.HttpClient, connection),
|
|
14
|
-
);
|
|
8
|
+
// The commands of commands.ts, which read where the server is and the token when one runs.
|
|
15
9
|
|
|
16
10
|
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
17
11
|
// HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
|
|
18
|
-
Command.run(
|
|
12
|
+
Command.run(commands, { version: "1.0.0" }).pipe(
|
|
19
13
|
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
20
14
|
ActionCli.logToStderr,
|
|
21
15
|
NodeRuntime.runMain,
|
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
import { Config, Console, Effect, FileSystem,
|
|
1
|
+
import { Config, Console, Effect, FileSystem, Schema } from "effect";
|
|
2
2
|
import { Argument, CliError, Command } from "effect/cli";
|
|
3
3
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
4
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
5
5
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
6
|
import { Http } from "./Api.js";
|
|
7
7
|
|
|
8
|
+
// What a command fails with when no token is configured: a tagged error of the CLI's own, printed
|
|
9
|
+
// on stderr as `MissingToken: <message>` under an `ERROR` heading, and exit 1.
|
|
10
|
+
class MissingToken extends Schema.TaggedError<MissingToken>()("MissingToken", {
|
|
11
|
+
message: Schema.String,
|
|
12
|
+
}) {}
|
|
13
|
+
|
|
14
|
+
// The binding's typed client, connected to where the server is and as who is calling, both read
|
|
15
|
+
// from config. It is an Effect, not a client: every command here, the derived ones and your own,
|
|
16
|
+
// runs it when it runs, so `--help` and a mistyped flag read nothing. It reaches these commands'
|
|
17
|
+
// requests and no others.
|
|
18
|
+
const api = Effect.gen(function* () {
|
|
19
|
+
const baseUrl = yield* Config.String("NOTES_URL").pipe(
|
|
20
|
+
Config.withDefault("http://127.0.0.1:3000"),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// Required, since every command but `get-status` is refused without one. Left as it is, a
|
|
24
|
+
// missing token prints as `ConfigError: …`; mapped, it prints the message, which says what to
|
|
25
|
+
// do. A CLI whose public commands must answer signed out reads the token with `Config.option`
|
|
26
|
+
// instead and adds the header when it is there.
|
|
27
|
+
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
28
|
+
Effect.mapError(
|
|
29
|
+
() =>
|
|
30
|
+
new MissingToken({
|
|
31
|
+
message: "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return yield* ActionHttp.client(Http, {
|
|
37
|
+
baseUrl,
|
|
38
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
8
42
|
// The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
|
|
9
43
|
// a blank line. A body line that starts as a title would is written with a backslash before it,
|
|
10
44
|
// as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
|
|
@@ -15,14 +49,14 @@ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
|
|
|
15
49
|
// A command of your own, for what no single action does: this one calls two and writes a file.
|
|
16
50
|
// It is a native Effect CLI command, so its syntax and its output are what you write: `notes
|
|
17
51
|
// export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
|
|
18
|
-
// calls the server through the
|
|
19
|
-
//
|
|
52
|
+
// calls the server through `api`, the client the derived commands call, and writes with the
|
|
53
|
+
// `FileSystem` every Effect CLI command has.
|
|
20
54
|
const exportNotes = Command.make(
|
|
21
55
|
"export",
|
|
22
56
|
{ file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
|
|
23
57
|
({ file }) =>
|
|
24
58
|
Effect.gen(function* () {
|
|
25
|
-
const client = yield*
|
|
59
|
+
const client = yield* api;
|
|
26
60
|
const files = yield* FileSystem.FileSystem;
|
|
27
61
|
const { notes } = yield* client.listNotes();
|
|
28
62
|
const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
|
|
@@ -63,7 +97,7 @@ const importNotes = Command.make(
|
|
|
63
97
|
},
|
|
64
98
|
({ text }) =>
|
|
65
99
|
Effect.gen(function* () {
|
|
66
|
-
const client = yield*
|
|
100
|
+
const client = yield* api;
|
|
67
101
|
|
|
68
102
|
if (text === "") return yield* Console.log("Created 0 notes");
|
|
69
103
|
|
|
@@ -93,6 +127,7 @@ const importNotes = Command.make(
|
|
|
93
127
|
// it: test/commands.test.ts.
|
|
94
128
|
export const commands = ActionCli.remote(Http, {
|
|
95
129
|
name: "notes",
|
|
130
|
+
client: api,
|
|
96
131
|
commands: {
|
|
97
132
|
// `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
|
|
98
133
|
createNote: { stdin: "body" },
|
|
@@ -104,34 +139,3 @@ export const commands = ActionCli.remote(Http, {
|
|
|
104
139
|
// go here.
|
|
105
140
|
subcommands: [exportNotes, importNotes],
|
|
106
141
|
});
|
|
107
|
-
|
|
108
|
-
// Where the server is and who is calling, read from config: the `HttpClient` every command
|
|
109
|
-
// above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
|
|
110
|
-
// and it runs when one of them does.
|
|
111
|
-
export const connection = Effect.gen(function* () {
|
|
112
|
-
const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
|
|
113
|
-
|
|
114
|
-
// Required, since every command but `get-status` is refused without one. A failure of what a
|
|
115
|
-
// command is provided is no action's error: left as it is, a missing token is reported as a
|
|
116
|
-
// crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
|
|
117
|
-
// ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
|
|
118
|
-
// the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
|
|
119
|
-
// printed, never `cause`: what the user needs from the cause goes into the message. A CLI
|
|
120
|
-
// whose public commands must answer signed out reads the token with `Config.option` instead
|
|
121
|
-
// and adds the header when it is there.
|
|
122
|
-
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
123
|
-
Effect.mapError(
|
|
124
|
-
(cause) =>
|
|
125
|
-
new CliError.UserError({
|
|
126
|
-
cause,
|
|
127
|
-
userMessage:
|
|
128
|
-
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
129
|
-
}),
|
|
130
|
-
),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
return HttpClient.mapRequest(
|
|
134
|
-
yield* HttpClient.HttpClient,
|
|
135
|
-
flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
|
|
136
|
-
);
|
|
137
|
-
});
|
|
@@ -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)),
|