@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
@@ -0,0 +1,4 @@
1
+ import { type Floor } from "./floor.js";
2
+ import type { Pipeline } from "./shape.js";
3
+ export declare function lineIds(floor: Floor): Promise<string[]>;
4
+ export declare function exportPipeline(floor: Floor, lineId: string): Promise<Pipeline>;
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
@@ -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
@@ -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
@@ -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[]>;
@@ -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
@@ -0,0 +1,6 @@
1
+ export interface Waiting {
2
+ floorUrl: string;
3
+ seconds: number;
4
+ pollMs?: number;
5
+ }
6
+ export declare function waitUntilReady(waiting: Waiting): Promise<void>;
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
@@ -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
+ }