@re-cinq/floor-client 0.1.2
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 +59 -0
- package/dist/blobs.d.ts +19 -0
- package/dist/blobs.js +18 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +52 -0
- package/dist/definitions.d.ts +13 -0
- package/dist/definitions.js +19 -0
- package/dist/events.d.ts +33 -0
- package/dist/events.js +19 -0
- package/dist/filters.d.ts +25 -0
- package/dist/filters.js +3 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +8 -0
- package/dist/live.d.ts +57 -0
- package/dist/live.js +164 -0
- package/dist/problem.d.ts +17 -0
- package/dist/problem.js +42 -0
- package/dist/runs.d.ts +37 -0
- package/dist/runs.js +20 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +66 -0
- package/dist/station-runs.d.ts +38 -0
- package/dist/station-runs.js +28 -0
- package/dist/tokens.d.ts +11 -0
- package/dist/tokens.js +9 -0
- package/dist/visit.d.ts +46 -0
- package/dist/visit.js +50 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 re:cinq B.V.
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @re-cinq/floor-client
|
|
2
|
+
|
|
3
|
+
The floor over HTTP and its live socket, typed. Every route a floor serves, and a watch that replays a run from a cursor and comes back without losing a frame.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createFloorClient, serviceToken } from "@re-cinq/floor-client";
|
|
7
|
+
|
|
8
|
+
const floor = createFloorClient({ url: "https://floor.example.com", token: serviceToken(process.env.FLOOR_SERVICE_TOKEN!) });
|
|
9
|
+
|
|
10
|
+
const { run } = await floor.lines.start("code-review", {
|
|
11
|
+
repo: "github.com/re-cinq/lore",
|
|
12
|
+
startItems: { pr_url: { kind: "value", ref: "https://github.com/re-cinq/lore/pull/412", by: "me" } },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
for await (const frame of floor.runs.watch(run.id)) {
|
|
16
|
+
if (frame.type === "run_settled") break;
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Where this fits
|
|
21
|
+
|
|
22
|
+
The one way anything reaches a floor from outside it. [`apps/cluster-agent`](../../apps/cluster-agent/README.md), [`@re-cinq/floor-station`](../station/README.md), [`@re-cinq/floor-pipeline`](../pipeline/README.md) and the lore converter all talk to a floor through this, and so does lore. It links no part of the floor's own storage — only [`@re-cinq/floor-contracts`](../contracts/README.md), for the shapes — so a floor's database is not reachable through it even by accident.
|
|
23
|
+
|
|
24
|
+
See [the map](../../README.md).
|
|
25
|
+
|
|
26
|
+
## What it decides for you
|
|
27
|
+
|
|
28
|
+
**Two tokens, and neither fits where the other belongs.** `ServiceToken` and `VisitToken` are branded, so one cannot be passed for the other. `createVisitClient` goes further: it binds the visit id at construction and takes one in no method, so addressing *another* visit is not something a caller can express. The floor refuses that with a 403; this refuses it at the compiler. The same client has no `events.post` — it has `report()`, which fills in the event's name, its visit and its dedupe key, because none of those are the caller's to choose.
|
|
29
|
+
|
|
30
|
+
**A status that carries meaning is in the return type, not an exception.** Absence is `null`, an empty queue is `[]`, and where a refusal is a real decision it is a named union:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const outcome = await floor.stationRuns.brief(visitId);
|
|
34
|
+
|
|
35
|
+
if (outcome.kind === "reported") return ack(event); // 409: already done, run nothing
|
|
36
|
+
if (outcome.kind === "absent") return deadLetter(event); // 404: asking again will never find it
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That one matters: before this package, a station read both as `null` and could not tell them apart, while the cluster agent threw on both and retried a settled visit until the event died.
|
|
40
|
+
|
|
41
|
+
Everything else a floor refuses throws a `FloorProblem` carrying the RFC 9457 body — `status`, `title`, `detail`, and the per-field `errors` a 400 lists.
|
|
42
|
+
|
|
43
|
+
**A filter the floor would 400 on does not compile.** `runs.list`, `events.feed` and `costs.summary` each need at least one filter, and the types say so.
|
|
44
|
+
|
|
45
|
+
**No runtime validation.** The client casts, in one place. The rule, which the floor already followed and never wrote down: validate across a trust boundary you do not own — `apps/api` validates what a git credential provider answers, because that is somebody else's service — and annotate across one you do. The floor's own handlers are annotated with these same types, so drift is a compile error on the server rather than a throw in someone else's pod.
|
|
46
|
+
|
|
47
|
+
## The live socket
|
|
48
|
+
|
|
49
|
+
`runs.watch(runId, options)` is an async iterable. It replays the run's journal from `after`, emits `caught_up`, then follows it live.
|
|
50
|
+
|
|
51
|
+
**It comes back for you.** The protocol was built for it — every frame but `unsupported` carries the `seq` to return with — so a watch reopens from the last seq it handed out, and misses nothing. It does not come back from a refusal: a bad cursor, a missing token, no such run, or too many viewers. That last one is deliberate. Sixteen viewers is a capacity signal, and a client that retried it invisibly would turn pressure on a busy run into a hidden hammer loop.
|
|
52
|
+
|
|
53
|
+
There is no idle timeout here on purpose. A caught-up quiet run legitimately says nothing for minutes; the floor's own ping already finds a dead viewer, and a client-side timer would only reconnect healthy watches.
|
|
54
|
+
|
|
55
|
+
One caveat for a relay: `visit_opened` and `visit_reported` carry the whole visit, `agentSettings` included — the model, the prompt, the image. Filter those before they reach a browser.
|
|
56
|
+
|
|
57
|
+
## Testing against it
|
|
58
|
+
|
|
59
|
+
Both seams are injectable and neither needs a server: `fetchFn` for HTTP, `socketFn` for the live socket. With `backoffMs: () => 0`, the whole reconnect table is unit-testable in milliseconds — see `live.test.ts`.
|
package/dist/blobs.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BlobRef } from "@re-cinq/floor-contracts";
|
|
2
|
+
import { type Reachable } from "./send.js";
|
|
3
|
+
export interface StoredBytes {
|
|
4
|
+
bytes: Uint8Array;
|
|
5
|
+
contentType: string;
|
|
6
|
+
}
|
|
7
|
+
export interface BlobsApi {
|
|
8
|
+
get(hash: string): Promise<StoredBytes | null>;
|
|
9
|
+
put(bytes: Uint8Array, contentType?: string): Promise<BlobRef>;
|
|
10
|
+
/** A file need names an absolute url a pod can reach; a worker elsewhere reads the path off it and asks its own floor. */
|
|
11
|
+
atUrl(url: string): Promise<StoredBytes | null>;
|
|
12
|
+
}
|
|
13
|
+
export declare function blobsApi(floor: Reachable): BlobsApi;
|
|
14
|
+
export interface ConversationApi {
|
|
15
|
+
/** The archive the named visit saved, for the one visit opened to continue it. */
|
|
16
|
+
restore(savedBy: string): Promise<Uint8Array | null>;
|
|
17
|
+
save(visitId: string, archive: Uint8Array): Promise<BlobRef>;
|
|
18
|
+
}
|
|
19
|
+
export declare function conversationApi(floor: Reachable): ConversationApi;
|
package/dist/blobs.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { asked, askedForBytes } from "./send.js";
|
|
2
|
+
const HTTP_NOT_FOUND = 404;
|
|
3
|
+
const OCTET_STREAM = "application/octet-stream";
|
|
4
|
+
const GZIP = "application/gzip";
|
|
5
|
+
export function blobsApi(floor) {
|
|
6
|
+
return {
|
|
7
|
+
get: (hash) => askedForBytes(floor, { method: "GET", path: `/blobs/${hash}` }, [HTTP_NOT_FOUND]),
|
|
8
|
+
put: async (bytes, contentType) => (await asked(floor, { method: "POST", path: "/blobs", bytes: { content: bytes, contentType: contentType ?? OCTET_STREAM } })),
|
|
9
|
+
atUrl: (url) => askedForBytes(floor, { method: "GET", path: new URL(url).pathname }, [HTTP_NOT_FOUND]),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function conversationApi(floor) {
|
|
13
|
+
return {
|
|
14
|
+
restore: async (savedBy) => (await askedForBytes(floor, { method: "GET", path: `/conversations/${savedBy}` }, [HTTP_NOT_FOUND]))?.bytes ?? null,
|
|
15
|
+
save: async (visitId, archive) => (await asked(floor, { method: "POST", path: `/conversations/${visitId}`, bytes: { content: archive, contentType: GZIP } })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=blobs.js.map
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AgentDefinitionBody, LineBody, MigrationBody, PutResult, ScheduleBody, StationBody, DefinitionRowView, FloorEventView } from "@re-cinq/floor-contracts";
|
|
2
|
+
import { type BlobsApi, type ConversationApi } from "./blobs.js";
|
|
3
|
+
import { type DefinitionApi } from "./definitions.js";
|
|
4
|
+
import { type EventsApi } from "./events.js";
|
|
5
|
+
import { type CostsRowsApi, type RunsApi, type StartRun, type StartedRun } from "./runs.js";
|
|
6
|
+
import { type StationRunsApi } from "./station-runs.js";
|
|
7
|
+
import type { SocketFn } from "./live.js";
|
|
8
|
+
import type { ServiceToken } from "./tokens.js";
|
|
9
|
+
export interface FloorAccess {
|
|
10
|
+
url: string;
|
|
11
|
+
token: ServiceToken;
|
|
12
|
+
/** The seam a test injects for HTTP. */
|
|
13
|
+
fetchFn?: typeof fetch;
|
|
14
|
+
/** The seam a test injects for the live socket. */
|
|
15
|
+
socketFn?: SocketFn;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface LinesApi extends DefinitionApi<LineBody> {
|
|
19
|
+
/** Answers the run already open on this subject, joined, rather than opening a second one. */
|
|
20
|
+
start(lineId: string, starting: StartRun): Promise<StartedRun>;
|
|
21
|
+
}
|
|
22
|
+
export interface SchedulesApi extends DefinitionApi<ScheduleBody> {
|
|
23
|
+
/** Posts this schedule's event now, without waiting for its cron. */
|
|
24
|
+
trigger(id: string): Promise<FloorEventView | null>;
|
|
25
|
+
}
|
|
26
|
+
export interface MigrationsApi {
|
|
27
|
+
list(): Promise<DefinitionRowView<MigrationBody>[]>;
|
|
28
|
+
get(name: string): Promise<DefinitionRowView<MigrationBody> | null>;
|
|
29
|
+
/** Refused with a 409 when that name already ran with different content. */
|
|
30
|
+
put(name: string, sha256: string): Promise<PutResult>;
|
|
31
|
+
}
|
|
32
|
+
export interface Version {
|
|
33
|
+
sha: string;
|
|
34
|
+
schemaVersion: number;
|
|
35
|
+
/** Every replica serves; only the one holding the floor's lease runs its loop. */
|
|
36
|
+
runsLoop: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface HealthApi {
|
|
39
|
+
live(): Promise<boolean>;
|
|
40
|
+
ready(): Promise<boolean>;
|
|
41
|
+
version(): Promise<Version>;
|
|
42
|
+
}
|
|
43
|
+
export interface FloorClient {
|
|
44
|
+
readonly health: HealthApi;
|
|
45
|
+
readonly lines: LinesApi;
|
|
46
|
+
readonly stations: DefinitionApi<StationBody>;
|
|
47
|
+
readonly agentDefinitions: DefinitionApi<AgentDefinitionBody>;
|
|
48
|
+
readonly schedules: SchedulesApi;
|
|
49
|
+
readonly migrations: MigrationsApi;
|
|
50
|
+
readonly runs: RunsApi;
|
|
51
|
+
readonly stationRuns: StationRunsApi;
|
|
52
|
+
readonly events: EventsApi;
|
|
53
|
+
readonly blobs: BlobsApi;
|
|
54
|
+
readonly conversations: ConversationApi;
|
|
55
|
+
readonly costs: CostsRowsApi;
|
|
56
|
+
}
|
|
57
|
+
export declare function createFloorClient(access: FloorAccess): FloorClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { blobsApi, conversationApi } from "./blobs.js";
|
|
2
|
+
import { definitionApi } from "./definitions.js";
|
|
3
|
+
import { eventsApi } from "./events.js";
|
|
4
|
+
import { costsApi, runsApi } from "./runs.js";
|
|
5
|
+
import { asked, reach } from "./send.js";
|
|
6
|
+
import { stationRunsApi } from "./station-runs.js";
|
|
7
|
+
const HTTP_NOT_FOUND = 404;
|
|
8
|
+
export function createFloorClient(access) {
|
|
9
|
+
const floor = { ...reach(access.url, access.token, { fetchFn: access.fetchFn, timeoutMs: access.timeoutMs }), socketFn: access.socketFn };
|
|
10
|
+
return {
|
|
11
|
+
health: healthApi(floor),
|
|
12
|
+
lines: { ...definitionApi(floor, "assembly-lines"), start: (lineId, starting) => startRun(floor, lineId, starting) },
|
|
13
|
+
stations: definitionApi(floor, "stations"),
|
|
14
|
+
agentDefinitions: definitionApi(floor, "agent-definitions"),
|
|
15
|
+
schedules: { ...definitionApi(floor, "schedules"), trigger: (id) => asked(floor, { method: "POST", path: `/schedules/${id}/trigger` }, [HTTP_NOT_FOUND]) },
|
|
16
|
+
migrations: migrationsApi(floor),
|
|
17
|
+
runs: runsApi(floor),
|
|
18
|
+
stationRuns: stationRunsApi(floor),
|
|
19
|
+
events: eventsApi(floor),
|
|
20
|
+
blobs: blobsApi(floor),
|
|
21
|
+
conversations: conversationApi(floor),
|
|
22
|
+
costs: costsApi(floor),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function startRun(floor, lineId, starting) {
|
|
26
|
+
return (await asked(floor, { method: "POST", path: `/assembly-lines/${lineId}/start`, body: starting }));
|
|
27
|
+
}
|
|
28
|
+
function migrationsApi(floor) {
|
|
29
|
+
const rows = definitionApi(floor, "migrations");
|
|
30
|
+
return {
|
|
31
|
+
list: () => rows.list(),
|
|
32
|
+
get: (name) => rows.get(name),
|
|
33
|
+
put: (name, sha256) => rows.put(name, { sha256 }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Health is unauthenticated, so a probe answers rather than throwing when the floor is not up yet.
|
|
37
|
+
function healthApi(floor) {
|
|
38
|
+
const answers = async (path) => {
|
|
39
|
+
try {
|
|
40
|
+
return (await asked(floor, { method: "GET", path })) !== null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
live: () => answers("/healthz"),
|
|
48
|
+
ready: () => answers("/readyz"),
|
|
49
|
+
version: async () => (await asked(floor, { method: "GET", path: "/version" })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DefinitionRowView, PutResult } from "@re-cinq/floor-contracts";
|
|
2
|
+
import { type Reachable } from "./send.js";
|
|
3
|
+
export interface DefinitionApi<Body> {
|
|
4
|
+
list(name?: string): Promise<DefinitionRowView<Body>[]>;
|
|
5
|
+
/** Null when nothing of that id is there, or it was archived. */
|
|
6
|
+
get(id: string): Promise<DefinitionRowView<Body> | null>;
|
|
7
|
+
versions(id: string): Promise<DefinitionRowView<Body>[]>;
|
|
8
|
+
version(id: string, hash: string): Promise<DefinitionRowView<Body> | null>;
|
|
9
|
+
/** A version is its content: putting the same body twice answers the same hash, created false the second time. */
|
|
10
|
+
put(id: string, body: Body): Promise<PutResult>;
|
|
11
|
+
archive(id: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function definitionApi<Body>(floor: Reachable, base: string): DefinitionApi<Body>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { asked, done } from "./send.js";
|
|
2
|
+
const HTTP_NOT_FOUND = 404;
|
|
3
|
+
export function definitionApi(floor, base) {
|
|
4
|
+
const rows = async (path, query) => {
|
|
5
|
+
const page = await asked(floor, { method: "GET", path, query });
|
|
6
|
+
return page?.items ?? [];
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
list: (name) => rows(`/${base}`, { name }),
|
|
10
|
+
get: (id) => asked(floor, { method: "GET", path: `/${base}/${id}` }, [HTTP_NOT_FOUND]),
|
|
11
|
+
versions: (id) => rows(`/${base}/${id}/versions`),
|
|
12
|
+
version: (id, hash) => asked(floor, { method: "GET", path: `/${base}/${id}/versions/${hash}` }, [HTTP_NOT_FOUND]),
|
|
13
|
+
put: async (id, body) => (await asked(floor, { method: "PUT", path: `/${base}/${id}`, body })),
|
|
14
|
+
archive: async (id) => {
|
|
15
|
+
await done(floor, { method: "DELETE", path: `/${base}/${id}` });
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=definitions.js.map
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ClaimedEvent, FloorEventView } from "@re-cinq/floor-contracts";
|
|
2
|
+
import type { EventFilter } from "./filters.js";
|
|
3
|
+
import { type Reachable } from "./send.js";
|
|
4
|
+
export interface EnqueueEvent {
|
|
5
|
+
name: string;
|
|
6
|
+
payload: Record<string, unknown>;
|
|
7
|
+
/** The same key twice is the same event: a redelivered webhook enqueues once. */
|
|
8
|
+
dedupeKey?: string;
|
|
9
|
+
availableAt?: Date;
|
|
10
|
+
runId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface EventsPage {
|
|
13
|
+
items: FloorEventView[];
|
|
14
|
+
nextCursor: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface EventsApi {
|
|
17
|
+
feed(filter: EventFilter, page?: {
|
|
18
|
+
limit?: number;
|
|
19
|
+
}): Promise<EventsPage>;
|
|
20
|
+
get(eventId: string): Promise<FloorEventView | null>;
|
|
21
|
+
post(event: EnqueueEvent): Promise<FloorEventView>;
|
|
22
|
+
/** Empty when nothing matching the tags is waiting. */
|
|
23
|
+
claim(claiming: {
|
|
24
|
+
tags: string[];
|
|
25
|
+
limit: number;
|
|
26
|
+
}): Promise<ClaimedEvent[]>;
|
|
27
|
+
ack(eventId: string): Promise<void>;
|
|
28
|
+
/** Back on the queue after a backoff, and dead-lettered once it has been tried too often. */
|
|
29
|
+
fail(eventId: string, error: string): Promise<void>;
|
|
30
|
+
/** Straight to the dead letters: trying again would never help. */
|
|
31
|
+
deadLetter(eventId: string, error: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export declare function eventsApi(floor: Reachable): EventsApi;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { asked, done } from "./send.js";
|
|
2
|
+
const HTTP_NOT_FOUND = 404;
|
|
3
|
+
export function eventsApi(floor) {
|
|
4
|
+
const refused = async (eventId, body) => {
|
|
5
|
+
await done(floor, { method: "POST", path: `/events/${eventId}/fail`, body });
|
|
6
|
+
};
|
|
7
|
+
return {
|
|
8
|
+
feed: async (filter, page) => (await asked(floor, { method: "GET", path: "/events", query: { ...filter, ...page } })),
|
|
9
|
+
get: (eventId) => asked(floor, { method: "GET", path: `/events/${eventId}` }, [HTTP_NOT_FOUND]),
|
|
10
|
+
post: async (event) => (await asked(floor, { method: "POST", path: "/events", body: { ...event, availableAt: event.availableAt?.toISOString() } })),
|
|
11
|
+
claim: async (claiming) => (await asked(floor, { method: "POST", path: "/events/claim", body: claiming })) ?? [],
|
|
12
|
+
ack: async (eventId) => {
|
|
13
|
+
await done(floor, { method: "POST", path: `/events/${eventId}/ack` });
|
|
14
|
+
},
|
|
15
|
+
fail: (eventId, error) => refused(eventId, { error, permanent: false }),
|
|
16
|
+
deadLetter: (eventId, error) => refused(eventId, { error, permanent: true }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** At least one of these, and any of the rest beside it. */
|
|
2
|
+
export type OneOf<Fields> = {
|
|
3
|
+
[Named in keyof Fields]-?: Required<Pick<Fields, Named>> & Partial<Omit<Fields, Named>>;
|
|
4
|
+
}[keyof Fields];
|
|
5
|
+
export type RunFilter = OneOf<{
|
|
6
|
+
line: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
open: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
export type EventFilter = OneOf<{
|
|
12
|
+
since: string;
|
|
13
|
+
name: string;
|
|
14
|
+
run: string;
|
|
15
|
+
stationRun: string;
|
|
16
|
+
}>;
|
|
17
|
+
export type CostsFilter = OneOf<{
|
|
18
|
+
run: string;
|
|
19
|
+
repo: string;
|
|
20
|
+
line: string;
|
|
21
|
+
station: string;
|
|
22
|
+
since: string;
|
|
23
|
+
until: string;
|
|
24
|
+
}>;
|
|
25
|
+
export type CostsGroupBy = "day" | "line" | "station" | "model" | "run";
|
package/dist/filters.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { createFloorClient } from "./client.js";
|
|
2
|
+
export { createVisitClient } from "./visit.js";
|
|
3
|
+
export { serviceToken, visitToken } from "./tokens.js";
|
|
4
|
+
export { FloorProblem, isFloorProblem } from "./problem.js";
|
|
5
|
+
export { reach, asked, send } from "./send.js";
|
|
6
|
+
export { CLOSE, watchRun } from "./live.js";
|
|
7
|
+
export type { FloorAccess, FloorClient, HealthApi, LinesApi, MigrationsApi, SchedulesApi, Version } from "./client.js";
|
|
8
|
+
export type { AppendRecord, GitCredentialOutcome, VisitAccess, VisitClient } from "./visit.js";
|
|
9
|
+
export type { ServiceToken, VisitToken } from "./tokens.js";
|
|
10
|
+
export type { Asking, Reachable } from "./send.js";
|
|
11
|
+
export type { BlobsApi, ConversationApi, StoredBytes } from "./blobs.js";
|
|
12
|
+
export type { DefinitionApi } from "./definitions.js";
|
|
13
|
+
export type { EnqueueEvent, EventsApi, EventsPage } from "./events.js";
|
|
14
|
+
export type { CostsFilter, CostsGroupBy, EventFilter, OneOf, RunFilter } from "./filters.js";
|
|
15
|
+
export type { CostsRowsApi, RunPage, RunsApi, StartRun, StartedRun } from "./runs.js";
|
|
16
|
+
export type { BriefOutcome, RecordsPage, StationRunsApi } from "./station-runs.js";
|
|
17
|
+
export type { LiveSocket, RefusalCode, RunWatch, SocketFn, WatchEnd, WatchOptions } from "./live.js";
|
|
18
|
+
export type * from "@re-cinq/floor-contracts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The floor, typed. One client for a service, one for a single visit, and a watch over the live socket.
|
|
2
|
+
export { createFloorClient } from "./client.js";
|
|
3
|
+
export { createVisitClient } from "./visit.js";
|
|
4
|
+
export { serviceToken, visitToken } from "./tokens.js";
|
|
5
|
+
export { FloorProblem, isFloorProblem } from "./problem.js";
|
|
6
|
+
export { reach, asked, send } from "./send.js";
|
|
7
|
+
export { CLOSE, watchRun } from "./live.js";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { LiveFrame } from "@re-cinq/floor-contracts";
|
|
2
|
+
import type { Reachable } from "./send.js";
|
|
3
|
+
/** The floor terminates a viewer it cannot reach, and a dropped connection reports the same code. */
|
|
4
|
+
export declare const CLOSE: {
|
|
5
|
+
readonly settled: 1000;
|
|
6
|
+
readonly stopping: 1001;
|
|
7
|
+
readonly dropped: 1006;
|
|
8
|
+
readonly unreadable: 1011;
|
|
9
|
+
readonly badCursor: 4400;
|
|
10
|
+
readonly noServiceToken: 4401;
|
|
11
|
+
readonly noSuchRun: 4404;
|
|
12
|
+
readonly tooManyViewers: 4429;
|
|
13
|
+
};
|
|
14
|
+
declare const REFUSALS: readonly [4400, 4401, 4404, 4429];
|
|
15
|
+
export type RefusalCode = (typeof REFUSALS)[number];
|
|
16
|
+
export type WatchEnd = {
|
|
17
|
+
reason: "settled";
|
|
18
|
+
} | {
|
|
19
|
+
reason: "stopped";
|
|
20
|
+
} | {
|
|
21
|
+
reason: "refused";
|
|
22
|
+
code: RefusalCode;
|
|
23
|
+
detail: string;
|
|
24
|
+
};
|
|
25
|
+
export interface Closed {
|
|
26
|
+
code: number;
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
/** The seam, in this package's own shape rather than the browser's: a test drives it with no server and no port. */
|
|
30
|
+
export interface LiveSocket {
|
|
31
|
+
onFrame(heard: (frame: string) => void): void;
|
|
32
|
+
onClosed(heard: (closed: Closed) => void): void;
|
|
33
|
+
close(code?: number, reason?: string): void;
|
|
34
|
+
}
|
|
35
|
+
export type SocketFn = (url: string, init: {
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
}) => LiveSocket;
|
|
38
|
+
export type LiveReachable = Reachable & {
|
|
39
|
+
socketFn?: SocketFn;
|
|
40
|
+
};
|
|
41
|
+
export interface WatchOptions {
|
|
42
|
+
/** The last seq already seen. Left out, the run is replayed from its start. */
|
|
43
|
+
after?: number;
|
|
44
|
+
/** False leaves every close to the caller. */
|
|
45
|
+
reconnect?: boolean;
|
|
46
|
+
/** Attempt to delay. Return 0 in a test. */
|
|
47
|
+
backoffMs?: (attempt: number) => number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
export interface RunWatch extends AsyncIterable<LiveFrame> {
|
|
51
|
+
/** The last seq handed out. Give it to the next watch and lose nothing. */
|
|
52
|
+
readonly seq: number;
|
|
53
|
+
readonly ended: Promise<WatchEnd>;
|
|
54
|
+
stop(): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function watchRun(floor: LiveReachable, runId: string, options?: WatchOptions): RunWatch;
|
|
57
|
+
export {};
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** The floor terminates a viewer it cannot reach, and a dropped connection reports the same code. */
|
|
2
|
+
export const CLOSE = { settled: 1000, stopping: 1001, dropped: 1006, unreadable: 1011, badCursor: 4400, noServiceToken: 4401, noSuchRun: 4404, tooManyViewers: 4429 };
|
|
3
|
+
const REFUSALS = [CLOSE.badCursor, CLOSE.noServiceToken, CLOSE.noSuchRun, CLOSE.tooManyViewers];
|
|
4
|
+
const BACKOFF_START_MS = 250;
|
|
5
|
+
const BACKOFF_CAP_MS = 10_000;
|
|
6
|
+
export function watchRun(floor, runId, options = {}) {
|
|
7
|
+
const watch = openWatch(cursorOf(options.after));
|
|
8
|
+
options.signal?.addEventListener("abort", () => watch.stop());
|
|
9
|
+
void follow({ floor, runId, options, watch });
|
|
10
|
+
return watch;
|
|
11
|
+
}
|
|
12
|
+
function openWatch(from) {
|
|
13
|
+
const journal = journalFrom(from);
|
|
14
|
+
const socket = { current: null };
|
|
15
|
+
const reading = journal.reading;
|
|
16
|
+
return {
|
|
17
|
+
get seq() {
|
|
18
|
+
return reading.seq;
|
|
19
|
+
},
|
|
20
|
+
waiting: reading.waiting,
|
|
21
|
+
ended: reading.ended,
|
|
22
|
+
endedYet: reading.endedYet,
|
|
23
|
+
settle: reading.settle,
|
|
24
|
+
take: reading.take,
|
|
25
|
+
socket,
|
|
26
|
+
stop: () => {
|
|
27
|
+
socket.current?.close(CLOSE.settled, "the watcher stopped");
|
|
28
|
+
reading.settle({ reason: "stopped" });
|
|
29
|
+
},
|
|
30
|
+
[Symbol.asyncIterator]: () => reading[Symbol.asyncIterator](),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** What has arrived and not yet been handed out, the cursor it moved, and whether the watch is over. */
|
|
34
|
+
function journalFrom(from) {
|
|
35
|
+
const held = { seq: from, end: null };
|
|
36
|
+
const waiting = [];
|
|
37
|
+
const { sleep, wake } = sleeper();
|
|
38
|
+
const { ended, finish } = promised();
|
|
39
|
+
const settle = (end) => {
|
|
40
|
+
if (held.end)
|
|
41
|
+
return;
|
|
42
|
+
held.end = end;
|
|
43
|
+
finish(end);
|
|
44
|
+
wake();
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
reading: {
|
|
48
|
+
get seq() {
|
|
49
|
+
return held.seq;
|
|
50
|
+
},
|
|
51
|
+
waiting,
|
|
52
|
+
ended,
|
|
53
|
+
endedYet: () => held.end,
|
|
54
|
+
settle,
|
|
55
|
+
take: (frame) => {
|
|
56
|
+
if (frame.type !== "unsupported")
|
|
57
|
+
held.seq = frame.seq;
|
|
58
|
+
waiting.push(frame);
|
|
59
|
+
wake();
|
|
60
|
+
},
|
|
61
|
+
[Symbol.asyncIterator]: () => handOut(waiting, { endedYet: () => held.end, sleep }),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A reader waiting on the next frame, woken when one arrives or when the watch is over. */
|
|
66
|
+
function sleeper() {
|
|
67
|
+
let waking = null;
|
|
68
|
+
return {
|
|
69
|
+
sleep: () => new Promise((resolve) => {
|
|
70
|
+
waking = resolve;
|
|
71
|
+
}),
|
|
72
|
+
wake: () => {
|
|
73
|
+
const woken = waking;
|
|
74
|
+
waking = null;
|
|
75
|
+
woken?.();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function promised() {
|
|
80
|
+
let finish;
|
|
81
|
+
const ended = new Promise((resolve) => {
|
|
82
|
+
finish = resolve;
|
|
83
|
+
});
|
|
84
|
+
return { ended, finish };
|
|
85
|
+
}
|
|
86
|
+
function handOut(waiting, watch) {
|
|
87
|
+
return {
|
|
88
|
+
async next() {
|
|
89
|
+
while (waiting.length === 0) {
|
|
90
|
+
if (watch.endedYet())
|
|
91
|
+
return { value: undefined, done: true };
|
|
92
|
+
await watch.sleep();
|
|
93
|
+
}
|
|
94
|
+
return { value: waiting.shift(), done: false };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function follow(following) {
|
|
99
|
+
const { options, watch } = following;
|
|
100
|
+
let attempt = 0;
|
|
101
|
+
while (!watch.endedYet()) {
|
|
102
|
+
const closed = await connected(following);
|
|
103
|
+
const end = endFor(closed, options);
|
|
104
|
+
if (watch.endedYet())
|
|
105
|
+
return;
|
|
106
|
+
if (end)
|
|
107
|
+
return watch.settle(end);
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, delayFor(attempt, options.backoffMs)));
|
|
109
|
+
attempt += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Settled is the end; a refusal will be refused again; anything else is worth coming back from.
|
|
113
|
+
function endFor(closed, options) {
|
|
114
|
+
if (closed.code === CLOSE.settled)
|
|
115
|
+
return { reason: "settled" };
|
|
116
|
+
if (refused(closed.code))
|
|
117
|
+
return { reason: "refused", code: closed.code, detail: closed.reason };
|
|
118
|
+
return options.reconnect === false ? { reason: "stopped" } : null;
|
|
119
|
+
}
|
|
120
|
+
function connected(following) {
|
|
121
|
+
const { floor, watch } = following;
|
|
122
|
+
const open = floor.socketFn ?? defaultSocket;
|
|
123
|
+
const socket = open(liveUrl(floor.url, following.runId, watch.seq), { headers: { authorization: `Bearer ${floor.token}` } });
|
|
124
|
+
watch.socket.current = socket;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
socket.onFrame((said) => {
|
|
127
|
+
const frame = frameOf(said);
|
|
128
|
+
if (frame)
|
|
129
|
+
watch.take(frame);
|
|
130
|
+
});
|
|
131
|
+
socket.onClosed(resolve);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function refused(code) {
|
|
135
|
+
return REFUSALS.includes(code);
|
|
136
|
+
}
|
|
137
|
+
// A whole number is what the floor accepts; anything else spends a connection to be told 4400.
|
|
138
|
+
function cursorOf(after) {
|
|
139
|
+
return Math.max(0, Math.trunc(after ?? 0));
|
|
140
|
+
}
|
|
141
|
+
function delayFor(attempt, backoffMs) {
|
|
142
|
+
return backoffMs ? backoffMs(attempt) : Math.min(BACKOFF_CAP_MS, BACKOFF_START_MS * 2 ** attempt);
|
|
143
|
+
}
|
|
144
|
+
function liveUrl(base, runId, after) {
|
|
145
|
+
return `${base.replace(/^http/, "ws")}/assembly-runs/${runId}/live?after=${after}`;
|
|
146
|
+
}
|
|
147
|
+
function frameOf(said) {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(said);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Node 22's global WebSocket takes headers through undici's options bag, which is the only way the floor sees the token: it reads the upgrade's authorization header and has no query fallback.
|
|
156
|
+
function defaultSocket(url, init) {
|
|
157
|
+
const socket = new WebSocket(url, init);
|
|
158
|
+
return {
|
|
159
|
+
onFrame: (heard) => socket.addEventListener("message", (said) => heard(String(said.data))),
|
|
160
|
+
onClosed: (heard) => socket.addEventListener("close", (closed) => heard({ code: closed.code, reason: closed.reason })),
|
|
161
|
+
close: (code, reason) => socket.close(code, reason),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=live.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Problem } from "@re-cinq/floor-contracts";
|
|
2
|
+
export declare class FloorProblem extends Error implements Problem {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly detail?: string;
|
|
7
|
+
/** What a 400 lists when a body failed to parse, one sentence a field. */
|
|
8
|
+
readonly errors?: string[];
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
constructor(problem: Problem, asked: {
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export declare function isFloorProblem(error: unknown): error is FloorProblem;
|
|
17
|
+
export declare function problemOf(response: Response): Promise<Problem>;
|
package/dist/problem.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const PROBLEM_TYPE = "application/problem+json";
|
|
2
|
+
export class FloorProblem extends Error {
|
|
3
|
+
type;
|
|
4
|
+
title;
|
|
5
|
+
status;
|
|
6
|
+
detail;
|
|
7
|
+
/** What a 400 lists when a body failed to parse, one sentence a field. */
|
|
8
|
+
errors;
|
|
9
|
+
method;
|
|
10
|
+
path;
|
|
11
|
+
constructor(problem, asked) {
|
|
12
|
+
super(`the floor refused ${asked.method} ${asked.path} with ${problem.status}: ${problem.detail ?? problem.title}`);
|
|
13
|
+
this.name = "FloorProblem";
|
|
14
|
+
this.type = problem.type;
|
|
15
|
+
this.title = problem.title;
|
|
16
|
+
this.status = problem.status;
|
|
17
|
+
this.detail = problem.detail;
|
|
18
|
+
this.errors = problem.errors;
|
|
19
|
+
this.method = asked.method;
|
|
20
|
+
this.path = asked.path;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function isFloorProblem(error) {
|
|
24
|
+
return error instanceof FloorProblem;
|
|
25
|
+
}
|
|
26
|
+
export async function problemOf(response) {
|
|
27
|
+
const said = await response.text().catch(() => "");
|
|
28
|
+
return statedIn(response, said) ?? { type: "about:blank", title: response.statusText || "the floor refused", status: response.status, detail: said || undefined };
|
|
29
|
+
}
|
|
30
|
+
function statedIn(response, said) {
|
|
31
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
32
|
+
return contentType.startsWith(PROBLEM_TYPE) ? parsed(said) : null;
|
|
33
|
+
}
|
|
34
|
+
function parsed(said) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(said);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=problem.js.map
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CostsRow, Item, RunView } from "@re-cinq/floor-contracts";
|
|
2
|
+
import type { CostsFilter, CostsGroupBy, RunFilter } from "./filters.js";
|
|
3
|
+
import { type Reachable } from "./send.js";
|
|
4
|
+
import { type RunWatch, type WatchOptions } from "./live.js";
|
|
5
|
+
export interface StartRun {
|
|
6
|
+
repo: string;
|
|
7
|
+
startItems: Record<string, Item>;
|
|
8
|
+
/** Starts somewhere other than the line's entry; the node must exist. */
|
|
9
|
+
entry?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface StartedRun {
|
|
12
|
+
run: RunView;
|
|
13
|
+
/** True when a run was already open on this subject and this start joined it rather than opening a second. */
|
|
14
|
+
joined: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface RunPage {
|
|
17
|
+
items: RunView[];
|
|
18
|
+
nextCursor: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface RunsApi {
|
|
21
|
+
list(filter: RunFilter, page?: {
|
|
22
|
+
limit?: number;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
}): Promise<RunPage>;
|
|
25
|
+
get(runId: string): Promise<{
|
|
26
|
+
run: RunView;
|
|
27
|
+
bag: Record<string, Item>;
|
|
28
|
+
} | null>;
|
|
29
|
+
cancel(runId: string, reason: string): Promise<RunView>;
|
|
30
|
+
/** Replays the run from `after`, then follows it live. Reconnects from the last seq it handed out. */
|
|
31
|
+
watch(runId: string, options?: WatchOptions): RunWatch;
|
|
32
|
+
}
|
|
33
|
+
export declare function runsApi(floor: Reachable): RunsApi;
|
|
34
|
+
export interface CostsRowsApi {
|
|
35
|
+
summary(filter: CostsFilter, group: CostsGroupBy): Promise<CostsRow[]>;
|
|
36
|
+
}
|
|
37
|
+
export declare function costsApi(floor: Reachable): CostsRowsApi;
|
package/dist/runs.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { asked } from "./send.js";
|
|
2
|
+
import { watchRun } from "./live.js";
|
|
3
|
+
const HTTP_NOT_FOUND = 404;
|
|
4
|
+
export function runsApi(floor) {
|
|
5
|
+
return {
|
|
6
|
+
list: async (filter, page) => (await asked(floor, { method: "GET", path: "/assembly-runs", query: { ...filter, ...page } })),
|
|
7
|
+
get: (runId) => asked(floor, { method: "GET", path: `/assembly-runs/${runId}` }, [HTTP_NOT_FOUND]),
|
|
8
|
+
cancel: async (runId, reason) => (await asked(floor, { method: "POST", path: `/assembly-runs/${runId}/cancel`, body: { reason } })),
|
|
9
|
+
watch: (runId, options) => watchRun(floor, runId, options),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function costsApi(floor) {
|
|
13
|
+
return {
|
|
14
|
+
summary: async (filter, group) => {
|
|
15
|
+
const page = await asked(floor, { method: "GET", path: "/costs", query: { ...filter, group } });
|
|
16
|
+
return page?.items ?? [];
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=runs.js.map
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ServiceToken, VisitToken } from "./tokens.js";
|
|
2
|
+
export interface Reachable {
|
|
3
|
+
/** Where the floor answers. A trailing slash is trimmed, so either spelling works. */
|
|
4
|
+
url: string;
|
|
5
|
+
token: ServiceToken | VisitToken;
|
|
6
|
+
/** The seam a test injects. Left out, the global fetch is used. */
|
|
7
|
+
fetchFn?: typeof fetch;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface Asking {
|
|
11
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
12
|
+
path: string;
|
|
13
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
/** Bytes instead of JSON, with the type they are stored under. */
|
|
16
|
+
bytes?: {
|
|
17
|
+
content: Uint8Array;
|
|
18
|
+
contentType: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function reach(url: string, token: ServiceToken | VisitToken, options?: Omit<Reachable, "url" | "token">): Reachable;
|
|
22
|
+
export declare function send(floor: Reachable, asking: Asking): Promise<Response>;
|
|
23
|
+
/** The answer, or null for a status this caller reads as absence. Anything else the floor refused throws a FloorProblem. */
|
|
24
|
+
export declare function asked<Answer>(floor: Reachable, asking: Asking, absent?: number[]): Promise<Answer | null>;
|
|
25
|
+
export declare function askedForBytes(floor: Reachable, asking: Asking, absent?: number[]): Promise<{
|
|
26
|
+
bytes: Uint8Array;
|
|
27
|
+
contentType: string;
|
|
28
|
+
} | null>;
|
|
29
|
+
/** Nothing to read: the floor either did it or refused. */
|
|
30
|
+
export declare function done(floor: Reachable, asking: Asking, absent?: number[]): Promise<boolean>;
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one place this package calls a floor. Everything else asks through here, so the bearer header, the timeout and the refusal are written once.
|
|
2
|
+
import { FloorProblem, problemOf } from "./problem.js";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
4
|
+
const HTTP_NO_CONTENT = 204;
|
|
5
|
+
export function reach(url, token, options = {}) {
|
|
6
|
+
return { url: url.replace(/\/+$/, ""), token, ...options };
|
|
7
|
+
}
|
|
8
|
+
export async function send(floor, asking) {
|
|
9
|
+
const call = floor.fetchFn ?? fetch;
|
|
10
|
+
return call(`${floor.url}${asking.path}${queryOf(asking.query)}`, {
|
|
11
|
+
method: asking.method,
|
|
12
|
+
headers: headersFor(floor, asking),
|
|
13
|
+
body: bodyOf(asking),
|
|
14
|
+
signal: AbortSignal.timeout(floor.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** The answer, or null for a status this caller reads as absence. Anything else the floor refused throws a FloorProblem. */
|
|
18
|
+
export async function asked(floor, asking, absent = []) {
|
|
19
|
+
const response = await accepted(floor, asking, absent);
|
|
20
|
+
if (!response)
|
|
21
|
+
return null;
|
|
22
|
+
const said = await response.text();
|
|
23
|
+
return said ? JSON.parse(said) : null;
|
|
24
|
+
}
|
|
25
|
+
export async function askedForBytes(floor, asking, absent = []) {
|
|
26
|
+
const response = await accepted(floor, asking, absent);
|
|
27
|
+
if (!response)
|
|
28
|
+
return null;
|
|
29
|
+
return { bytes: new Uint8Array(await response.arrayBuffer()), contentType: response.headers.get("content-type") ?? "application/octet-stream" };
|
|
30
|
+
}
|
|
31
|
+
/** Nothing to read: the floor either did it or refused. */
|
|
32
|
+
export async function done(floor, asking, absent = []) {
|
|
33
|
+
return (await accepted(floor, asking, absent)) !== null;
|
|
34
|
+
}
|
|
35
|
+
async function accepted(floor, asking, absent) {
|
|
36
|
+
const response = await send(floor, asking);
|
|
37
|
+
if (absent.includes(response.status) || response.status === HTTP_NO_CONTENT)
|
|
38
|
+
return null;
|
|
39
|
+
if (response.ok)
|
|
40
|
+
return response;
|
|
41
|
+
throw new FloorProblem(await problemOf(response), asking);
|
|
42
|
+
}
|
|
43
|
+
function headersFor(floor, asking) {
|
|
44
|
+
const headers = { authorization: `Bearer ${floor.token}` };
|
|
45
|
+
const contentType = contentTypeOf(asking);
|
|
46
|
+
if (contentType)
|
|
47
|
+
headers["content-type"] = contentType;
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
// Bytes go up under the type they are stored as; a JSON body says so; a request with no body says nothing.
|
|
51
|
+
function contentTypeOf(asking) {
|
|
52
|
+
if (asking.bytes)
|
|
53
|
+
return asking.bytes.contentType;
|
|
54
|
+
return asking.body === undefined ? null : "application/json";
|
|
55
|
+
}
|
|
56
|
+
function bodyOf(asking) {
|
|
57
|
+
if (asking.bytes)
|
|
58
|
+
return asking.bytes.content;
|
|
59
|
+
return asking.body === undefined ? undefined : JSON.stringify(asking.body);
|
|
60
|
+
}
|
|
61
|
+
function queryOf(query) {
|
|
62
|
+
const stated = Object.entries(query ?? {}).filter((entry) => entry[1] !== undefined);
|
|
63
|
+
const search = new URLSearchParams(stated.map(([name, value]) => [name, String(value)]));
|
|
64
|
+
return search.size > 0 ? `?${search.toString()}` : "";
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=send.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RecordKind, StationRunRecordView, VisitBrief, VisitView } from "@re-cinq/floor-contracts";
|
|
2
|
+
import { type Reachable } from "./send.js";
|
|
3
|
+
/** A brief is asked for once a dispatch is claimed, and the answer decides what the worker does next. */
|
|
4
|
+
export type BriefOutcome = {
|
|
5
|
+
kind: "brief";
|
|
6
|
+
brief: VisitBrief;
|
|
7
|
+
}
|
|
8
|
+
/** The visit already reported: ack the dispatch and run nothing. */
|
|
9
|
+
| {
|
|
10
|
+
kind: "reported";
|
|
11
|
+
}
|
|
12
|
+
/** No such visit: dead-letter the dispatch, since trying again will never find it. */
|
|
13
|
+
| {
|
|
14
|
+
kind: "absent";
|
|
15
|
+
};
|
|
16
|
+
export interface RecordsPage {
|
|
17
|
+
items: StationRunRecordView[];
|
|
18
|
+
/** Null once the records have been read to their end. */
|
|
19
|
+
nextCursor: number | null;
|
|
20
|
+
}
|
|
21
|
+
export interface StationRunsApi {
|
|
22
|
+
list(query: {
|
|
23
|
+
run: string;
|
|
24
|
+
node?: string;
|
|
25
|
+
open?: boolean;
|
|
26
|
+
}): Promise<VisitView[]>;
|
|
27
|
+
get(visitId: string): Promise<(VisitView & {
|
|
28
|
+
cost: unknown;
|
|
29
|
+
}) | null>;
|
|
30
|
+
brief(visitId: string): Promise<BriefOutcome>;
|
|
31
|
+
records(visitId: string, query: {
|
|
32
|
+
kind: RecordKind;
|
|
33
|
+
since?: number;
|
|
34
|
+
limit?: number;
|
|
35
|
+
}): Promise<RecordsPage>;
|
|
36
|
+
}
|
|
37
|
+
export declare function stationRunsApi(floor: Reachable): StationRunsApi;
|
|
38
|
+
export declare function briefOutcome(floor: Reachable, visitId: string): Promise<BriefOutcome>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FloorProblem, problemOf } from "./problem.js";
|
|
2
|
+
import { asked, send } from "./send.js";
|
|
3
|
+
const HTTP_NOT_FOUND = 404;
|
|
4
|
+
const HTTP_CONFLICT = 409;
|
|
5
|
+
export function stationRunsApi(floor) {
|
|
6
|
+
return {
|
|
7
|
+
list: async (query) => {
|
|
8
|
+
const page = await asked(floor, { method: "GET", path: "/station-runs", query });
|
|
9
|
+
return page?.items ?? [];
|
|
10
|
+
},
|
|
11
|
+
get: (visitId) => asked(floor, { method: "GET", path: `/station-runs/${visitId}` }, [HTTP_NOT_FOUND]),
|
|
12
|
+
brief: (visitId) => briefOutcome(floor, visitId),
|
|
13
|
+
records: async (visitId, query) => (await asked(floor, { method: "GET", path: `/station-runs/${visitId}/records`, query })),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// Which of the three it is, is the status: `asked` would collapse both refusals to null, and the two mean opposite things to a claim loop.
|
|
17
|
+
export async function briefOutcome(floor, visitId) {
|
|
18
|
+
const asking = { method: "GET", path: `/station-runs/${visitId}/brief` };
|
|
19
|
+
const response = await send(floor, asking);
|
|
20
|
+
if (response.status === HTTP_CONFLICT)
|
|
21
|
+
return { kind: "reported" };
|
|
22
|
+
if (response.status === HTTP_NOT_FOUND)
|
|
23
|
+
return { kind: "absent" };
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new FloorProblem(await problemOf(response), asking);
|
|
26
|
+
return { kind: "brief", brief: (await response.json()) };
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=station-runs.js.map
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare const tokenKind: unique symbol;
|
|
2
|
+
export type ServiceToken = string & {
|
|
3
|
+
readonly [tokenKind]: "service";
|
|
4
|
+
};
|
|
5
|
+
export type VisitToken = string & {
|
|
6
|
+
readonly [tokenKind]: "visit";
|
|
7
|
+
};
|
|
8
|
+
export declare function serviceToken(raw: string): ServiceToken;
|
|
9
|
+
/** A visit token comes from that visit's brief, and stops being accepted when the visit reports. */
|
|
10
|
+
export declare function visitToken(raw: string): VisitToken;
|
|
11
|
+
export {};
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Two tokens reach a floor and they are not interchangeable: a service token may read any visit, a visit token only its own. Branding them keeps one from being passed where the other belongs.
|
|
2
|
+
export function serviceToken(raw) {
|
|
3
|
+
return raw;
|
|
4
|
+
}
|
|
5
|
+
/** A visit token comes from that visit's brief, and stops being accepted when the visit reports. */
|
|
6
|
+
export function visitToken(raw) {
|
|
7
|
+
return raw;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=tokens.js.map
|
package/dist/visit.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { BlobRef, RecordKind, Report, StationRunRecordView } from "@re-cinq/floor-contracts";
|
|
2
|
+
import { type StoredBytes } from "./blobs.js";
|
|
3
|
+
import { type BriefOutcome } from "./station-runs.js";
|
|
4
|
+
import type { VisitToken } from "./tokens.js";
|
|
5
|
+
export interface VisitAccess {
|
|
6
|
+
url: string;
|
|
7
|
+
token: VisitToken;
|
|
8
|
+
visitId: string;
|
|
9
|
+
fetchFn?: typeof fetch;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export type GitCredentialOutcome = {
|
|
13
|
+
kind: "credential";
|
|
14
|
+
username: string;
|
|
15
|
+
password: string;
|
|
16
|
+
}
|
|
17
|
+
/** This floor was given no credential provider, so no visit may write to a repository. */
|
|
18
|
+
| {
|
|
19
|
+
kind: "unconfigured";
|
|
20
|
+
}
|
|
21
|
+
/** This visit declared no need for that repository, or only to read it. */
|
|
22
|
+
| {
|
|
23
|
+
kind: "ungranted";
|
|
24
|
+
};
|
|
25
|
+
export interface AppendRecord {
|
|
26
|
+
kind: Extract<RecordKind, "log" | "turn" | "llm_call">;
|
|
27
|
+
body: unknown;
|
|
28
|
+
occurredAt: Date;
|
|
29
|
+
}
|
|
30
|
+
export interface VisitClient {
|
|
31
|
+
brief(): Promise<BriefOutcome>;
|
|
32
|
+
/** One enveloped event from the subsystem's supervisor. */
|
|
33
|
+
sink(event: unknown): Promise<void>;
|
|
34
|
+
gitCredential(repo: string): Promise<GitCredentialOutcome>;
|
|
35
|
+
append(records: AppendRecord[]): Promise<StationRunRecordView[]>;
|
|
36
|
+
readBlob(hashOrUrl: string): Promise<StoredBytes | null>;
|
|
37
|
+
storeBlob(bytes: Uint8Array, contentType?: string): Promise<BlobRef>;
|
|
38
|
+
saveConversation(archive: Uint8Array): Promise<BlobRef>;
|
|
39
|
+
restoreConversation(savedBy: string): Promise<Uint8Array | null>;
|
|
40
|
+
/** The one thing a visit token may post to the queue. Its name, its visit and its dedupe key are not the caller's to choose. */
|
|
41
|
+
report(reported: {
|
|
42
|
+
report: Report;
|
|
43
|
+
worker: string;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export declare function createVisitClient(access: VisitAccess): VisitClient;
|
package/dist/visit.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { blobsApi, conversationApi } from "./blobs.js";
|
|
2
|
+
import { briefOutcome } from "./station-runs.js";
|
|
3
|
+
import { FloorProblem, problemOf } from "./problem.js";
|
|
4
|
+
import { asked, done, send } from "./send.js";
|
|
5
|
+
const HTTP_NOT_IMPLEMENTED = 501;
|
|
6
|
+
const HTTP_FORBIDDEN = 403;
|
|
7
|
+
export function createVisitClient(access) {
|
|
8
|
+
const floor = { url: access.url.replace(/\/+$/, ""), token: access.token, fetchFn: access.fetchFn, timeoutMs: access.timeoutMs };
|
|
9
|
+
const visitId = access.visitId;
|
|
10
|
+
const blobs = blobsApi(floor);
|
|
11
|
+
const conversation = conversationApi(floor);
|
|
12
|
+
return {
|
|
13
|
+
brief: () => briefOutcome(floor, visitId),
|
|
14
|
+
sink: async (event) => {
|
|
15
|
+
await done(floor, { method: "POST", path: `/station-runs/${visitId}/sink`, body: event });
|
|
16
|
+
},
|
|
17
|
+
gitCredential: (repo) => gitCredential(floor, visitId, repo),
|
|
18
|
+
append: async (records) => {
|
|
19
|
+
const written = await asked(floor, { method: "POST", path: `/station-runs/${visitId}/records`, body: { records: records.map(onTheWire) } });
|
|
20
|
+
return written?.items ?? [];
|
|
21
|
+
},
|
|
22
|
+
readBlob: (hashOrUrl) => (hashOrUrl.includes("://") ? blobs.atUrl(hashOrUrl) : blobs.get(hashOrUrl)),
|
|
23
|
+
storeBlob: (bytes, contentType) => blobs.put(bytes, contentType),
|
|
24
|
+
saveConversation: (archive) => conversation.save(visitId, archive),
|
|
25
|
+
restoreConversation: (savedBy) => conversation.restore(savedBy),
|
|
26
|
+
report: async (reported) => {
|
|
27
|
+
await done(floor, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
path: "/events",
|
|
30
|
+
body: { name: "station_run.reported", payload: { visitId, worker: reported.worker, report: reported.report }, dedupeKey: `station_run.reported:${visitId}` },
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function onTheWire(record) {
|
|
36
|
+
return { kind: record.kind, body: record.body, occurredAt: record.occurredAt.toISOString() };
|
|
37
|
+
}
|
|
38
|
+
// The two refusals mean opposite things — the floor has no provider at all, or this visit was not given that repository — so the status is read rather than collapsed.
|
|
39
|
+
async function gitCredential(floor, visitId, repo) {
|
|
40
|
+
const asking = { method: "POST", path: `/station-runs/${visitId}/git-credential`, body: { repo } };
|
|
41
|
+
const response = await send(floor, asking);
|
|
42
|
+
if (response.status === HTTP_NOT_IMPLEMENTED)
|
|
43
|
+
return { kind: "unconfigured" };
|
|
44
|
+
if (response.status === HTTP_FORBIDDEN)
|
|
45
|
+
return { kind: "ungranted" };
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new FloorProblem(await problemOf(response), asking);
|
|
48
|
+
return { kind: "credential", ...(await response.json()) };
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=visit.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@re-cinq/floor-client",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "The floor over HTTP and its live socket, typed: definitions, runs, visits, blobs, events, schedules and costs, plus a watch that replays a run from a cursor and reconnects without losing a frame.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/re-cinq/floor.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/re-cinq/floor/tree/main/packages/client#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/re-cinq/floor/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"floor",
|
|
17
|
+
"client",
|
|
18
|
+
"orchestration",
|
|
19
|
+
"agents"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"types": "dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"!dist/**/*.map"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"dev": "tsc --watch -p tsconfig.build.json",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@re-cinq/floor-contracts": "0.1.2"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.20.1",
|
|
52
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^5.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|