@worker-protocol/hono 0.1.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/dist/actions.d.ts +200 -0
- package/dist/actions.js +203 -0
- package/dist/buckets.d.ts +36 -0
- package/dist/buckets.js +133 -0
- package/dist/codes.d.ts +201 -0
- package/dist/codes.js +129 -0
- package/dist/collection.d.ts +44 -0
- package/dist/collection.js +62 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +22 -0
- package/dist/metrics.d.ts +56 -0
- package/dist/metrics.js +122 -0
- package/dist/mount.d.ts +92 -0
- package/dist/mount.js +432 -0
- package/dist/surfaces.d.ts +483 -0
- package/dist/surfaces.js +424 -0
- package/dist/tasks.d.ts +78 -0
- package/dist/tasks.js +45 -0
- package/dist/worker.d.ts +169 -0
- package/dist/worker.js +1 -0
- package/package.json +51 -0
package/dist/surfaces.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
+
import { activityPage, alertPage, DIMENSION_NAME, descriptor, error, health, INSTANT, metricGranularity, metricPage, registry, taskPage, } from "@worker-protocol/schemas";
|
|
3
|
+
import { byCode } from "./codes.js";
|
|
4
|
+
/**
|
|
5
|
+
* The surface: which verb answers where, which parameters and headers travel, and which refusals
|
|
6
|
+
* each operation gives — declared as Hono routes, because the routes are what runs.
|
|
7
|
+
*
|
|
8
|
+
* `schemas/` fixes what a document carries and `spec/` fixes behaviour that no schema can state.
|
|
9
|
+
* Between them sat a third thing that was written down in neither: the *call*. Which verb a
|
|
10
|
+
* Capability answers, which query parameters a read takes, which code answers which refusal — all
|
|
11
|
+
* of it lived in tables in `spec/`, which is to say in English. An SDK generated from what existed
|
|
12
|
+
* before this file got the shapes and had to read prose for everything else, and two SDKs that each
|
|
13
|
+
* read it correctly could still expose two different APIs for one call.
|
|
14
|
+
*
|
|
15
|
+
* So the surface is declared here and `openapi/` is generated from it, committed, and compared in
|
|
16
|
+
* CI — the reason and the mechanism `schemas/` and `rules.json` already have.
|
|
17
|
+
*
|
|
18
|
+
* **It is declared as `createRoute` objects and not as data of this repository's own, because most
|
|
19
|
+
* Workers built on this protocol run on Hono and `mount()` in this package is what they mount.**
|
|
20
|
+
* Declaring the surface once as data for the generator and once more as routes for what runs would
|
|
21
|
+
* be the duplication this repository refuses everywhere else, so the declaration that runs is the
|
|
22
|
+
* one that generates. What that costs is stated rather than hidden: the routes are written in a
|
|
23
|
+
* library's vocabulary, `generate-openapi.ts` has to normalise what that library emits back into
|
|
24
|
+
* the shape `openapi/` had before, and one parameter below carries a `preprocess` that is runtime
|
|
25
|
+
* glue and not declaration. What it buys is that a Worker author who mounts these routes can ask
|
|
26
|
+
* their own app for its OpenAPI document and get one that describes *their* Worker — their Actions,
|
|
27
|
+
* their Task payloads — with no second generator.
|
|
28
|
+
*
|
|
29
|
+
* **Every item cites the rule it encodes**, exactly as each Zod node's `description` does, so that
|
|
30
|
+
* the attribution `packages/conformance` reads off `schemas/` reaches the surface too. Nothing here
|
|
31
|
+
* decides anything: a line without a rule id behind it is this file inventing an obligation, which
|
|
32
|
+
* is the thing `conformance/README.md` forbids a verifier and forbids this for the same reason.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here handles a request. A route is a declaration; `mount.ts` is where handlers live.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* A response schema, named after its file in `schemas/`.
|
|
38
|
+
*
|
|
39
|
+
* The name is the registry id, carried as the Zod `id` meta that `@hono/zod-openapi` turns into a
|
|
40
|
+
* named component — so the generator can turn `#/components/schemas/health` into
|
|
41
|
+
* `../schemas/health.json` by string replacement and nothing in `openapi/` retypes a schema. It is
|
|
42
|
+
* the meta and not `.openapi()` because the schema is another package's instance, and a method
|
|
43
|
+
* patched onto one copy of `zod` does not reach an object built by another. Memoised, because Zod
|
|
44
|
+
* refuses to register one id twice.
|
|
45
|
+
*/
|
|
46
|
+
const refs = new Map();
|
|
47
|
+
const ref = (schema) => {
|
|
48
|
+
const id = registry.get(schema)?.id;
|
|
49
|
+
if (id === undefined)
|
|
50
|
+
throw new Error("a response schema must be registered in schemas/");
|
|
51
|
+
let named = refs.get(schema);
|
|
52
|
+
if (named === undefined) {
|
|
53
|
+
named = schema.meta({ ...schema.meta(), id });
|
|
54
|
+
refs.set(schema, named);
|
|
55
|
+
}
|
|
56
|
+
return named;
|
|
57
|
+
};
|
|
58
|
+
/** `RULE. text`, the citation convention every description here and in `schemas/` holds. */
|
|
59
|
+
const cite = (rule, text) => `${rule}. ${text}`;
|
|
60
|
+
/** ENDP-5 — on every protocol response, whatever it says. */
|
|
61
|
+
export const RESPONSE_HEADERS = {
|
|
62
|
+
"Worker-Protocol-Edition": {
|
|
63
|
+
description: cite("ENDP-5", "The edition that produced this answer. A caller that sees one it did not expect re-reads the Descriptor rather than parsing the body."),
|
|
64
|
+
required: true,
|
|
65
|
+
schema: { type: "string", pattern: "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$" },
|
|
66
|
+
},
|
|
67
|
+
"Worker-Protocol-Capability-Version": {
|
|
68
|
+
description: cite("ENDP-5", "The Capability version that produced this answer."),
|
|
69
|
+
required: true,
|
|
70
|
+
schema: { type: "integer", minimum: 1 },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
/** ENDP-6 — a caller may state the version it expects, on any request. */
|
|
74
|
+
const expectedVersion = z.coerce
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.min(1)
|
|
78
|
+
.optional()
|
|
79
|
+
.openapi({
|
|
80
|
+
description: cite("ENDP-6", "The Capability version the caller expects. A Worker that cannot answer it refuses the request whole with `400` and `unsupported_version`, and never substitutes its own."),
|
|
81
|
+
type: "integer",
|
|
82
|
+
minimum: 1,
|
|
83
|
+
});
|
|
84
|
+
/** The one header every operation takes. */
|
|
85
|
+
const versionHeader = z.object({ "Worker-Protocol-Capability-Version": expectedVersion });
|
|
86
|
+
/** ENDP-20 — every collection pages the same way. */
|
|
87
|
+
const cursor = z
|
|
88
|
+
.string()
|
|
89
|
+
.min(1)
|
|
90
|
+
.optional()
|
|
91
|
+
.openapi({
|
|
92
|
+
description: cite("ENDP-21", "Opaque, produced only by the Worker, never constructed by a caller. Absent from an answer at the end of the collection."),
|
|
93
|
+
});
|
|
94
|
+
const instant = (description) => z.string().regex(INSTANT).optional().openapi({ description, format: "date-time" });
|
|
95
|
+
/** One answer that is not a refusal: `schema` is a registered Zod object, or null for no body. */
|
|
96
|
+
const answer = (rule, description, schema) => ({
|
|
97
|
+
description: cite(rule, description),
|
|
98
|
+
headers: RESPONSE_HEADERS,
|
|
99
|
+
...(schema === null ? {} : { content: { "application/json": { schema: ref(schema) } } }),
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* The refusals an operation gives, one response per distinct status.
|
|
103
|
+
*
|
|
104
|
+
* Several codes share a status — ENDP-26 forbids one code under two statuses, not two codes under
|
|
105
|
+
* one — so the description names which may arrive. The status comes from `CODES` and nowhere else,
|
|
106
|
+
* which is what makes a refusal here agree with ENDP-26 by construction.
|
|
107
|
+
*/
|
|
108
|
+
const refusals = (codes) => {
|
|
109
|
+
const byStatus = new Map();
|
|
110
|
+
for (const [code, rule] of codes) {
|
|
111
|
+
const status = byCode.get(code)?.status;
|
|
112
|
+
if (status === undefined)
|
|
113
|
+
throw new Error(`${code} is not in CODES`);
|
|
114
|
+
byStatus.set(status, [...(byStatus.get(status) ?? []), `\`${code}\` (${rule})`]);
|
|
115
|
+
}
|
|
116
|
+
return Object.fromEntries([...byStatus].map(([status, names]) => [
|
|
117
|
+
status,
|
|
118
|
+
{
|
|
119
|
+
description: `ENDP-25. The shared error envelope, carrying one of: ${names.join(", ")}.`,
|
|
120
|
+
headers: RESPONSE_HEADERS,
|
|
121
|
+
content: { "application/json": { schema: ref(error) } },
|
|
122
|
+
},
|
|
123
|
+
]));
|
|
124
|
+
};
|
|
125
|
+
/** Every refusal any surface may give for a reason that is not its own (REG-3, ENDP-6, ENDP-24). */
|
|
126
|
+
const SHARED = [
|
|
127
|
+
["unsupported_version", "ENDP-6"],
|
|
128
|
+
["unauthenticated", "REG-3"],
|
|
129
|
+
["forbidden", "ENDP-29"],
|
|
130
|
+
["internal_error", "ENDP-29"],
|
|
131
|
+
["unavailable", "ENDP-29"],
|
|
132
|
+
];
|
|
133
|
+
// ---- the routes ---------------------------------------------------------------------------------
|
|
134
|
+
export const readDescriptor = createRoute({
|
|
135
|
+
method: "get",
|
|
136
|
+
path: "/.well-known/worker-protocol",
|
|
137
|
+
summary: "Read the Descriptor",
|
|
138
|
+
description: cite("DESC-5", "Reading a Descriptor is a GET and changes nothing. REG-21 has the Worker accept the credential recorded for it here as on every other address."),
|
|
139
|
+
request: { headers: versionHeader },
|
|
140
|
+
responses: {
|
|
141
|
+
200: answer("DESC-1", "The Descriptor.", descriptor),
|
|
142
|
+
...refusals(SHARED),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
export const pollHealth = createRoute({
|
|
146
|
+
method: "get",
|
|
147
|
+
path: "/",
|
|
148
|
+
summary: "Poll health",
|
|
149
|
+
description: cite("HLTH-5", "Answers `200` whatever it reports. The status is read from the body, and a response that is not `200` means the Worker did not answer rather than that it is unwell — which is the one distinction a health surface exists to draw."),
|
|
150
|
+
request: { headers: versionHeader },
|
|
151
|
+
responses: {
|
|
152
|
+
200: answer("HLTH-2", "One status and a map of named checks.", health),
|
|
153
|
+
...refusals(SHARED),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
export const readMetric = createRoute({
|
|
157
|
+
method: "get",
|
|
158
|
+
path: "/",
|
|
159
|
+
summary: "Read one metric",
|
|
160
|
+
description: cite("MET-8", "A read names one metric and answers one series. MET-18: a dimension a read neither fixes nor breaks down by is accumulated over, so an unfiltered answer is the total and never a slice the Worker chose."),
|
|
161
|
+
request: {
|
|
162
|
+
query: z.object({
|
|
163
|
+
metric: z
|
|
164
|
+
.string()
|
|
165
|
+
.min(1)
|
|
166
|
+
.openapi({
|
|
167
|
+
description: cite("MET-8", "One of the metrics the entry declares. One not declared is `404`."),
|
|
168
|
+
}),
|
|
169
|
+
granularity: z.optional(metricGranularity).openapi({
|
|
170
|
+
description: cite("MET-8", "Required where the metric declares more than one, and omittable where it declares exactly one."),
|
|
171
|
+
}),
|
|
172
|
+
from: instant(cite("MET-11", "Inclusive. Absent, the start of the current bucket.")),
|
|
173
|
+
to: instant(cite("MET-11", "Exclusive — the interval is half-open, so two adjacent reads add up. Absent, the instant the Worker answers.")),
|
|
174
|
+
// The one place runtime glue sits inside a declaration. Hono's validator hands a query
|
|
175
|
+
// parameter that appears once over as a string, so a plain array refuses `?by=taskType`.
|
|
176
|
+
// The preprocess wraps a lone value; the generator sees the inner array and nothing else.
|
|
177
|
+
by: z
|
|
178
|
+
.preprocess((value) => (value === undefined || Array.isArray(value) ? value : [value]), z.array(z.string().regex(DIMENSION_NAME)).optional())
|
|
179
|
+
.openapi({
|
|
180
|
+
description: cite("MET-19", "Break down by a dimension, and only by one that declared its set of values: over a free dimension nothing would bound the number of series."),
|
|
181
|
+
// `form` with `explode` is what turns a list into `?by=a&by=b` — not a formatting
|
|
182
|
+
// preference but the shape MET-19 already fixed, said in the vocabulary a generator reads.
|
|
183
|
+
param: { style: "form", explode: true },
|
|
184
|
+
}),
|
|
185
|
+
// MET-16 spells a dimension into a parameter of its own NAME, and those names are the
|
|
186
|
+
// Worker's — read from its Descriptor and never fixed here. An exploded object is the one
|
|
187
|
+
// shape OpenAPI has for that: `dimensions` is the parameter's name and never travels, and
|
|
188
|
+
// what reaches the wire is `?taskType=verify-vehicle&tenant=acme`. At runtime nothing
|
|
189
|
+
// arrives under this name; `mount()` hands the raw query to the Worker, which knows them.
|
|
190
|
+
dimensions: z
|
|
191
|
+
.record(z.string(), z.string())
|
|
192
|
+
.optional()
|
|
193
|
+
.openapi({
|
|
194
|
+
description: cite("MET-16", "Each dimension the metric declares, fixed by a parameter named exactly as the dimension. The names are the Worker's own and are read from its Descriptor, so they are not enumerated here. A value outside a declared set is `400`; where a dimension declares no set, any string is accepted and may match nothing."),
|
|
195
|
+
param: { style: "form", explode: true },
|
|
196
|
+
}),
|
|
197
|
+
cursor,
|
|
198
|
+
}),
|
|
199
|
+
headers: versionHeader,
|
|
200
|
+
},
|
|
201
|
+
responses: {
|
|
202
|
+
200: answer("MET-14", "Buckets ascending by start, in the shared page envelope.", metricPage),
|
|
203
|
+
...refusals([
|
|
204
|
+
["not_found", "MET-9"],
|
|
205
|
+
["invalid_parameter", "MET-10"],
|
|
206
|
+
["unknown_filter", "ENDP-24"],
|
|
207
|
+
...SHARED,
|
|
208
|
+
]),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
export const performAction = createRoute({
|
|
212
|
+
method: "post",
|
|
213
|
+
path: "/",
|
|
214
|
+
summary: "Perform an Action",
|
|
215
|
+
description: cite("ACT-5", "The body is the input and carries nothing else, so a console posts its form result verbatim and a Worker validates it against the schema it published with no envelope to unwrap."),
|
|
216
|
+
request: {
|
|
217
|
+
query: z.object({
|
|
218
|
+
action: z
|
|
219
|
+
.string()
|
|
220
|
+
.min(1)
|
|
221
|
+
.openapi({
|
|
222
|
+
description: cite("ACT-5", "One of the Actions the entry declares. Naming none is `400`; naming one it does not declare is `404`."),
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
headers: z.object({
|
|
226
|
+
"Idempotency-Key": z
|
|
227
|
+
.string()
|
|
228
|
+
.min(1)
|
|
229
|
+
.optional()
|
|
230
|
+
.openapi({
|
|
231
|
+
description: cite("ENDP-15", "Where the Action declares it reads a key from the header. Within the declared window a repeat under the same key is not a second performance; the same key with a different body is `409`."),
|
|
232
|
+
}),
|
|
233
|
+
"Worker-Protocol-Capability-Version": expectedVersion,
|
|
234
|
+
}),
|
|
235
|
+
// No schema, deliberately, and so no validation here: the body is the Action's own input,
|
|
236
|
+
// against the JSON Schema its entry declares, and this protocol has no data model. The Worker
|
|
237
|
+
// reads the raw body and answers `malformed_request` or `schema_mismatch` itself (ACT-8).
|
|
238
|
+
body: {
|
|
239
|
+
required: true,
|
|
240
|
+
description: cite("ACT-2", "The Action's own input, against the JSON Schema its entry declares. This protocol has no data model, so no shape is fixed here."),
|
|
241
|
+
content: { "application/json": { schema: {} } },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
responses: {
|
|
245
|
+
200: answer("ACT-10", "The Action completed and declares a result, against its own declared schema.", null),
|
|
246
|
+
204: answer("ACT-10", "The Action completed and declares no result.", null),
|
|
247
|
+
202: answer("ACT-11", "The Action declares that it does not complete within the call. No body.", null),
|
|
248
|
+
...refusals([
|
|
249
|
+
["invalid_parameter", "ACT-7"],
|
|
250
|
+
["schema_mismatch", "ACT-8"],
|
|
251
|
+
["idempotency_key_required", "ENDP-18"],
|
|
252
|
+
["not_found", "ACT-6"],
|
|
253
|
+
["idempotency_key_reused", "ENDP-17"],
|
|
254
|
+
["unprocessable_content", "ACT-9"],
|
|
255
|
+
...SHARED,
|
|
256
|
+
]),
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
export const readTasks = createRoute({
|
|
260
|
+
method: "get",
|
|
261
|
+
path: "/",
|
|
262
|
+
summary: "Read the open Tasks",
|
|
263
|
+
description: cite("TASK-5", "Listing a Worker's open Tasks does not consume them, which is what ENDP-2 spends its argument on and why this is a GET."),
|
|
264
|
+
request: {
|
|
265
|
+
query: z.object({
|
|
266
|
+
type: z
|
|
267
|
+
.string()
|
|
268
|
+
.optional()
|
|
269
|
+
.openapi({
|
|
270
|
+
description: cite("TASK-8", "One of the Task types the entry declares. A type it does not declare is `400`."),
|
|
271
|
+
}),
|
|
272
|
+
cursor,
|
|
273
|
+
}),
|
|
274
|
+
headers: versionHeader,
|
|
275
|
+
},
|
|
276
|
+
responses: {
|
|
277
|
+
200: answer("TASK-5", "Tasks whose conditions hold, in the shared page envelope.", taskPage),
|
|
278
|
+
...refusals([["invalid_parameter", "TASK-8"], ["unknown_filter", "ENDP-24"], ...SHARED]),
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
export const takeNudge = createRoute({
|
|
282
|
+
method: "post",
|
|
283
|
+
path: "/",
|
|
284
|
+
summary: "Tell the Worker there is work of a Task type",
|
|
285
|
+
description: cite("NDG-2", "A nudge carries a Task type and nothing else, and buys latency and nothing else: the receiver has not accepted the work and has not looked yet, and reads the Tasks at the address that raised them exactly as it would have on its next schedule. TASK-15 is why the Task itself does not travel — the owner is authoritative over whether the condition still holds, so a Task in flight is a claim that may already be false."),
|
|
286
|
+
request: {
|
|
287
|
+
headers: versionHeader,
|
|
288
|
+
// The one body in this protocol whose shape is NOT the Worker's, which is the whole reason
|
|
289
|
+
// `nudges` is an address rather than an Action: ACT-2 has an Action's input be the shape its
|
|
290
|
+
// declarer chose, and this one is fixed here. Written as a reference rather than a Zod object
|
|
291
|
+
// because `mount()` validates it itself — every refusal from this address is then one this
|
|
292
|
+
// repository wrote, in the envelope ENDP-25 fixes.
|
|
293
|
+
body: {
|
|
294
|
+
required: true,
|
|
295
|
+
description: cite("NDG-2", "One Task type, and nothing else."),
|
|
296
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/nudge" } } },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
responses: {
|
|
300
|
+
204: answer("NDG-2", "The Worker was told. No body, because there is nothing to say: it has promised nothing and looked at nothing.", null),
|
|
301
|
+
...refusals([
|
|
302
|
+
["not_found", "NDG-3"],
|
|
303
|
+
["schema_mismatch", "NDG-2"],
|
|
304
|
+
["malformed_request", "ENDP-4"],
|
|
305
|
+
...SHARED,
|
|
306
|
+
]),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
export const readAlerts = createRoute({
|
|
310
|
+
method: "get",
|
|
311
|
+
path: "/",
|
|
312
|
+
summary: "Read the Alerts whose conditions hold",
|
|
313
|
+
description: cite("ALRT-2", "ALRT-6 answers the same Alerts to every caller the Worker authenticates: the party an Alert is for is whoever operates the Worker, and that is enrollment rather than a Contract."),
|
|
314
|
+
request: { query: z.object({ cursor }), headers: versionHeader },
|
|
315
|
+
responses: {
|
|
316
|
+
200: answer("ALRT-2", "Alerts whose conditions hold, in the shared page envelope.", alertPage),
|
|
317
|
+
...refusals([["unknown_filter", "ENDP-24"], ...SHARED]),
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
export const readActivity = createRoute({
|
|
321
|
+
method: "get",
|
|
322
|
+
path: "/",
|
|
323
|
+
summary: "Read what the Worker is doing and has undertaken to do",
|
|
324
|
+
description: cite("ACTV-2", "ACTV-6 answers the same activities to every caller the Worker authenticates: the party this surface is for is whoever operates the Worker, and that is enrollment rather than a Contract. Not a Claim — the Worker reports its own Fact, and nothing is held on anyone's behalf."),
|
|
325
|
+
request: { query: z.object({ cursor }), headers: versionHeader },
|
|
326
|
+
responses: {
|
|
327
|
+
200: answer("ACTV-2", "The activities the Worker holds, in the shared page envelope.", activityPage),
|
|
328
|
+
...refusals([["unknown_filter", "ENDP-24"], ...SHARED]),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
const address = (capability, rule, extra = "") => ({
|
|
332
|
+
variable: "address",
|
|
333
|
+
rule,
|
|
334
|
+
description: `The address the \`${capability}\` entry declares, resolved per DESC-12${extra}.`,
|
|
335
|
+
});
|
|
336
|
+
export const SURFACES = [
|
|
337
|
+
{
|
|
338
|
+
document: "descriptor",
|
|
339
|
+
capability: "descriptor",
|
|
340
|
+
server: {
|
|
341
|
+
variable: "baseUrl",
|
|
342
|
+
rule: "DESC-3",
|
|
343
|
+
description: "The Worker's enrolled base URL: one absolute `https` URL, with or without a path.",
|
|
344
|
+
},
|
|
345
|
+
title: "worker-protocol — the Descriptor",
|
|
346
|
+
description: "The one route this protocol fixes, and the whole of what every Worker owes. Every other address is declared in the document this answers (ENDP-1).",
|
|
347
|
+
route: readDescriptor,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
document: "health",
|
|
351
|
+
capability: "health",
|
|
352
|
+
server: address("health", "HLTH-1", " against the URL the Descriptor was read from"),
|
|
353
|
+
title: "worker-protocol — health",
|
|
354
|
+
description: "The answer to a poll: one status for the Worker and a map of named checks.",
|
|
355
|
+
route: pollHealth,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
document: "metrics",
|
|
359
|
+
capability: "metrics",
|
|
360
|
+
server: address("metrics", "MET-1"),
|
|
361
|
+
title: "worker-protocol — metrics",
|
|
362
|
+
description: "Named quantities accumulated over declared periods. The Descriptor is the catalog: this surface answers values and never lists what exists (MET-21).",
|
|
363
|
+
route: readMetric,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
document: "actions",
|
|
367
|
+
capability: "actions",
|
|
368
|
+
server: address("actions", "ACT-16"),
|
|
369
|
+
title: "worker-protocol — actions",
|
|
370
|
+
description: "Performing an operation a Worker accepts. The `configure` reading address of ACT-15 is not described here: its address is declared inside an Action rather than beside the Capability, and the document it answers is shaped by that Action's own input schema, which is the Worker's.",
|
|
371
|
+
route: performAction,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
document: "tasks",
|
|
375
|
+
capability: "tasks",
|
|
376
|
+
server: {
|
|
377
|
+
variable: "address",
|
|
378
|
+
rule: "TASK-27",
|
|
379
|
+
description: "The one address the `tasks` entry declares, resolved per DESC-12.",
|
|
380
|
+
},
|
|
381
|
+
title: "worker-protocol — tasks",
|
|
382
|
+
description: "The Tasks whose conditions hold. TASK-6 answers only those the credential presented covers.",
|
|
383
|
+
route: readTasks,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
document: "nudges",
|
|
387
|
+
capability: "nudges",
|
|
388
|
+
server: address("nudges", "NDG-1"),
|
|
389
|
+
title: "worker-protocol — nudges",
|
|
390
|
+
description: "Being told there is work of a Task type. Declaring it is optional and what it buys is latency: a consumer that reads on its own schedule is slower and never wrong (TASK-19).",
|
|
391
|
+
route: takeNudge,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
document: "alerts",
|
|
395
|
+
capability: "alerts",
|
|
396
|
+
server: address("alerts", "ALRT-1"),
|
|
397
|
+
title: "worker-protocol — alerts",
|
|
398
|
+
description: "Conditions an operator should see. An Alert ends when its condition stops holding and nobody dismisses one (ALRT-5), so there is no write here.",
|
|
399
|
+
route: readAlerts,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
document: "activity",
|
|
403
|
+
capability: "activity",
|
|
404
|
+
server: address("activity", "ACTV-1"),
|
|
405
|
+
title: "worker-protocol — activity",
|
|
406
|
+
description: "What a Worker is doing and has undertaken to do. An activity ends when the Worker stops holding it and nobody declares that (ACTV-5), so there is no write here.",
|
|
407
|
+
route: readActivity,
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
/**
|
|
411
|
+
* What this file deliberately does not describe.
|
|
412
|
+
*
|
|
413
|
+
* - **`events`.** It has no address: it travels over a broker this protocol declines to name, which
|
|
414
|
+
* is the one case DESC-22 leaves the shared entry's address optional for. There is no call to
|
|
415
|
+
* describe, and inventing a document for it would be the artifact claiming a surface the
|
|
416
|
+
* specification refuses to fix.
|
|
417
|
+
* - **Anything whose shape is the Worker's own.** An Action's input and result, a Task's payload, an
|
|
418
|
+
* event's data. Each is a JSON Schema a Worker declares in its Descriptor. A Worker that mounts
|
|
419
|
+
* these routes and asks its own app for a document gets them in it, because that document is
|
|
420
|
+
* generated from that Worker's routes and not from this file.
|
|
421
|
+
* - **A dimension named as a query parameter.** MET-16 spells a dimension into a parameter of its
|
|
422
|
+
* own name, and those names are the Worker's. `dimensions` above is the OpenAPI shape for that
|
|
423
|
+
* and never travels under its own name.
|
|
424
|
+
*/
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tasks` surface, which is everything in `spec/tasks.md` that is not a Fact.
|
|
3
|
+
*
|
|
4
|
+
* A Worker declares what it raises and answers, and says which Tasks' conditions hold right now.
|
|
5
|
+
* That is the whole of what it owes, because TASK-15 makes a Task a condition over the Worker's own
|
|
6
|
+
* Facts and nothing else here can know one. The page envelope, the cursor, the filter that must be
|
|
7
|
+
* refused rather than ignored, the ordering that makes paging terminate: those are fixed by rules,
|
|
8
|
+
* and a Worker writing any of them again would be re-deriving the specification.
|
|
9
|
+
*
|
|
10
|
+
* **This file used to be twice as long and most of what is gone was a Claim** — a lease, an expiry,
|
|
11
|
+
* a fencing token, counts of what had failed and lapsed, an identifier for whoever held one.
|
|
12
|
+
* `spec/tasks.md` carries the argument for withdrawing it.
|
|
13
|
+
*/
|
|
14
|
+
import type { qualifiedName } from "@worker-protocol/schemas";
|
|
15
|
+
import type * as z from "zod";
|
|
16
|
+
import { type Page } from "./collection.ts";
|
|
17
|
+
import type { Refusal } from "./worker.ts";
|
|
18
|
+
/** What a Worker says about a Task whose condition holds: the domain, and the whole of it. */
|
|
19
|
+
export type OpenTask = {
|
|
20
|
+
/** TASK-28. The Worker's own id, opaque to everyone else. */
|
|
21
|
+
id: string;
|
|
22
|
+
/** TASK-4, NAME-7. One of the types the entry declares under `raises`. */
|
|
23
|
+
type: z.infer<typeof qualifiedName>;
|
|
24
|
+
/** TASK-28. Against the schema that type declared. The Worker's own shape. */
|
|
25
|
+
payload: unknown;
|
|
26
|
+
/**
|
|
27
|
+
* TASK-28. When this condition began.
|
|
28
|
+
*
|
|
29
|
+
* It is what a stuck Task is read from, and it is the one field here a Worker has to think
|
|
30
|
+
* about: a condition derived fresh on every read has no memory of when it started, so a Worker
|
|
31
|
+
* that answers `new Date()` is answering *now* and telling an operator nothing.
|
|
32
|
+
*/
|
|
33
|
+
since: Date;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* TASK-32. What a Worker declares about one Task type it raises.
|
|
37
|
+
*
|
|
38
|
+
* `payload` is a Zod object and not a JSON Schema written by hand, for the reason ACT-2's input is
|
|
39
|
+
* one: the Descriptor carries the JSON Schema a console renders a form from, `mount()` generates it
|
|
40
|
+
* from this, and there is one declaration rather than two that can drift.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* TASK-32. What a Worker declares about one Task type it raises: what it sends, and the ONE Action
|
|
44
|
+
* of its own that answers it. A Task with several endings has them as variants of that Action's
|
|
45
|
+
* input — `z.discriminatedUnion` — and an answerer that produces one variant is answering a subtype.
|
|
46
|
+
*/
|
|
47
|
+
export type TaskTypes = Record<string, {
|
|
48
|
+
payload: z.ZodType;
|
|
49
|
+
answeredBy: string;
|
|
50
|
+
}>;
|
|
51
|
+
/** What a Worker author implements for `tasks`, beside the declaration itself. */
|
|
52
|
+
export type TaskFacts = {
|
|
53
|
+
/**
|
|
54
|
+
* TASK-15. The Tasks whose conditions hold, derived from this Worker's own Facts.
|
|
55
|
+
*
|
|
56
|
+
* It is called on every read, so it answers the present rather than a cache: a Task closes when
|
|
57
|
+
* its condition stops holding, and nothing else in this protocol closes one.
|
|
58
|
+
*/
|
|
59
|
+
current: () => OpenTask[] | Promise<OpenTask[]>;
|
|
60
|
+
/**
|
|
61
|
+
* TASK-6. Which Task ids the credential presented covers, or `undefined` for all of them.
|
|
62
|
+
*
|
|
63
|
+
* The owner filters rather than the consumer discarding, and the file gives two reasons: a list
|
|
64
|
+
* showing every Task to every holder of any Contract is a disclosure the owner cannot take back,
|
|
65
|
+
* and a consumer reading through work it may not take costs both sides.
|
|
66
|
+
*
|
|
67
|
+
* It may answer a promise, because what a Contract covers is a thing a Worker looks up rather
|
|
68
|
+
* than a thing it holds — the Tower brokered it, the Worker stored what it was told, and reading
|
|
69
|
+
* that is a query like any other.
|
|
70
|
+
*/
|
|
71
|
+
covers?: (token: string | undefined) => string[] | undefined | Promise<string[] | undefined>;
|
|
72
|
+
/** ENDP-19 (recommended). The most Tasks one page carries. */
|
|
73
|
+
pageSize?: number;
|
|
74
|
+
};
|
|
75
|
+
export type TaskSurface = {
|
|
76
|
+
read: (query: URLSearchParams, token: string | undefined) => Promise<Refusal | Page>;
|
|
77
|
+
};
|
|
78
|
+
export declare function tasks(raises: TaskTypes, facts: TaskFacts): TaskSurface;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tasks` surface, which is everything in `spec/tasks.md` that is not a Fact.
|
|
3
|
+
*
|
|
4
|
+
* A Worker declares what it raises and answers, and says which Tasks' conditions hold right now.
|
|
5
|
+
* That is the whole of what it owes, because TASK-15 makes a Task a condition over the Worker's own
|
|
6
|
+
* Facts and nothing else here can know one. The page envelope, the cursor, the filter that must be
|
|
7
|
+
* refused rather than ignored, the ordering that makes paging terminate: those are fixed by rules,
|
|
8
|
+
* and a Worker writing any of them again would be re-deriving the specification.
|
|
9
|
+
*
|
|
10
|
+
* **This file used to be twice as long and most of what is gone was a Claim** — a lease, an expiry,
|
|
11
|
+
* a fencing token, counts of what had failed and lapsed, an identifier for whoever held one.
|
|
12
|
+
* `spec/tasks.md` carries the argument for withdrawing it.
|
|
13
|
+
*/
|
|
14
|
+
import { rfc3339 } from "./buckets.js";
|
|
15
|
+
import { collection } from "./collection.js";
|
|
16
|
+
const refuse = (code, message) => ({ code, message });
|
|
17
|
+
/** What TASK-5 defines on this read beyond the cursor; `collection` refuses everything else. */
|
|
18
|
+
const READ_PARAMETERS = ["type"];
|
|
19
|
+
export function tasks(raises, facts) {
|
|
20
|
+
const cap = facts.pageSize ?? 50;
|
|
21
|
+
return {
|
|
22
|
+
async read(query, token) {
|
|
23
|
+
// TASK-8: a type the entry does not declare. The surface exists and the caller asked about
|
|
24
|
+
// something this Worker never raises, which is a parameter whose VALUE it will not accept.
|
|
25
|
+
const type = query.get("type");
|
|
26
|
+
if (type !== null && !(type in raises)) {
|
|
27
|
+
return refuse("invalid_parameter", `No Task type named ${type} is declared.`);
|
|
28
|
+
}
|
|
29
|
+
// TASK-6: only what this credential covers. Absent, it covers everything.
|
|
30
|
+
const covers = await facts.covers?.(token);
|
|
31
|
+
const matching = (await facts.current())
|
|
32
|
+
.filter((task) => covers === undefined || covers.includes(task.id))
|
|
33
|
+
.filter((task) => type === null || task.type === type);
|
|
34
|
+
// ENDP-19's cap, ENDP-20's envelope, ENDP-21's cursor and ENDP-23's order are the same for
|
|
35
|
+
// every collection in this protocol, and `collection.ts` carries them. What is left here is
|
|
36
|
+
// what only Tasks know: which ones this credential covers, and what a Task looks like.
|
|
37
|
+
return collection(matching, query, (task) => ({
|
|
38
|
+
id: task.id,
|
|
39
|
+
type: task.type,
|
|
40
|
+
payload: task.payload,
|
|
41
|
+
since: rfc3339(task.since),
|
|
42
|
+
}), cap, READ_PARAMETERS);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|