@re-cinq/floor-pipeline 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +110 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +95 -0
- package/dist/export.d.ts +4 -0
- package/dist/export.js +51 -0
- package/dist/file.d.ts +28 -0
- package/dist/file.js +29 -0
- package/dist/floor.d.ts +13 -0
- package/dist/floor.js +34 -0
- package/dist/import.d.ts +10 -0
- package/dist/import.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/migrate.d.ts +13 -0
- package/dist/migrate.js +28 -0
- package/dist/ready.d.ts +6 -0
- package/dist/ready.js +24 -0
- package/dist/shape.d.ts +24 -0
- package/dist/shape.js +104 -0
- package/package.json +61 -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,110 @@
|
|
|
1
|
+
# @re-cinq/floor-pipeline
|
|
2
|
+
|
|
3
|
+
A pipeline as one file: an assembly line with its stations, its agent definitions and their prompts, its files and its schedules. One file is one pipeline, and holds everything the pipeline needs that a file can hold.
|
|
4
|
+
|
|
5
|
+
The tool speaks the HTTP API of a [floor](https://github.com/re-cinq/floor) and nothing else. It is a client like any other, with the service token.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @re-cinq/floor-pipeline # the command is floor-pipeline
|
|
9
|
+
|
|
10
|
+
export FLOOR_SERVICE_TOKEN=...
|
|
11
|
+
|
|
12
|
+
floor-pipeline export code-review --floor http://localhost:8180 > code-review.yaml
|
|
13
|
+
floor-pipeline export --all --dir backup/ --floor http://localhost:8180
|
|
14
|
+
floor-pipeline import code-review.yaml --floor http://localhost:8180
|
|
15
|
+
floor-pipeline migrate pipelines/ --floor http://localhost:8180
|
|
16
|
+
floor-pipeline migrate pipelines/ --floor http://floor-api:8080 --wait-ready 180
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| command | what it does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `export <line>` | writes the line's latest version, and what it names, as one file |
|
|
22
|
+
| `export --all --dir <dir>` | one file a line, named after it: a backup |
|
|
23
|
+
| `import <file>...` | puts each file to the floor. The same file twice changes nothing: a version is its content |
|
|
24
|
+
| `migrate <dir>` | the folder's files in the order of their names, each once |
|
|
25
|
+
| `--wait-ready <seconds>` | first polls the floor's `/readyz` until it answers 200, and fails, naming the floor and the seconds, if it does not in time. For a job that starts with the floor, such as the chart's seed Job |
|
|
26
|
+
|
|
27
|
+
## Where this fits
|
|
28
|
+
|
|
29
|
+
A client and nothing more: it speaks the floor's HTTP API with a service token, exactly as lore or
|
|
30
|
+
a worker does, and links none of floor's own packages. Where
|
|
31
|
+
[`@floor/lore-converter`](https://github.com/re-cinq/floor/blob/main/packages/lore-converter/README.md) brings an assembly line *in* from lore, this
|
|
32
|
+
moves a whole pipeline between a file and a floor — to review it, to keep it in git, or to put the
|
|
33
|
+
same one into another floor. It moves definitions only; assembly runs stay where they happened.
|
|
34
|
+
|
|
35
|
+
See [the map](https://github.com/re-cinq/floor/blob/main/README.md).
|
|
36
|
+
|
|
37
|
+
## The file
|
|
38
|
+
|
|
39
|
+
YAML, in `snake_case`, as the docs write definitions. Every prompt is in it, as the block it is.
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
line:
|
|
43
|
+
id: code-review
|
|
44
|
+
entry: review
|
|
45
|
+
exit: done
|
|
46
|
+
args:
|
|
47
|
+
pr_url: { kind: value, subject: true }
|
|
48
|
+
files:
|
|
49
|
+
checklist: |
|
|
50
|
+
- read the spec
|
|
51
|
+
- read the diff
|
|
52
|
+
nodes:
|
|
53
|
+
- { id: review, station: code-review }
|
|
54
|
+
- { id: done }
|
|
55
|
+
edges:
|
|
56
|
+
- { from: review, to: done, on: success }
|
|
57
|
+
- { from: review, to: review, on: failed, iteration_max: 1 }
|
|
58
|
+
stations:
|
|
59
|
+
code-review:
|
|
60
|
+
kind: agent
|
|
61
|
+
agent_definition: reviewer
|
|
62
|
+
outcomes: [success, failed]
|
|
63
|
+
needs: [{ name: pr_url, kind: value }]
|
|
64
|
+
produces: []
|
|
65
|
+
agent_definitions:
|
|
66
|
+
reviewer:
|
|
67
|
+
settings:
|
|
68
|
+
model: gemini-3.1-pro-preview
|
|
69
|
+
image: node:22-bookworm
|
|
70
|
+
timeout_minutes: 25
|
|
71
|
+
prices:
|
|
72
|
+
gemini-3.1-pro-preview: { input_per_million: 2, output_per_million: 12 }
|
|
73
|
+
prompt: |
|
|
74
|
+
Review {pr_url}.
|
|
75
|
+
schedules:
|
|
76
|
+
nightly: { cron: "0 3 * * *", payload: { pr_url: https://pr/1 } }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **The floor's own fields change spelling**, `timeout_minutes` to `timeoutMinutes`. Names a person chose do not: a model's name, a repository's, a key of `config` or of `env`.
|
|
80
|
+
- **A file a line ships is in the file by its content.** Text as it is; anything else as `{ base64: ... }`. The floor keeps it by its hash, and a hash is no backup.
|
|
81
|
+
- **A station two lines use is in both files.** Put twice with the same content, it is one version.
|
|
82
|
+
- **What is exported is what runs now**: each definition's latest version. Older versions stay on the floor and are not in the file.
|
|
83
|
+
|
|
84
|
+
## What a file cannot hold
|
|
85
|
+
|
|
86
|
+
| | where it is |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Secrets: a model's key, an MCP server's token | in the cluster's `agent-secrets`. A definition names the key, and never the value |
|
|
89
|
+
| The program behind a service station | wherever it runs. The file declares the station; the program that posts a review is lore's |
|
|
90
|
+
| Runs, visits, their records and costs | in the floor's database. This is a backup of what a floor is told to do, and not of what it did |
|
|
91
|
+
|
|
92
|
+
## Migrations
|
|
93
|
+
|
|
94
|
+
`migrate` is for seeding a floor at deploy, and for changing it after. It runs a folder's files in the order of their names, each once.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
pipelines/
|
|
98
|
+
0001-code-review.yaml
|
|
99
|
+
0002-code-review-reply.yaml
|
|
100
|
+
0003-code-review.yaml a changed prompt
|
|
101
|
+
0004-remove-gap-fill.yaml archive: { lines: [gap-fill] }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **The floor remembers which files ran**, by name and by the sha256 of what they held: `GET /migrations`.
|
|
105
|
+
- **A file that ran is not run again.** So what a person changes over HTTP stays, until a later file changes that pipeline.
|
|
106
|
+
- **A file that ran and was changed since is refused.** What it did is done. A change is a new file.
|
|
107
|
+
- **A file the floor refused is not remembered**, and runs when it is mended.
|
|
108
|
+
- **A pipeline leaves as it came, by a file**: `archive` names the lines, stations, agent definitions and schedules to archive. The floor refuses to archive a line with open runs.
|
|
109
|
+
|
|
110
|
+
A file is several requests, and not one: a file the floor refuses half way has put what came before. Putting is safe to repeat, so the mended file puts the rest.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// floor-pipeline: a pipeline as one file, out of a floor and into one.
|
|
3
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { exportPipeline, lineIds } from "./export.js";
|
|
7
|
+
import { readPipelineFile, writePipelineFile } from "./file.js";
|
|
8
|
+
import { importPipeline } from "./import.js";
|
|
9
|
+
import { migrate } from "./migrate.js";
|
|
10
|
+
import { waitUntilReady } from "./ready.js";
|
|
11
|
+
import { fileOf, pipelineOf } from "./shape.js";
|
|
12
|
+
const USAGE = `floor-pipeline <what to do> --floor <url> with FLOOR_SERVICE_TOKEN
|
|
13
|
+
|
|
14
|
+
export <line> one pipeline, written to standard output, or to --out <file>
|
|
15
|
+
export --all --dir <dir> every pipeline, one file each, named after its line: a backup
|
|
16
|
+
import <file>... puts each file to the floor. The same file twice changes nothing
|
|
17
|
+
migrate <dir> the folder's files in the order of their names, each once.
|
|
18
|
+
The floor remembers which ran
|
|
19
|
+
|
|
20
|
+
--wait-ready <seconds> first waits up to that long for the floor's /readyz to answer 200,
|
|
21
|
+
for a job that starts with the floor`;
|
|
22
|
+
const OPTIONS = { floor: { type: "string" }, out: { type: "string" }, dir: { type: "string" }, all: { type: "boolean" }, "wait-ready": { type: "string" } };
|
|
23
|
+
function given() {
|
|
24
|
+
return parseArgs({ options: OPTIONS, allowPositionals: true });
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
const told = given();
|
|
28
|
+
const [command = ""] = told.positionals;
|
|
29
|
+
const commands = { export: exported, import: imported, migrate: migrated };
|
|
30
|
+
const run = commands[command];
|
|
31
|
+
if (!run)
|
|
32
|
+
throw new Error(USAGE);
|
|
33
|
+
const floor = floorOf(told);
|
|
34
|
+
await waitedFor(floor, told.values["wait-ready"]);
|
|
35
|
+
await run(floor, told);
|
|
36
|
+
}
|
|
37
|
+
async function waitedFor(floor, waitReady) {
|
|
38
|
+
if (waitReady === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const seconds = Number(waitReady);
|
|
41
|
+
if (!(seconds > 0))
|
|
42
|
+
throw new Error(`--wait-ready is a number of seconds above 0, and not "${waitReady}"`);
|
|
43
|
+
await waitUntilReady({ floorUrl: floor.url, seconds });
|
|
44
|
+
}
|
|
45
|
+
function floorOf(told) {
|
|
46
|
+
const token = process.env.FLOOR_SERVICE_TOKEN;
|
|
47
|
+
const url = told.values.floor;
|
|
48
|
+
if (!url || !token)
|
|
49
|
+
throw new Error(`a floor is named with --floor, and reached with FLOOR_SERVICE_TOKEN\n\n${USAGE}`);
|
|
50
|
+
return { url: url.replace(/\/$/, ""), token };
|
|
51
|
+
}
|
|
52
|
+
async function exported(floor, told) {
|
|
53
|
+
const [, line] = told.positionals;
|
|
54
|
+
if (told.values.all)
|
|
55
|
+
return backedUp(floor, told.values.dir);
|
|
56
|
+
if (!line)
|
|
57
|
+
throw new Error(USAGE);
|
|
58
|
+
const written = writePipelineFile(fileOf(await exportPipeline(floor, line)));
|
|
59
|
+
if (!told.values.out)
|
|
60
|
+
return void process.stdout.write(written);
|
|
61
|
+
await writeFile(told.values.out, written);
|
|
62
|
+
}
|
|
63
|
+
async function backedUp(floor, dir) {
|
|
64
|
+
if (!dir)
|
|
65
|
+
throw new Error("export --all writes a file a pipeline, into --dir <dir>");
|
|
66
|
+
await mkdir(dir, { recursive: true });
|
|
67
|
+
const lines = await lineIds(floor);
|
|
68
|
+
await Promise.all(lines.map(async (line) => writeFile(join(dir, `${line}.yaml`), writePipelineFile(fileOf(await exportPipeline(floor, line))))));
|
|
69
|
+
console.error(`${lines.length} pipeline(s) written to ${dir}`);
|
|
70
|
+
}
|
|
71
|
+
async function imported(floor, told) {
|
|
72
|
+
const [, ...files] = told.positionals;
|
|
73
|
+
if (files.length === 0)
|
|
74
|
+
throw new Error(USAGE);
|
|
75
|
+
for (const file of files)
|
|
76
|
+
said(file, await importPipeline(floor, pipelineOf(readPipelineFile(await readFile(file, "utf8")))));
|
|
77
|
+
}
|
|
78
|
+
async function migrated(floor, told) {
|
|
79
|
+
const [, dir] = told.positionals;
|
|
80
|
+
if (!dir)
|
|
81
|
+
throw new Error(USAGE);
|
|
82
|
+
const names = (await readdir(dir)).filter((name) => /\.ya?ml$/.test(name));
|
|
83
|
+
const files = await Promise.all(names.map(async (name) => ({ name, text: await readFile(join(dir, name), "utf8") })));
|
|
84
|
+
const all = await migrate(floor, files);
|
|
85
|
+
all.forEach((each) => (each.ran ? said(each.name, each.put) : console.log(`${each.name}: ran before`)));
|
|
86
|
+
}
|
|
87
|
+
function said(file, put) {
|
|
88
|
+
console.log(`${file}:`);
|
|
89
|
+
put.forEach((each) => console.log(` ${each.kind}/${each.id}: ${each.changed ? "changed" : "as it was"}`));
|
|
90
|
+
}
|
|
91
|
+
main().catch((error) => {
|
|
92
|
+
console.error(error instanceof Error ? error.message : error);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
});
|
|
95
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/export.d.ts
ADDED
package/dist/export.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A pipeline, read from a floor: a line's latest version with everything it needs to run on another floor, or on this one after it is lost. A file is read whole, its content and not its hash: a hash is no backup.
|
|
2
|
+
import { asked, blobRead } from "./floor.js";
|
|
3
|
+
const SCHEDULE_TICK = /^schedule\.(.+)\.tick$/;
|
|
4
|
+
export async function lineIds(floor) {
|
|
5
|
+
const listed = await asked(floor, { method: "GET", path: "/assembly-lines" });
|
|
6
|
+
return (listed?.items ?? []).map((row) => row.id);
|
|
7
|
+
}
|
|
8
|
+
export async function exportPipeline(floor, lineId) {
|
|
9
|
+
const line = await named(floor, "assembly-lines", lineId);
|
|
10
|
+
const { files = {}, ...body } = line.body;
|
|
11
|
+
const stations = await allNamed(floor, "stations", stationsOf(body));
|
|
12
|
+
const agentDefinitions = await allNamed(floor, "agent-definitions", namesIn(stations.map((station) => station.body.agentDefinition)));
|
|
13
|
+
return {
|
|
14
|
+
line: { id: line.id, body },
|
|
15
|
+
files: await filesRead(floor, files),
|
|
16
|
+
stations,
|
|
17
|
+
agentDefinitions,
|
|
18
|
+
schedules: await allNamed(floor, "schedules", schedulesOf(body)),
|
|
19
|
+
archive: { lines: [], stations: [], agentDefinitions: [], schedules: [] },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// A node may pin a station's version, `name@hash`; the file carries the station by its name.
|
|
23
|
+
function stationsOf(line) {
|
|
24
|
+
const nodes = (line.nodes ?? []);
|
|
25
|
+
const named = nodes.map((node) => node.station ?? "");
|
|
26
|
+
return namesIn(named.map((station) => station.split("@")[0]));
|
|
27
|
+
}
|
|
28
|
+
// A line a schedule starts names the schedule's tick as what starts it.
|
|
29
|
+
function schedulesOf(line) {
|
|
30
|
+
const start = (line.start ?? {});
|
|
31
|
+
const ticks = (start.on ?? []).map((event) => SCHEDULE_TICK.exec(event)?.[1]);
|
|
32
|
+
return namesIn(ticks);
|
|
33
|
+
}
|
|
34
|
+
function namesIn(held) {
|
|
35
|
+
const names = held.filter((name) => typeof name === "string" && name.length > 0);
|
|
36
|
+
return [...new Set(names)];
|
|
37
|
+
}
|
|
38
|
+
function allNamed(floor, kind, ids) {
|
|
39
|
+
return Promise.all(ids.map((id) => named(floor, kind, id)));
|
|
40
|
+
}
|
|
41
|
+
async function named(floor, kind, id) {
|
|
42
|
+
const row = await asked(floor, { method: "GET", path: `/${kind}/${id}` });
|
|
43
|
+
if (!row)
|
|
44
|
+
throw new Error(`the floor has no ${kind} "${id}"`);
|
|
45
|
+
return { id: row.id, body: row.body };
|
|
46
|
+
}
|
|
47
|
+
async function filesRead(floor, files) {
|
|
48
|
+
const read = await Promise.all(Object.entries(files).map(async ([name, hash]) => [name, await blobRead(floor, hash)]));
|
|
49
|
+
return Object.fromEntries(read);
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=export.js.map
|
package/dist/file.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Text as it is; anything else as base64. */
|
|
3
|
+
declare const content: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
4
|
+
base64: z.ZodString;
|
|
5
|
+
}, z.core.$strict>]>;
|
|
6
|
+
declare const pipelineFile: z.ZodObject<{
|
|
7
|
+
line: z.ZodOptional<z.ZodObject<{
|
|
8
|
+
id: z.ZodString;
|
|
9
|
+
files: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
10
|
+
base64: z.ZodString;
|
|
11
|
+
}, z.core.$strict>]>>>;
|
|
12
|
+
}, z.core.$loose>>;
|
|
13
|
+
stations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
14
|
+
agent_definitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
15
|
+
schedules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
16
|
+
archive: z.ZodOptional<z.ZodObject<{
|
|
17
|
+
lines: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
stations: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
19
|
+
agent_definitions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
20
|
+
schedules: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
21
|
+
}, z.core.$strict>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
export type PipelineFile = z.infer<typeof pipelineFile>;
|
|
24
|
+
export type FileContent = z.infer<typeof content>;
|
|
25
|
+
export declare function readPipelineFile(text: string): PipelineFile;
|
|
26
|
+
/** A prompt is written as the block it is, and no line is folded: a prompt's line breaks are the prompt's. */
|
|
27
|
+
export declare function writePipelineFile(file: PipelineFile): string;
|
|
28
|
+
export {};
|
package/dist/file.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A pipeline as one file, as a person writes it: YAML, in snake_case, with every prompt and every file's content in it. Only the file's own outline is checked here; what a line, a station or an agent definition may hold is the floor's to say, and it says so when the file is imported.
|
|
2
|
+
import { parse, stringify } from "yaml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const fields = z.record(z.string(), z.unknown());
|
|
5
|
+
const named = z.record(z.string(), fields);
|
|
6
|
+
const names = z.array(z.string());
|
|
7
|
+
/** Text as it is; anything else as base64. */
|
|
8
|
+
const content = z.union([z.string(), z.strictObject({ base64: z.string() })]);
|
|
9
|
+
const pipelineFile = z.strictObject({
|
|
10
|
+
line: z.looseObject({ id: z.string().min(1), files: z.record(z.string(), content).optional() }).optional(),
|
|
11
|
+
stations: named.optional(),
|
|
12
|
+
agent_definitions: named.optional(),
|
|
13
|
+
schedules: named.optional(),
|
|
14
|
+
/** What this file takes away: a pipeline that is no longer wanted leaves by a file, as it came. */
|
|
15
|
+
archive: z.strictObject({ lines: names.optional(), stations: names.optional(), agent_definitions: names.optional(), schedules: names.optional() }).optional(),
|
|
16
|
+
});
|
|
17
|
+
export function readPipelineFile(text) {
|
|
18
|
+
const read = pipelineFile.safeParse(parse(text));
|
|
19
|
+
if (read.success)
|
|
20
|
+
return read.data;
|
|
21
|
+
const { issues } = read.error;
|
|
22
|
+
const problems = issues.map((issue) => `${issue.path.join(".") || "the file"}: ${issue.message}`);
|
|
23
|
+
throw new Error(`not a pipeline file: ${problems.join("; ")}`);
|
|
24
|
+
}
|
|
25
|
+
/** A prompt is written as the block it is, and no line is folded: a prompt's line breaks are the prompt's. */
|
|
26
|
+
export function writePipelineFile(file) {
|
|
27
|
+
return stringify(file, { lineWidth: 0, blockQuote: "literal" });
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=file.js.map
|
package/dist/floor.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Floor {
|
|
2
|
+
url: string;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Asking {
|
|
6
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
7
|
+
path: string;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
}
|
|
10
|
+
/** Null for what the floor does not have. Anything else it refuses is thrown, in the floor's own words. */
|
|
11
|
+
export declare function asked<Answer>(floor: Floor, asking: Asking): Promise<Answer | null>;
|
|
12
|
+
export declare function blobStored(floor: Floor, bytes: Buffer): Promise<string>;
|
|
13
|
+
export declare function blobRead(floor: Floor, hash: string): Promise<Buffer>;
|
package/dist/floor.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The floor, as this tool reaches it: over HTTP, as a service, through the routes anything else would use.
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
const HTTP_NOT_FOUND = 404;
|
|
4
|
+
/** Null for what the floor does not have. Anything else it refuses is thrown, in the floor's own words. */
|
|
5
|
+
export async function asked(floor, asking) {
|
|
6
|
+
const response = await sent(floor, asking, asking.body === undefined ? undefined : { type: "application/json", bytes: JSON.stringify(asking.body) });
|
|
7
|
+
if (response.status === HTTP_NOT_FOUND)
|
|
8
|
+
return null;
|
|
9
|
+
const answer = await response.text();
|
|
10
|
+
return answer ? JSON.parse(answer) : null;
|
|
11
|
+
}
|
|
12
|
+
export async function blobStored(floor, bytes) {
|
|
13
|
+
const response = await sent(floor, { method: "POST", path: "/blobs" }, { type: "application/octet-stream", bytes: new Uint8Array(bytes) });
|
|
14
|
+
const stored = (await response.json());
|
|
15
|
+
return stored.hash;
|
|
16
|
+
}
|
|
17
|
+
export async function blobRead(floor, hash) {
|
|
18
|
+
const response = await sent(floor, { method: "GET", path: `/blobs/${hash}` });
|
|
19
|
+
if (response.status === HTTP_NOT_FOUND)
|
|
20
|
+
throw new Error(`the floor has no blob "${hash}", which a line names as its file`);
|
|
21
|
+
return Buffer.from(await response.arrayBuffer());
|
|
22
|
+
}
|
|
23
|
+
async function sent(floor, asking, body) {
|
|
24
|
+
const response = await fetch(`${floor.url}${asking.path}`, {
|
|
25
|
+
method: asking.method,
|
|
26
|
+
headers: { authorization: `Bearer ${floor.token}`, ...(body && { "content-type": body.type }) },
|
|
27
|
+
body: body?.bytes,
|
|
28
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
29
|
+
});
|
|
30
|
+
if (response.ok || response.status === HTTP_NOT_FOUND)
|
|
31
|
+
return response;
|
|
32
|
+
throw new Error(`the floor refused ${asking.method} ${asking.path} with ${response.status}: ${await response.text()}`);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=floor.js.map
|
package/dist/import.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Floor } from "./floor.js";
|
|
2
|
+
import type { Pipeline } from "./shape.js";
|
|
3
|
+
export interface Put {
|
|
4
|
+
/** The floor's route: `stations`, say. */
|
|
5
|
+
kind: string;
|
|
6
|
+
id: string;
|
|
7
|
+
/** False for a version the floor already had. */
|
|
8
|
+
changed: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function importPipeline(floor: Floor, pipeline: Pipeline): Promise<Put[]>;
|
package/dist/import.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A pipeline, put to a floor: what a line names first, then the line, since the floor refuses a line naming a station it does not have. Putting the same thing twice changes nothing; a version is its content.
|
|
2
|
+
import { asked, blobStored } from "./floor.js";
|
|
3
|
+
export async function importPipeline(floor, pipeline) {
|
|
4
|
+
const files = await filesStored(floor, pipeline.files);
|
|
5
|
+
const agentDefinitions = await allPut(floor, "agent-definitions", pipeline.agentDefinitions);
|
|
6
|
+
const stations = await allPut(floor, "stations", pipeline.stations);
|
|
7
|
+
const lines = await allPut(floor, "assembly-lines", linesOf(pipeline, files));
|
|
8
|
+
const schedules = await allPut(floor, "schedules", pipeline.schedules);
|
|
9
|
+
return [...agentDefinitions, ...stations, ...lines, ...schedules, ...(await archived(floor, pipeline))];
|
|
10
|
+
}
|
|
11
|
+
function linesOf(pipeline, files) {
|
|
12
|
+
const line = pipeline.line;
|
|
13
|
+
if (!line)
|
|
14
|
+
return [];
|
|
15
|
+
const named = Object.keys(files).length > 0 ? { files } : {};
|
|
16
|
+
return [{ id: line.id, body: { ...line.body, ...named } }];
|
|
17
|
+
}
|
|
18
|
+
async function filesStored(floor, files) {
|
|
19
|
+
const stored = await Promise.all(Object.entries(files).map(async ([name, bytes]) => [name, await blobStored(floor, bytes)]));
|
|
20
|
+
return Object.fromEntries(stored);
|
|
21
|
+
}
|
|
22
|
+
function allPut(floor, kind, named) {
|
|
23
|
+
return Promise.all(named.map((each) => put(floor, kind, each)));
|
|
24
|
+
}
|
|
25
|
+
async function put(floor, kind, named) {
|
|
26
|
+
const answer = await asked(floor, { method: "POST", path: `/${kind}`, body: { id: named.id, ...named.body } });
|
|
27
|
+
return { kind, id: named.id, changed: answer?.created ?? true };
|
|
28
|
+
}
|
|
29
|
+
// A line goes before what it named: the floor keeps a station a line still names.
|
|
30
|
+
async function archived(floor, pipeline) {
|
|
31
|
+
const { lines, stations, agentDefinitions, schedules } = pipeline.archive;
|
|
32
|
+
const gone = [...kindOf("assembly-lines", lines), ...kindOf("schedules", schedules), ...kindOf("stations", stations), ...kindOf("agent-definitions", agentDefinitions)];
|
|
33
|
+
for (const each of gone)
|
|
34
|
+
await asked(floor, { method: "DELETE", path: `/${each.kind}/${each.id}` });
|
|
35
|
+
return gone;
|
|
36
|
+
}
|
|
37
|
+
function kindOf(kind, ids) {
|
|
38
|
+
return ids.map((id) => ({ kind, id, changed: true }));
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=import.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { readPipelineFile, writePipelineFile, type PipelineFile } from "./file.js";
|
|
2
|
+
export { fileOf, pipelineOf, type Pipeline } from "./shape.js";
|
|
3
|
+
export { exportPipeline, lineIds } from "./export.js";
|
|
4
|
+
export { importPipeline, type Put } from "./import.js";
|
|
5
|
+
export { migrate, type Migrated, type MigrationFile } from "./migrate.js";
|
|
6
|
+
export type { Floor } from "./floor.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { readPipelineFile, writePipelineFile } from "./file.js";
|
|
2
|
+
export { fileOf, pipelineOf } from "./shape.js";
|
|
3
|
+
export { exportPipeline, lineIds } from "./export.js";
|
|
4
|
+
export { importPipeline } from "./import.js";
|
|
5
|
+
export { migrate } from "./migrate.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Floor } from "./floor.js";
|
|
2
|
+
import { type Put } from "./import.js";
|
|
3
|
+
export interface MigrationFile {
|
|
4
|
+
name: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Migrated {
|
|
8
|
+
name: string;
|
|
9
|
+
/** Empty for a file that had run before. */
|
|
10
|
+
put: Put[];
|
|
11
|
+
ran: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function migrate(floor: Floor, files: MigrationFile[]): Promise<Migrated[]>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A folder of pipeline files, applied in the order of their names, each once. The floor remembers which ran, and by what content: a file that ran is never run again, and one that ran and was since changed is refused, since what it did is done. A change is a new file.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readPipelineFile } from "./file.js";
|
|
4
|
+
import { asked } from "./floor.js";
|
|
5
|
+
import { importPipeline } from "./import.js";
|
|
6
|
+
import { pipelineOf } from "./shape.js";
|
|
7
|
+
export async function migrate(floor, files) {
|
|
8
|
+
const ordered = files.toSorted((one, other) => one.name.localeCompare(other.name));
|
|
9
|
+
const migrated = [];
|
|
10
|
+
for (const file of ordered)
|
|
11
|
+
migrated.push(await applied(floor, file));
|
|
12
|
+
return migrated;
|
|
13
|
+
}
|
|
14
|
+
async function applied(floor, file) {
|
|
15
|
+
const sha256 = createHash("sha256").update(file.text).digest("hex");
|
|
16
|
+
const remembered = await asked(floor, { method: "GET", path: `/migrations/${encodeURIComponent(file.name)}` });
|
|
17
|
+
if (remembered)
|
|
18
|
+
return alreadyRan(file, { sha256, ranAs: remembered.body.sha256 });
|
|
19
|
+
const put = await importPipeline(floor, pipelineOf(readPipelineFile(file.text)));
|
|
20
|
+
await asked(floor, { method: "PUT", path: `/migrations/${encodeURIComponent(file.name)}`, body: { sha256 } });
|
|
21
|
+
return { name: file.name, put, ran: true };
|
|
22
|
+
}
|
|
23
|
+
function alreadyRan(file, content) {
|
|
24
|
+
if (content.sha256 !== content.ranAs)
|
|
25
|
+
throw new Error(`"${file.name}" has run, and has been changed since: what it did is done. Put the change in a new file`);
|
|
26
|
+
return { name: file.name, put: [], ran: false };
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=migrate.js.map
|
package/dist/ready.d.ts
ADDED
package/dist/ready.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The floor answers /readyz once it can serve. A deploy hook runs when the floor's pods are created, not when they are ready.
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
const MS_PER_SECOND = 1000;
|
|
4
|
+
const DEFAULT_POLL_MS = 2000;
|
|
5
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
6
|
+
export async function waitUntilReady(waiting) {
|
|
7
|
+
const { floorUrl, seconds, pollMs = DEFAULT_POLL_MS } = waiting;
|
|
8
|
+
const deadline = performance.now() + seconds * MS_PER_SECOND;
|
|
9
|
+
while (!(await answersReady(floorUrl))) {
|
|
10
|
+
if (performance.now() + pollMs > deadline)
|
|
11
|
+
throw new Error(`the floor at ${floorUrl} did not answer ready in ${seconds} seconds`);
|
|
12
|
+
await sleep(pollMs);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function answersReady(floorUrl) {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`${floorUrl}/readyz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
18
|
+
return response.ok;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=ready.js.map
|
package/dist/shape.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PipelineFile } from "./file.js";
|
|
2
|
+
export type Fields = Record<string, unknown>;
|
|
3
|
+
export interface Named {
|
|
4
|
+
id: string;
|
|
5
|
+
body: Fields;
|
|
6
|
+
}
|
|
7
|
+
export interface Archive {
|
|
8
|
+
lines: string[];
|
|
9
|
+
stations: string[];
|
|
10
|
+
agentDefinitions: string[];
|
|
11
|
+
schedules: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface Pipeline {
|
|
14
|
+
/** Without its files, which the floor keeps by their hash and the file by their content. */
|
|
15
|
+
line?: Named;
|
|
16
|
+
files: Record<string, Buffer>;
|
|
17
|
+
stations: Named[];
|
|
18
|
+
agentDefinitions: Named[];
|
|
19
|
+
schedules: Named[];
|
|
20
|
+
archive: Archive;
|
|
21
|
+
}
|
|
22
|
+
export declare function pipelineOf(file: PipelineFile): Pipeline;
|
|
23
|
+
/** What is empty is left out: a file says what a pipeline has. */
|
|
24
|
+
export declare function fileOf(pipeline: Pipeline): PipelineFile;
|
package/dist/shape.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const AS_THE_FLOOR_SPELLS = {
|
|
2
|
+
edge: { iteration_max: "iterationMax" },
|
|
3
|
+
station: { agent_definition: "agentDefinition", conversation_key: "conversationKey", must_change: "mustChange" },
|
|
4
|
+
settings: { timeout_minutes: "timeoutMinutes" },
|
|
5
|
+
price: { input_per_million: "inputPerMillion", output_per_million: "outputPerMillion", cache_read_per_million: "cacheReadPerMillion", cache_write_per_million: "cacheWritePerMillion" },
|
|
6
|
+
};
|
|
7
|
+
const AS_THE_FILE_SPELLS = {
|
|
8
|
+
edge: turned(AS_THE_FLOOR_SPELLS.edge),
|
|
9
|
+
station: turned(AS_THE_FLOOR_SPELLS.station),
|
|
10
|
+
settings: turned(AS_THE_FLOOR_SPELLS.settings),
|
|
11
|
+
price: turned(AS_THE_FLOOR_SPELLS.price),
|
|
12
|
+
};
|
|
13
|
+
export function pipelineOf(file) {
|
|
14
|
+
return {
|
|
15
|
+
...lineOf(file.line),
|
|
16
|
+
stations: namedIn(file.stations, (body) => respelled(body, AS_THE_FLOOR_SPELLS.station)),
|
|
17
|
+
agentDefinitions: namedIn(file.agent_definitions, (body) => agentDefinitionBody(body, AS_THE_FLOOR_SPELLS)),
|
|
18
|
+
schedules: namedIn(file.schedules, (body) => body),
|
|
19
|
+
archive: archiveOf(file.archive),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function lineOf(written) {
|
|
23
|
+
if (!written)
|
|
24
|
+
return { files: {} };
|
|
25
|
+
const { id, files = {}, ...line } = written;
|
|
26
|
+
return { line: { id, body: lineBody(line, AS_THE_FLOOR_SPELLS) }, files: valuesOf(files, bytesOf) };
|
|
27
|
+
}
|
|
28
|
+
function archiveOf(written) {
|
|
29
|
+
const { agent_definitions: agentDefinitions, ...taken } = { lines: [], stations: [], schedules: [], agent_definitions: [], ...written };
|
|
30
|
+
return { ...taken, agentDefinitions };
|
|
31
|
+
}
|
|
32
|
+
/** The order a person reads in: where a line begins before what it is given, a prompt after the model it is for. A floor hands its fields back in the alphabet's. */
|
|
33
|
+
const READ_IN_ORDER = {
|
|
34
|
+
line: ["id", "entry", "exit", "start", "args", "files", "nodes", "edges"],
|
|
35
|
+
station: ["kind", "agent_definition", "conversation", "conversation_key", "url", "route", "outcomes", "must_change", "needs", "produces"],
|
|
36
|
+
settings: ["model", "image", "timeout_minutes", "tags", "prices", "config", "prompt"],
|
|
37
|
+
schedule: ["cron", "timezone", "payload"],
|
|
38
|
+
};
|
|
39
|
+
/** What is empty is left out: a file says what a pipeline has. */
|
|
40
|
+
export function fileOf(pipeline) {
|
|
41
|
+
const line = pipeline.line;
|
|
42
|
+
const written = {
|
|
43
|
+
line: line && inOrder({ id: line.id, ...lineBody(line.body, AS_THE_FILE_SPELLS), ...filled("files", valuesOf(pipeline.files, contentOf)) }, READ_IN_ORDER.line),
|
|
44
|
+
stations: byId(pipeline.stations, (body) => inOrder(respelled(body, AS_THE_FILE_SPELLS.station), READ_IN_ORDER.station)),
|
|
45
|
+
agent_definitions: byId(pipeline.agentDefinitions, (body) => agentDefinitionBody(body, AS_THE_FILE_SPELLS)),
|
|
46
|
+
schedules: byId(pipeline.schedules, (body) => inOrder(body, READ_IN_ORDER.schedule)),
|
|
47
|
+
};
|
|
48
|
+
return Object.fromEntries(Object.entries(written).filter(([, held]) => isFilled(held)));
|
|
49
|
+
}
|
|
50
|
+
// The fields named, in the order named, then any other as it came.
|
|
51
|
+
function inOrder(given, order) {
|
|
52
|
+
const named = order.filter((name) => name in given);
|
|
53
|
+
const others = Object.keys(given).filter((name) => !order.includes(name));
|
|
54
|
+
return Object.fromEntries([...named, ...others].map((name) => [name, given[name]]));
|
|
55
|
+
}
|
|
56
|
+
function lineBody(line, spellings) {
|
|
57
|
+
return { ...line, ...filled("edges", listOf(line.edges).map((edge) => respelled(edge, spellings.edge))) };
|
|
58
|
+
}
|
|
59
|
+
function agentDefinitionBody(body, spellings) {
|
|
60
|
+
const variants = valuesOf(fieldsOf(body.variants), (variant) => settingsOf(fieldsOf(variant), spellings));
|
|
61
|
+
return { ...body, settings: settingsOf(fieldsOf(body.settings), spellings), ...filled("variants", variants) };
|
|
62
|
+
}
|
|
63
|
+
function settingsOf(settings, spellings) {
|
|
64
|
+
const prices = valuesOf(fieldsOf(settings.prices), (price) => respelled(fieldsOf(price), spellings.price));
|
|
65
|
+
return inOrder({ ...respelled(settings, spellings.settings), ...filled("prices", prices) }, READ_IN_ORDER.settings);
|
|
66
|
+
}
|
|
67
|
+
function respelled(given, spelling) {
|
|
68
|
+
return Object.fromEntries(Object.entries(given).map(([name, held]) => [spelling[name] ?? name, held]));
|
|
69
|
+
}
|
|
70
|
+
function turned(spelling) {
|
|
71
|
+
return Object.fromEntries(Object.entries(spelling).map(([file, floor]) => [floor, file]));
|
|
72
|
+
}
|
|
73
|
+
function namedIn(byName, bodyOf) {
|
|
74
|
+
return Object.entries(byName ?? {}).map(([id, body]) => ({ id, body: bodyOf(body) }));
|
|
75
|
+
}
|
|
76
|
+
function byId(named, bodyOf) {
|
|
77
|
+
return Object.fromEntries(named.map((each) => [each.id, bodyOf(each.body)]));
|
|
78
|
+
}
|
|
79
|
+
function bytesOf(written) {
|
|
80
|
+
return typeof written === "string" ? Buffer.from(written) : Buffer.from(written.base64, "base64");
|
|
81
|
+
}
|
|
82
|
+
// Text only when it comes back the same: bytes that are no text would be written as something else.
|
|
83
|
+
function contentOf(bytes) {
|
|
84
|
+
const text = bytes.toString("utf8");
|
|
85
|
+
return Buffer.from(text).equals(bytes) ? text : { base64: bytes.toString("base64") };
|
|
86
|
+
}
|
|
87
|
+
function valuesOf(byName, made) {
|
|
88
|
+
return Object.fromEntries(Object.entries(byName).map(([name, held]) => [name, made(held)]));
|
|
89
|
+
}
|
|
90
|
+
function filled(name, held) {
|
|
91
|
+
return isFilled(held) ? { [name]: held } : {};
|
|
92
|
+
}
|
|
93
|
+
function isFilled(held) {
|
|
94
|
+
if (held === undefined)
|
|
95
|
+
return false;
|
|
96
|
+
return typeof held === "object" && held !== null ? Object.keys(held).length > 0 : true;
|
|
97
|
+
}
|
|
98
|
+
function fieldsOf(held) {
|
|
99
|
+
return typeof held === "object" && held !== null && !Array.isArray(held) ? held : {};
|
|
100
|
+
}
|
|
101
|
+
function listOf(held) {
|
|
102
|
+
return Array.isArray(held) ? held.map(fieldsOf) : [];
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=shape.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@re-cinq/floor-pipeline",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A floor's pipeline as one YAML file: an assembly line with its stations, its agent definitions and their prompts, its files and its schedules. Exports one from a floor, imports one into a floor, and applies a folder of them in order, each once. It speaks the floor's HTTP API and nothing else.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/re-cinq/floor.git",
|
|
9
|
+
"directory": "packages/pipeline"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/re-cinq/floor/tree/main/packages/pipeline#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/re-cinq/floor/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"floor",
|
|
17
|
+
"pipeline",
|
|
18
|
+
"assembly-line",
|
|
19
|
+
"yaml",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"bin": {
|
|
26
|
+
"floor-pipeline": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"!dist/**/*.map"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"dev": "tsc --watch -p tsconfig.build.json",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"yaml": "^2.7.0",
|
|
53
|
+
"zod": "^4.5.4"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.20.1",
|
|
57
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|