@akterjs/client 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +17 -2
- package/dist/actor-fields.pure.d.mts +77 -0
- package/dist/actor-fields.pure.mjs +85 -0
- package/dist/actor-operations.pure.d.mts +49 -0
- package/dist/actor-operations.pure.mjs +512 -0
- package/dist/actor-protocol.layer.d.mts +18 -0
- package/dist/actor-protocol.layer.mjs +172 -0
- package/dist/actor-rpc.pure.d.mts +28 -0
- package/dist/actor-rpc.pure.mjs +154 -0
- package/dist/akter-client.layer.d.mts +13 -0
- package/dist/akter-client.layer.mjs +27 -0
- package/dist/promise-client.adapter.d.mts +55 -0
- package/dist/promise-client.adapter.mjs +114 -0
- package/dist/promise-files.pure.d.mts +48 -0
- package/dist/promise-files.pure.mjs +35 -0
- package/dist/read-floors.pure.d.mts +7 -0
- package/dist/read-floors.pure.mjs +49 -0
- package/dist/remote-interrupt.pure.d.mts +13 -0
- package/dist/remote-interrupt.pure.mjs +45 -0
- package/dist/upload-source.errors.d.mts +9 -0
- package/dist/upload-source.errors.mjs +5 -0
- package/package.json +68 -3
- package/src/actor-fields.pure.ts +60 -0
- package/src/actor-operations.pure.ts +484 -0
- package/src/actor-protocol.layer.ts +331 -0
- package/src/actor-rpc.pure.ts +179 -0
- package/src/akter-client.layer.ts +67 -0
- package/src/promise-client.adapter.ts +354 -0
- package/src/promise-files.pure.ts +106 -0
- package/src/read-floors.pure.ts +82 -0
- package/src/remote-interrupt.pure.ts +79 -0
- package/src/upload-source.errors.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akterjs/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Effect and Promise clients for calling Akter actors from Bun, Node, Workers and browsers.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Rika-Labs/akter.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"imports": {
|
|
18
|
+
"#src/*": "./src/*",
|
|
19
|
+
"#tests/*": "./tests/*.ts"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/promise-client.adapter.d.mts",
|
|
24
|
+
"default": "./dist/promise-client.adapter.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./effect": {
|
|
27
|
+
"types": "./dist/akter-client.layer.d.mts",
|
|
28
|
+
"default": "./dist/akter-client.layer.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./operations": {
|
|
31
|
+
"types": "./dist/actor-operations.pure.d.mts",
|
|
32
|
+
"default": "./dist/actor-operations.pure.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./actor-rpc": {
|
|
35
|
+
"types": "./dist/actor-rpc.pure.d.mts",
|
|
36
|
+
"default": "./dist/actor-rpc.pure.mjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@akterjs/effect": "0.1.1",
|
|
45
|
+
"@akterjs/workerd-test": "0.0.0",
|
|
46
|
+
"@effect/platform-node": "4.1.2",
|
|
47
|
+
"@effect/vitest": "4.1.2",
|
|
48
|
+
"@vitest/browser-playwright": "5.1.0",
|
|
49
|
+
"effect": "4.1.2",
|
|
50
|
+
"esbuild": "0.28.2",
|
|
51
|
+
"mutant-cafe": "0.1.6",
|
|
52
|
+
"playwright": "1.65.0",
|
|
53
|
+
"publint": "0.3.25",
|
|
54
|
+
"tsdown": "0.23.5",
|
|
55
|
+
"vitest": "5.1.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@akterjs/effect": "0.1.1",
|
|
59
|
+
"effect": "4.1.2"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"typecheck": "effect-tsgo tsrs --noEmit -p tsconfig.json",
|
|
63
|
+
"lint": "effect-tsgo --tsrs-lint oxlint -c ../../oxlint.config.ts --type-aware --tsconfig tsconfig.json src tests",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:browser": "vitest run --mode browser",
|
|
66
|
+
"test:workerd": "workerd-test",
|
|
67
|
+
"build": "tsdown",
|
|
68
|
+
"mutants": "VITEST_MAX_WORKERS=1 mutant-cafe --advisory && node ../../node_modules/@rikalabs/rules/src/mutants/mutants.cli.mjs --all",
|
|
69
|
+
"mutants:diff": "VITEST_MAX_WORKERS=1 mutant-cafe --in-diff-range main...HEAD --advisory && node ../../node_modules/@rikalabs/rules/src/mutants/mutants.cli.mjs"
|
|
70
|
+
}
|
|
6
71
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Array as Arr, Record as Rec } from "effect";
|
|
2
|
+
import type { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
type JsonObject = Schema.JsonObject;
|
|
5
|
+
|
|
6
|
+
// The fields an actor operation takes, as its tool's input and the CLI's flags, with each one's JSON Schema.
|
|
7
|
+
const fieldSchemas = {
|
|
8
|
+
actor: { description: "The actor's name in the app, such as Sale.", type: "string" },
|
|
9
|
+
after: {
|
|
10
|
+
description: "Only log lines after this seq, the last one a page before printed, to read on from there.",
|
|
11
|
+
type: "integer",
|
|
12
|
+
},
|
|
13
|
+
commandId: {
|
|
14
|
+
description: "An id for this call, so a retry with the same id runs it once. A new one is made when it is missing.",
|
|
15
|
+
type: "string",
|
|
16
|
+
},
|
|
17
|
+
computer: { description: "The computer's name, as the actor named it.", type: "string" },
|
|
18
|
+
computerPath: { description: "Relative path in the computer's files directory.", type: "string" },
|
|
19
|
+
content: { description: "The file's bytes, as base64.", type: "string" },
|
|
20
|
+
cursor: { description: "Where the page to read starts, from the cursor of the page before.", type: "string" },
|
|
21
|
+
direction: {
|
|
22
|
+
description: "toComputer or fromComputer; copy-back returns an upload receipt.",
|
|
23
|
+
enum: ["toComputer", "fromComputer"],
|
|
24
|
+
type: "string",
|
|
25
|
+
},
|
|
26
|
+
into: { description: "The new actor's key, as JSON. A bare word is taken as a string key." },
|
|
27
|
+
key: { description: "The actor's key, as JSON. A bare word is taken as a string key." },
|
|
28
|
+
length: { description: "How many bytes to read, from offset; give both or neither.", type: "integer" },
|
|
29
|
+
limit: { description: "The most entries to return.", type: "integer" },
|
|
30
|
+
member: { description: "The command or query to run.", type: "string" },
|
|
31
|
+
offset: { description: "The byte to start reading at, with length; give both or neither.", type: "integer" },
|
|
32
|
+
path: { description: "The file's path in the actor, such as photos/cat.png.", type: "string" },
|
|
33
|
+
payload: { description: "The command's, query's or create input's payload, as JSON." },
|
|
34
|
+
prefix: { description: "Only paths that start with this.", type: "string" },
|
|
35
|
+
step: { description: "The step's id, as steps list prints it.", type: "string" },
|
|
36
|
+
} satisfies Readonly<Record<string, JsonObject>>;
|
|
37
|
+
|
|
38
|
+
type FieldName = keyof typeof fieldSchemas;
|
|
39
|
+
|
|
40
|
+
const optional: readonly FieldName[] = [
|
|
41
|
+
"after",
|
|
42
|
+
"commandId",
|
|
43
|
+
"cursor",
|
|
44
|
+
"length",
|
|
45
|
+
"limit",
|
|
46
|
+
"offset",
|
|
47
|
+
"payload",
|
|
48
|
+
"prefix",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const inputOf = (fields: readonly FieldName[]): JsonObject => ({
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
properties: Rec.fromEntries(Arr.map(fields, (name) => [name, fieldSchemas[name]] as const)),
|
|
54
|
+
required: Arr.filter(fields, (name) => !Arr.contains(optional, name)),
|
|
55
|
+
type: "object",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export { inputOf };
|
|
59
|
+
|
|
60
|
+
export type { FieldName };
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Option, Record as Rec, SchemaAST, String as Str } from "effect";
|
|
2
|
+
import type { Schema } from "effect";
|
|
3
|
+
import { SecureRandom } from "effect/crypto";
|
|
4
|
+
import type { Rpc } from "effect/rpc";
|
|
5
|
+
import { RpcSchema } from "effect/rpc";
|
|
6
|
+
|
|
7
|
+
import { inputOf } from "./actor-fields.pure.js";
|
|
8
|
+
import type { FieldName } from "./actor-fields.pure.js";
|
|
9
|
+
|
|
10
|
+
type JsonObject = Schema.JsonObject;
|
|
11
|
+
|
|
12
|
+
// MCP hints matching the Cloud API; this package imports only contracts.
|
|
13
|
+
interface Annotations {
|
|
14
|
+
readonly confirm: boolean;
|
|
15
|
+
readonly destructive: boolean;
|
|
16
|
+
readonly idempotent: boolean;
|
|
17
|
+
readonly openWorld: boolean;
|
|
18
|
+
readonly readOnly: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ActorKind =
|
|
22
|
+
| "call"
|
|
23
|
+
| "computers/cp"
|
|
24
|
+
| "computers/delete"
|
|
25
|
+
| "computers/get"
|
|
26
|
+
| "computers/list"
|
|
27
|
+
| "computers/logs"
|
|
28
|
+
| "computers/pause"
|
|
29
|
+
| "computers/resume"
|
|
30
|
+
| "computers/stop"
|
|
31
|
+
| "create"
|
|
32
|
+
| "ensure"
|
|
33
|
+
| "files/download"
|
|
34
|
+
| "files/list"
|
|
35
|
+
| "files/upload"
|
|
36
|
+
| "files/usage"
|
|
37
|
+
| "fork"
|
|
38
|
+
| "query"
|
|
39
|
+
| "state"
|
|
40
|
+
| "steps/get"
|
|
41
|
+
| "steps/list"
|
|
42
|
+
| "warm"
|
|
43
|
+
| "watch";
|
|
44
|
+
|
|
45
|
+
// An actor RPC operation (ADR 0016), shared by the CLI and MCP.
|
|
46
|
+
interface ActorOperation {
|
|
47
|
+
readonly annotations: Annotations;
|
|
48
|
+
// The feature flag the operation is behind, as a Cloud API operation's (akter-cloud ADR 0030): the CLI leaves it out
|
|
49
|
+
// Of the help and the MCP server lists it to nobody until it reads the caller's flags.
|
|
50
|
+
readonly flag: Option.Option<string>;
|
|
51
|
+
readonly command: readonly [string, string];
|
|
52
|
+
readonly description: string;
|
|
53
|
+
readonly fields: readonly FieldName[];
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly input: JsonObject;
|
|
56
|
+
readonly kind: ActorKind;
|
|
57
|
+
readonly stream: boolean;
|
|
58
|
+
readonly tool: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Declared {
|
|
62
|
+
readonly description: string;
|
|
63
|
+
readonly flag?: string;
|
|
64
|
+
readonly fields: readonly FieldName[];
|
|
65
|
+
readonly kind: ActorKind;
|
|
66
|
+
readonly readOnly: boolean;
|
|
67
|
+
readonly idempotent: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Files and computers have command groups; the rest are under actors.
|
|
71
|
+
const group = "actors";
|
|
72
|
+
|
|
73
|
+
const commandOfKind = (kind: ActorKind): readonly [string, string] => {
|
|
74
|
+
const [owner, name] = Str.split("/")(kind);
|
|
75
|
+
|
|
76
|
+
return Option.match(Option.fromUndefinedOr(name), {
|
|
77
|
+
onNone: () => [group, kind] as const,
|
|
78
|
+
onSome: (found) => [owner, found] as const,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const subscribe = "/subscribe";
|
|
83
|
+
|
|
84
|
+
const declared: readonly Declared[] = [
|
|
85
|
+
{
|
|
86
|
+
description: "Run one of an actor's commands and print its reply.",
|
|
87
|
+
fields: ["actor", "key", "member", "payload", "commandId"],
|
|
88
|
+
idempotent: false,
|
|
89
|
+
kind: "call",
|
|
90
|
+
readOnly: false,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
description: "Run one of an actor's queries and print its reply.",
|
|
94
|
+
fields: ["actor", "key", "member", "payload"],
|
|
95
|
+
idempotent: true,
|
|
96
|
+
kind: "query",
|
|
97
|
+
readOnly: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
description: "Print an actor's state once.",
|
|
101
|
+
fields: ["actor", "key"],
|
|
102
|
+
idempotent: true,
|
|
103
|
+
kind: "state",
|
|
104
|
+
readOnly: true,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
description: "Print a query's reply, then each new reply as the actor changes, one JSON line each.",
|
|
108
|
+
fields: ["actor", "key", "member", "payload"],
|
|
109
|
+
idempotent: true,
|
|
110
|
+
kind: "watch",
|
|
111
|
+
readOnly: true,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
description: "Create an actor from its create input; fails when it already exists.",
|
|
115
|
+
fields: ["actor", "key", "payload", "commandId"],
|
|
116
|
+
idempotent: false,
|
|
117
|
+
kind: "create",
|
|
118
|
+
readOnly: false,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
description: "Create an actor from its create input unless it already exists.",
|
|
122
|
+
fields: ["actor", "key", "payload", "commandId"],
|
|
123
|
+
idempotent: true,
|
|
124
|
+
kind: "ensure",
|
|
125
|
+
readOnly: false,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
description: "List an actor's files a page at a time, with each path, size, digest and write time.",
|
|
129
|
+
fields: ["actor", "key", "prefix", "cursor", "limit"],
|
|
130
|
+
idempotent: true,
|
|
131
|
+
kind: "files/list",
|
|
132
|
+
readOnly: true,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
description: "List an actor's files a page at a time, with the total bytes they hold.",
|
|
136
|
+
fields: ["actor", "key", "prefix", "cursor", "limit"],
|
|
137
|
+
idempotent: true,
|
|
138
|
+
kind: "files/usage",
|
|
139
|
+
readOnly: true,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
description: "Download one of an actor's files, whole or the byte range offset and length give.",
|
|
143
|
+
fields: ["actor", "key", "path", "offset", "length"],
|
|
144
|
+
idempotent: true,
|
|
145
|
+
kind: "files/download",
|
|
146
|
+
readOnly: true,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
description: "Upload a file for an actor and print its receipt, which a command attaches at a path.",
|
|
150
|
+
fields: ["actor", "key", "content"],
|
|
151
|
+
idempotent: false,
|
|
152
|
+
kind: "files/upload",
|
|
153
|
+
readOnly: false,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
description:
|
|
157
|
+
"Fork an actor into a new actor at another key: a copy of its state and files at one moment, with none of its " +
|
|
158
|
+
"work in flight (its jobs, timers and undelivered messages stay with the original). A retry with the same " +
|
|
159
|
+
"commandId makes one copy.",
|
|
160
|
+
fields: ["actor", "key", "into", "commandId"],
|
|
161
|
+
idempotent: false,
|
|
162
|
+
kind: "fork",
|
|
163
|
+
readOnly: false,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
description: "List an actor's computers, each with its image, size, egress and state.",
|
|
167
|
+
fields: ["actor", "key"],
|
|
168
|
+
idempotent: true,
|
|
169
|
+
kind: "computers/list",
|
|
170
|
+
readOnly: true,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
description:
|
|
174
|
+
"Copy one file (at most 1 MiB) between an actor and its computer. fromComputer returns a receipt; attach it with an actor command.",
|
|
175
|
+
fields: ["actor", "key", "computer", "direction", "path", "computerPath"],
|
|
176
|
+
flag: "computerFiles",
|
|
177
|
+
idempotent: false,
|
|
178
|
+
kind: "computers/cp",
|
|
179
|
+
readOnly: false,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
description: "Show one of an actor's computers: its image, size, egress and state.",
|
|
183
|
+
fields: ["actor", "key", "computer"],
|
|
184
|
+
idempotent: true,
|
|
185
|
+
kind: "computers/get",
|
|
186
|
+
readOnly: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
description: "Read a page of a computer's log, oldest first; pass the last seq printed as after to read on.",
|
|
190
|
+
fields: ["actor", "key", "computer", "after", "limit"],
|
|
191
|
+
idempotent: true,
|
|
192
|
+
kind: "computers/logs",
|
|
193
|
+
readOnly: true,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
description: "Pause a computer, keeping its memory and disk, and print it.",
|
|
197
|
+
fields: ["actor", "key", "computer"],
|
|
198
|
+
idempotent: true,
|
|
199
|
+
kind: "computers/pause",
|
|
200
|
+
readOnly: false,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
description: "Resume a paused computer and print it; fails while a fork holds it.",
|
|
204
|
+
fields: ["actor", "key", "computer"],
|
|
205
|
+
idempotent: true,
|
|
206
|
+
kind: "computers/resume",
|
|
207
|
+
readOnly: false,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
description: "Stop a computer, keeping its disk, and print it.",
|
|
211
|
+
fields: ["actor", "key", "computer"],
|
|
212
|
+
idempotent: true,
|
|
213
|
+
kind: "computers/stop",
|
|
214
|
+
readOnly: false,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
description: "Delete a computer and its disk. The actor's own jobs make computers; this only removes one.",
|
|
218
|
+
fields: ["actor", "key", "computer"],
|
|
219
|
+
idempotent: true,
|
|
220
|
+
kind: "computers/delete",
|
|
221
|
+
readOnly: false,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
description:
|
|
225
|
+
"List an actor's durable computer steps, newest first: each one's job, name, computer, command and state, and its exit code once it ran.",
|
|
226
|
+
fields: ["actor", "key", "limit"],
|
|
227
|
+
flag: "computerSteps",
|
|
228
|
+
idempotent: true,
|
|
229
|
+
kind: "steps/list",
|
|
230
|
+
readOnly: true,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
description:
|
|
234
|
+
"Show one of an actor's durable computer steps with its output. A step in state unknown may have done its work; reconcile it by its id.",
|
|
235
|
+
fields: ["actor", "key", "step"],
|
|
236
|
+
flag: "computerSteps",
|
|
237
|
+
idempotent: true,
|
|
238
|
+
kind: "steps/get",
|
|
239
|
+
readOnly: true,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
description: "Load an actor ahead of its first call, so that call answers sooner.",
|
|
243
|
+
fields: ["actor", "key"],
|
|
244
|
+
idempotent: true,
|
|
245
|
+
kind: "warm",
|
|
246
|
+
readOnly: true,
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const streams: readonly ActorKind[] = ["files/download", "state", "watch"];
|
|
251
|
+
|
|
252
|
+
// Kinds that remove something for good, so the CLI asks a person first and an MCP client confirms (ADR 0023).
|
|
253
|
+
const dangerous: readonly ActorKind[] = ["computers/delete"];
|
|
254
|
+
|
|
255
|
+
const operationOf = ({ description, fields, flag, idempotent, kind, readOnly }: Declared): ActorOperation => {
|
|
256
|
+
const [owner, name] = commandOfKind(kind);
|
|
257
|
+
const confirm = Arr.contains(dangerous, kind);
|
|
258
|
+
|
|
259
|
+
const operation: ActorOperation = {
|
|
260
|
+
annotations: { confirm, destructive: confirm, idempotent, openWorld: false, readOnly },
|
|
261
|
+
command: [owner, name],
|
|
262
|
+
description,
|
|
263
|
+
fields,
|
|
264
|
+
flag: Option.fromUndefinedOr(flag),
|
|
265
|
+
id: `${owner}.${name}`,
|
|
266
|
+
input: inputOf(fields),
|
|
267
|
+
kind,
|
|
268
|
+
stream: Arr.contains(streams, kind),
|
|
269
|
+
tool: `${owner}_${name}`,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (kind === "computers/cp") {
|
|
273
|
+
return { ...operation, tool: "computers_files" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return operation;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Internal kinds, with no command or tool.
|
|
280
|
+
const unexposedKinds: readonly ActorKind[] = ["warm"];
|
|
281
|
+
|
|
282
|
+
const actorOperations: readonly ActorOperation[] = Arr.map(
|
|
283
|
+
Arr.filter(declared, ({ kind }) => !Arr.contains(unexposedKinds, kind)),
|
|
284
|
+
operationOf,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// The RPC tag a kind sends for an actor and member (akter ADR 0016).
|
|
288
|
+
interface Addressed {
|
|
289
|
+
readonly actor: string;
|
|
290
|
+
readonly kind: ActorKind;
|
|
291
|
+
readonly member: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const tagOf = ({ actor, kind, member }: Addressed): string => {
|
|
295
|
+
const tags: Readonly<Record<ActorKind, string>> = {
|
|
296
|
+
call: `${actor}/${member}`,
|
|
297
|
+
"computers/cp": `${actor}/computer/files`,
|
|
298
|
+
"computers/delete": `${actor}/computer/delete`,
|
|
299
|
+
"computers/get": `${actor}/computer/get`,
|
|
300
|
+
"computers/list": `${actor}/computer/list`,
|
|
301
|
+
"computers/logs": `${actor}/computer/logs`,
|
|
302
|
+
"computers/pause": `${actor}/computer/pause`,
|
|
303
|
+
"computers/resume": `${actor}/computer/resume`,
|
|
304
|
+
"computers/stop": `${actor}/computer/stop`,
|
|
305
|
+
create: `${actor}/create`,
|
|
306
|
+
ensure: `${actor}/ensure`,
|
|
307
|
+
"files/download": `${actor}/files/download`,
|
|
308
|
+
"files/list": `${actor}/files/list`,
|
|
309
|
+
"files/upload": `${actor}/files/upload`,
|
|
310
|
+
"files/usage": `${actor}/files/usage`,
|
|
311
|
+
fork: `${actor}/fork`,
|
|
312
|
+
query: `${actor}/${member}`,
|
|
313
|
+
state: `${actor}/state${subscribe}`,
|
|
314
|
+
"steps/get": `${actor}/computer/step`,
|
|
315
|
+
"steps/list": `${actor}/computer/steps`,
|
|
316
|
+
warm: `${actor}/warm`,
|
|
317
|
+
watch: `${actor}/${member}${subscribe}`,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return tags[kind];
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// The payload fields an RPC takes, sorted, such as "commandId,key,payload".
|
|
324
|
+
const payloadFields = (rpc: Rpc.AnyWithProps): string => {
|
|
325
|
+
const names = Option.match(Option.liftPredicate(rpc.payloadSchema.ast, SchemaAST.isObjects), {
|
|
326
|
+
onNone: () => [],
|
|
327
|
+
onSome: (ast) => Arr.map(ast.propertySignatures, ({ name }) => String(name)),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return Arr.join(Arr.sort(names, Str.Order), ",");
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
interface RpcSignature {
|
|
334
|
+
readonly fields: string;
|
|
335
|
+
readonly kind: ActorKind;
|
|
336
|
+
readonly stream: boolean;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// What each kind's RPC takes and whether it streams. Kinds with a fixed member are matched by it first.
|
|
340
|
+
const fixedMembers = {
|
|
341
|
+
create: { fields: "commandId,key,payload", kind: "create", stream: false },
|
|
342
|
+
ensure: { fields: "commandId,key,payload", kind: "ensure", stream: false },
|
|
343
|
+
fork: { fields: "commandId,into,key", kind: "fork", stream: false },
|
|
344
|
+
state: { fields: "key", kind: "state", stream: true },
|
|
345
|
+
warm: { fields: "key", kind: "warm", stream: false },
|
|
346
|
+
} satisfies Readonly<Record<string, RpcSignature>>;
|
|
347
|
+
|
|
348
|
+
const anyMember: readonly RpcSignature[] = [
|
|
349
|
+
{ fields: "commandId,key,payload", kind: "call", stream: false },
|
|
350
|
+
{ fields: "key,payload", kind: "query", stream: false },
|
|
351
|
+
{ fields: "key,payload", kind: "watch", stream: true },
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
// The file RPCs, by the tag after the actor's name. An upload is a session of three RPCs, so all three are its kind.
|
|
355
|
+
const fileRpcs = {
|
|
356
|
+
"files/download": { fields: "key,length,offset,path", kind: "files/download", stream: true },
|
|
357
|
+
"files/list": { fields: "cursor,key,limit,prefix", kind: "files/list", stream: false },
|
|
358
|
+
"files/upload/append": { fields: "bytes,key,offset,upload", kind: "files/upload", stream: false },
|
|
359
|
+
"files/upload/close": { fields: "bytes,key,upload", kind: "files/upload", stream: false },
|
|
360
|
+
"files/upload/open": { fields: "key", kind: "files/upload", stream: false },
|
|
361
|
+
"files/usage": { fields: "cursor,key,limit,prefix", kind: "files/usage", stream: false },
|
|
362
|
+
} satisfies Readonly<Record<string, RpcSignature>>;
|
|
363
|
+
|
|
364
|
+
// The computer RPCs, by the tag after the actor's name: none streams, and only the actor makes or runs computers.
|
|
365
|
+
const computerRpcs = {
|
|
366
|
+
"computer/delete": { fields: "computer,key", kind: "computers/delete", stream: false },
|
|
367
|
+
"computer/files": { fields: "computer,computerPath,direction,key,path", kind: "computers/cp", stream: false },
|
|
368
|
+
"computer/get": { fields: "computer,key", kind: "computers/get", stream: false },
|
|
369
|
+
"computer/list": { fields: "key", kind: "computers/list", stream: false },
|
|
370
|
+
"computer/logs": { fields: "after,computer,key,limit", kind: "computers/logs", stream: false },
|
|
371
|
+
"computer/pause": { fields: "computer,key", kind: "computers/pause", stream: false },
|
|
372
|
+
"computer/resume": { fields: "computer,key", kind: "computers/resume", stream: false },
|
|
373
|
+
"computer/step": { fields: "key,step", kind: "steps/get", stream: false },
|
|
374
|
+
"computer/steps": { fields: "key,limit", kind: "steps/list", stream: false },
|
|
375
|
+
"computer/stop": { fields: "computer,key", kind: "computers/stop", stream: false },
|
|
376
|
+
} satisfies Readonly<Record<string, RpcSignature>>;
|
|
377
|
+
|
|
378
|
+
const nestedRpcs = { ...fileRpcs, ...computerRpcs } satisfies Readonly<Record<string, RpcSignature>>;
|
|
379
|
+
|
|
380
|
+
// Members reserved for RPCs of their own (akter ADR 0016): no actor may declare them, so they are never a call or
|
|
381
|
+
// Query. `fork` is one RPC with its own signature above; `files` and `computer` name groups of them.
|
|
382
|
+
const laterMembers = ["computer", "files"];
|
|
383
|
+
|
|
384
|
+
// `<Actor>/<rest>`: the rest names a file or computer RPC, or a member with or without /subscribe.
|
|
385
|
+
const actorRest = /^[^/]+\/(?<rest>.+)$/u;
|
|
386
|
+
|
|
387
|
+
// `<Actor>/<member>` or `<Actor>/<member>/subscribe`; anything longer, such as `<Actor>/computer/exec`, is not a kind.
|
|
388
|
+
const actorTag = /^[^/]+\/(?<member>[^/]+)(?:\/subscribe)?$/u;
|
|
389
|
+
|
|
390
|
+
// The kind an actor's RPC belongs to, or none when the registry has no operation for it. The coverage test runs
|
|
391
|
+
// This over a real actor's RPC group, so a new kind of RPC (files, computer, fork) fails it until the registry
|
|
392
|
+
// Covers it.
|
|
393
|
+
const kindOfRpc = ([tag, rpc]: readonly [string, Rpc.AnyWithProps]): Option.Option<ActorKind> => {
|
|
394
|
+
const member = Option.fromUndefinedOr(actorTag.exec(tag)?.groups?.member);
|
|
395
|
+
const fields = payloadFields(rpc);
|
|
396
|
+
const stream = RpcSchema.isStreamSchema(rpc.successSchema);
|
|
397
|
+
|
|
398
|
+
const rest = Option.fromUndefinedOr(actorRest.exec(tag)?.groups?.rest);
|
|
399
|
+
const nested = Option.flatMap(rest, (name) => Rec.get<string, RpcSignature>(nestedRpcs, name));
|
|
400
|
+
|
|
401
|
+
const candidates = Option.match(
|
|
402
|
+
Option.filter(member, (name) => !Arr.contains(laterMembers, name)),
|
|
403
|
+
{
|
|
404
|
+
onNone: (): readonly RpcSignature[] => Option.toArray(nested),
|
|
405
|
+
onSome: (name) =>
|
|
406
|
+
Option.match(Rec.get<string, RpcSignature>(fixedMembers, name), { onNone: () => anyMember, onSome: Arr.of }),
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
return Option.map(
|
|
411
|
+
Arr.findFirst(candidates, (signature) => signature.fields === fields && signature.stream === stream),
|
|
412
|
+
(signature) => signature.kind,
|
|
413
|
+
);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Every RPC in an actor's group (its requests, by tag) with the kind the registry gives it.
|
|
417
|
+
const kindsOf = (
|
|
418
|
+
requests: ReadonlyMap<string, Rpc.AnyWithProps>,
|
|
419
|
+
): readonly (readonly [string, Option.Option<ActorKind>])[] =>
|
|
420
|
+
Arr.map(Arr.fromIterable(requests), ([tag, rpc]) => [tag, kindOfRpc([tag, rpc])] as const);
|
|
421
|
+
|
|
422
|
+
// The CLI/tool input: key and payload use the actor's JSON schemas. fileFields carries file and computer-copy fields,
|
|
423
|
+
// Never upload bytes; member is used only by call, query and watch.
|
|
424
|
+
interface ActorInput {
|
|
425
|
+
readonly actor: string;
|
|
426
|
+
readonly commandId: Option.Option<string>;
|
|
427
|
+
readonly fileFields: Readonly<Record<string, Schema.Json>>;
|
|
428
|
+
readonly into: Option.Option<Schema.Json>;
|
|
429
|
+
readonly key: Schema.Json;
|
|
430
|
+
readonly member: string;
|
|
431
|
+
readonly payload: Option.Option<Schema.Json>;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The RPC request an operation sends: its tag and its payload, { commandId?, key, payload? }.
|
|
435
|
+
interface ActorRequest {
|
|
436
|
+
readonly payload: Readonly<Record<string, Schema.Json>>;
|
|
437
|
+
readonly tag: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const someField = <Value extends Schema.Json>(
|
|
441
|
+
name: string,
|
|
442
|
+
value: Option.Option<Value>,
|
|
443
|
+
): Readonly<Record<string, Schema.Json>> =>
|
|
444
|
+
Option.match(value, { onNone: () => ({}), onSome: (found) => ({ [name]: found }) });
|
|
445
|
+
|
|
446
|
+
// Shared CLI/MCP request. Commands receive the given id or a fresh UUIDv7; an absent payload is null (Void's JSON).
|
|
447
|
+
const actorRequestOf =
|
|
448
|
+
(operation: ActorOperation): ((input: ActorInput) => Effect.Effect<ActorRequest, never, SecureRandom.SecureRandom>) =>
|
|
449
|
+
(input: ActorInput): Effect.Effect<ActorRequest, never, SecureRandom.SecureRandom> =>
|
|
450
|
+
Effect.gen(function* buildRequest() {
|
|
451
|
+
const takes = (name: FieldName): boolean => Arr.contains(operation.fields, name);
|
|
452
|
+
const minted = SecureRandom.SecureRandom.use((random) => random.uuidV7());
|
|
453
|
+
const fresh = yield* Effect.when(minted, Effect.succeed(takes("commandId") && Option.isNone(input.commandId)));
|
|
454
|
+
|
|
455
|
+
const commandId = Option.filter(
|
|
456
|
+
Option.orElse(input.commandId, () => fresh),
|
|
457
|
+
() => takes("commandId"),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const payload = Option.liftPredicate(Option.getOrNull(input.payload), () => takes("payload"));
|
|
461
|
+
|
|
462
|
+
const fileFields = Rec.filter(input.fileFields, (_value, name) =>
|
|
463
|
+
Arr.some(operation.fields, (field) => field === name),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const fields: ActorRequest["payload"] = {
|
|
467
|
+
...fileFields,
|
|
468
|
+
...someField("commandId", commandId),
|
|
469
|
+
...someField(
|
|
470
|
+
"into",
|
|
471
|
+
Option.filter(input.into, () => takes("into")),
|
|
472
|
+
),
|
|
473
|
+
...someField("payload", payload),
|
|
474
|
+
key: input.key,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
return { payload: fields, tag: tagOf({ actor: input.actor, kind: operation.kind, member: input.member }) };
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
export { actorOperations, actorRequestOf, kindsOf, tagOf, unexposedKinds };
|
|
481
|
+
|
|
482
|
+
export type { ActorInput, ActorKind, ActorOperation, ActorRequest, Annotations };
|
|
483
|
+
|
|
484
|
+
export type { FieldName } from "./actor-fields.pure.js";
|