@thinair/factory 0.0.0-stage → 0.1.124
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 +202 -0
- package/NOTICE +4 -0
- package/README.md +104 -2
- package/cli/evaluate.ts +101 -0
- package/cli/factory-code-lint.ts +146 -0
- package/cli/load.ts +463 -0
- package/cli/template.ts +73 -0
- package/domain/load.ts +67 -0
- package/domain/step.ts +70 -0
- package/domain/system-elements/agent.ts +41 -0
- package/domain/system-elements/app-shape.ts +88 -0
- package/domain/system-elements/compose.ts +211 -0
- package/domain/system-elements/factory.ts +61 -0
- package/domain/system-elements/goal.ts +37 -0
- package/domain/system-elements/hole.ts +25 -0
- package/domain/system-elements/job.ts +78 -0
- package/domain/system-elements/node.ts +189 -0
- package/domain/system-elements/pool.ts +63 -0
- package/domain/system-elements/program.ts +123 -0
- package/domain/system-elements/registry.ts +173 -0
- package/domain/system-elements/repository.ts +79 -0
- package/domain/system-elements/rule.ts +55 -0
- package/domain/system-elements/service.ts +82 -0
- package/domain/system-elements/source.ts +40 -0
- package/domain/system-elements/stage.ts +70 -0
- package/package.json +40 -4
package/cli/load.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Context, Effect, Layer, Result, Schema } from "effect";
|
|
9
|
+
import { LOAD_VERSION, LoadJson, LoadPost, type Finding, type Load } from "../domain/load";
|
|
10
|
+
import { appShapeProblems, AUTHORING_MODULES, folderKey, IGNORED_FOLDERS, isLoadedPath, memberFiles } from "../domain/system-elements/app-shape";
|
|
11
|
+
import { FrameworkVersion, holesOf, type Node, type Program } from "../domain/system-elements/program";
|
|
12
|
+
import { EvaluateOutputJson, type EvaluateInput } from "./evaluate";
|
|
13
|
+
import { templateFiles } from "./template";
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* The factory loader: one program, two hosts. The factory VM runs it on the
|
|
17
|
+
* tree the plane fetched (live); an agent runs `dev` in its working copy
|
|
18
|
+
* (preview). It runs under bun.
|
|
19
|
+
*
|
|
20
|
+
* init [dir] [--framework <spec>] write the blank factory template
|
|
21
|
+
* load [tree] [--framework <x.y.z>] load once; print the Load as JSON
|
|
22
|
+
* dev --factory <id> [--tree <dir>] load on every save and post each load
|
|
23
|
+
*
|
|
24
|
+
* A load: snapshot → shape → the factory-code lint → bundle ∥ type check →
|
|
25
|
+
* evaluate in a subprocess with a runaway deadline → value checks → encode.
|
|
26
|
+
* Every finding holds the revision and is pinned to a node, a file and a
|
|
27
|
+
* line where the loader can tell. The same files under the same framework
|
|
28
|
+
* give the same key, and a key loaded before comes from the cache.
|
|
29
|
+
*
|
|
30
|
+
* A load names the framework it was made by: this package's version, or the
|
|
31
|
+
* version `--framework` gives, for a host that carries this framework under
|
|
32
|
+
* a release's number (a Thinair release's factory engine).
|
|
33
|
+
*
|
|
34
|
+
* Exit codes: 0 ready, 1 held, 2 the loader itself failed.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export class FactoryLoadFailed extends Schema.TaggedError<FactoryLoadFailed>()("FactoryLoadFailed", { message: Schema.String }) {}
|
|
38
|
+
|
|
39
|
+
/** Where each load goes: `factory.load.post`, as whoever runs the loader. */
|
|
40
|
+
export class LoadPosts extends Context.Service<LoadPosts, {
|
|
41
|
+
readonly post: (post: LoadPost) => Effect.Effect<PostedLoad, FactoryLoadFailed>;
|
|
42
|
+
}>()("@thinair/factory/cli/load/LoadPosts") {}
|
|
43
|
+
|
|
44
|
+
/** What the plane answered a post: the ref's row as it keeps it now. */
|
|
45
|
+
export type PostedLoad = Readonly<{ state: "ready" | "held"; revision: string | null }>;
|
|
46
|
+
|
|
47
|
+
const ToolEnvelope = Schema.Struct({
|
|
48
|
+
data: Schema.optionalKey(Schema.Struct({ success: Schema.Boolean, output: Schema.optionalKey(Schema.Struct({
|
|
49
|
+
load: Schema.Struct({ state: Schema.Literals(["ready", "held"]), revision: Schema.NullOr(Schema.String) }),
|
|
50
|
+
})) })),
|
|
51
|
+
error: Schema.optionalKey(Schema.Struct({ message: Schema.String })),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Each post through `thinair tool call factory.load.post`, as the caller the `thinair` CLI speaks for: in a Thinair
|
|
56
|
+
* Terminal, its person (or their agent) there. The loader holds no credential of its own.
|
|
57
|
+
*/
|
|
58
|
+
export const LoadPostsThroughThinair = Layer.succeed(LoadPosts, {
|
|
59
|
+
post: (post) => Effect.gen(function* () {
|
|
60
|
+
const input = JSON.stringify(Schema.encodeSync(LoadPostJson)(post));
|
|
61
|
+
const ran = yield* runCommand("thinair", ["--json", "tool", "call", "factory.load.post", "--input-stdin", "--timeout", String(POST_GUARD_MILLIS)], { cwd: process.cwd(), guardMillis: POST_GUARD_MILLIS + 5_000, input });
|
|
62
|
+
if (ran.timedOut) return yield* new FactoryLoadFailed({ message: `factory.load.post ran past its runaway guard of ${POST_GUARD_MILLIS / 1000} s.` });
|
|
63
|
+
const envelope = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ToolEnvelope))(ran.stdout.trim()).pipe(
|
|
64
|
+
Effect.mapError(() => new FactoryLoadFailed({ message: `factory.load.post did not answer (${ran.code ?? "stopped"}): ${`${ran.stderr}${ran.stdout}`.trim().slice(-500)}` })),
|
|
65
|
+
);
|
|
66
|
+
if (envelope.error !== undefined || envelope.data?.success !== true || envelope.data.output === undefined)
|
|
67
|
+
return yield* new FactoryLoadFailed({ message: `factory.load.post refused the load: ${envelope.error?.message ?? "no answer"}` });
|
|
68
|
+
return envelope.data.output.load;
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Runaway guards, never speed checks. */
|
|
73
|
+
const EVALUATE_GUARD_MILLIS = 60_000;
|
|
74
|
+
const TYPE_GUARD_MILLIS = 300_000;
|
|
75
|
+
const LINT_GUARD_MILLIS = 120_000;
|
|
76
|
+
const POLL_MILLIS = 250;
|
|
77
|
+
const POST_GUARD_MILLIS = 60_000;
|
|
78
|
+
const SETTLE_MILLIS = 150;
|
|
79
|
+
|
|
80
|
+
const LoadPostJson = Schema.toCodecJson(LoadPost);
|
|
81
|
+
|
|
82
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
83
|
+
const requireHere = createRequire(import.meta.url);
|
|
84
|
+
const sha256 = (text: string | Uint8Array) => createHash("sha256").update(text).digest("hex");
|
|
85
|
+
const failed = (message: string) => () => new FactoryLoadFailed({ message });
|
|
86
|
+
|
|
87
|
+
const readJson = (file: string) => Effect.tryPromise({ try: async () => JSON.parse(await readFile(file, "utf8")) as Readonly<Record<string, unknown>>, catch: failed(`Could not read ${file}.`) });
|
|
88
|
+
const exists = (file: string) => Effect.promise(() => access(file).then(() => true, () => false));
|
|
89
|
+
|
|
90
|
+
/** The framework's own version, and the effect it is built with: what init pins. */
|
|
91
|
+
const versions = Effect.fn("factory.load.versions")(function* () {
|
|
92
|
+
const own = yield* readJson(path.join(here, "..", "package.json"));
|
|
93
|
+
const effect = yield* readJson(requireHere.resolve("effect/package.json"));
|
|
94
|
+
return { framework: String(own["version"]), effect: String(effect["version"]) };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
type Ran = Readonly<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }>;
|
|
98
|
+
|
|
99
|
+
/** Run a command to its end, or stop it (by its own PID) at its runaway guard. */
|
|
100
|
+
const runCommand = (command: string, args: ReadonlyArray<string>, options: Readonly<{ cwd: string; guardMillis: number; input?: string }>) =>
|
|
101
|
+
Effect.callback<Ran, FactoryLoadFailed>((resume) => {
|
|
102
|
+
const child = spawn(command, [...args], { cwd: options.cwd, env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
103
|
+
if (options.input !== undefined) child.stdin?.end(options.input);
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; });
|
|
108
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; });
|
|
109
|
+
const guard = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, options.guardMillis);
|
|
110
|
+
child.on("error", (error) => { clearTimeout(guard); resume(Effect.fail(new FactoryLoadFailed({ message: `${command} did not start: ${error.message}` }))); });
|
|
111
|
+
child.on("close", (code) => { clearTimeout(guard); resume(Effect.succeed({ code, stdout, stderr, timedOut })); });
|
|
112
|
+
return Effect.sync(() => {
|
|
113
|
+
clearTimeout(guard);
|
|
114
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const IGNORED = new Set<string>([...IGNORED_FOLDERS, ".git"]);
|
|
119
|
+
|
|
120
|
+
/** The folder's files, relative and sorted: everything but installed packages and dot entries (except .gitignore). */
|
|
121
|
+
const listTree = (tree: string) => Effect.tryPromise({
|
|
122
|
+
try: async () => {
|
|
123
|
+
const found: string[] = [];
|
|
124
|
+
const pending = [""];
|
|
125
|
+
while (pending.length > 0) {
|
|
126
|
+
const folder = pending.pop()!;
|
|
127
|
+
for (const entry of await readdir(path.join(tree, folder), { withFileTypes: true })) {
|
|
128
|
+
const relative = folder === "" ? entry.name : `${folder}/${entry.name}`;
|
|
129
|
+
if (IGNORED.has(entry.name) || !isLoadedPath(relative)) continue;
|
|
130
|
+
if (entry.isDirectory()) pending.push(relative);
|
|
131
|
+
else if (entry.isFile()) found.push(relative);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return found.sort();
|
|
135
|
+
},
|
|
136
|
+
catch: failed(`Could not read the factory folder ${tree}.`),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const finding = (step: Finding["step"], message: string, where: Readonly<{ file?: string | null; line?: number | null; node?: Finding["node"] }> = {}): Finding =>
|
|
140
|
+
({ step, node: where.node ?? null, file: where.file ?? null, line: where.line ?? null, message });
|
|
141
|
+
|
|
142
|
+
/** `0.1.12` against `0.1.11-dev.3`: core numbers first, then a release above its prereleases. */
|
|
143
|
+
const compareVersions = (left: string, right: string) => {
|
|
144
|
+
const parse = (version: string) => {
|
|
145
|
+
const [core = "", pre = ""] = version.split("-", 2);
|
|
146
|
+
return { core: core.split(".").map(Number), pre };
|
|
147
|
+
};
|
|
148
|
+
const a = parse(left);
|
|
149
|
+
const b = parse(right);
|
|
150
|
+
for (let index = 0; index < 3; index += 1) if ((a.core[index] ?? 0) !== (b.core[index] ?? 0)) return (a.core[index] ?? 0) - (b.core[index] ?? 0);
|
|
151
|
+
if (a.pre === b.pre) return 0;
|
|
152
|
+
if (a.pre === "") return 1;
|
|
153
|
+
if (b.pre === "") return -1;
|
|
154
|
+
return a.pre < b.pre ? -1 : 1;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const OxlintReport = Schema.Struct({
|
|
158
|
+
diagnostics: Schema.Array(Schema.Struct({
|
|
159
|
+
message: Schema.String,
|
|
160
|
+
code: Schema.optionalKey(Schema.String),
|
|
161
|
+
filename: Schema.String,
|
|
162
|
+
labels: Schema.optionalKey(Schema.Array(Schema.Struct({ span: Schema.Struct({ line: Schema.optionalKey(Schema.Number) }) }))),
|
|
163
|
+
})),
|
|
164
|
+
});
|
|
165
|
+
const categoriesOff = Object.fromEntries(["correctness", "suspicious", "pedantic", "style", "nursery", "perf", "restriction"].map((category) => [category, "off"]));
|
|
166
|
+
|
|
167
|
+
/** The factory-code lint: one oxlint set, whose allowlist is AUTHORING_MODULES. */
|
|
168
|
+
const lint = Effect.fn("factory.load.lint")(function* (tree: string, paths: ReadonlyArray<string>, packages: ReadonlyArray<string>, scratch: string) {
|
|
169
|
+
const files = paths.filter((file) => file.endsWith(".ts"));
|
|
170
|
+
if (files.length === 0) return [];
|
|
171
|
+
const options = { root: tree, modules: [...AUTHORING_MODULES], packages: [...packages], members: [...memberFiles(paths)] };
|
|
172
|
+
const config = path.join(scratch, "factory-code.json");
|
|
173
|
+
yield* Effect.tryPromise({
|
|
174
|
+
try: () => writeFile(config, JSON.stringify({
|
|
175
|
+
plugins: [],
|
|
176
|
+
categories: categoriesOff,
|
|
177
|
+
jsPlugins: [path.join(here, "factory-code-lint.ts")],
|
|
178
|
+
rules: { "thinair-factory/imports": ["error", options], "thinair-factory/sealed": "error", "thinair-factory/one-default-export": ["error", options] },
|
|
179
|
+
})),
|
|
180
|
+
catch: failed("Could not write the lint's settings."),
|
|
181
|
+
});
|
|
182
|
+
const oxlint = path.join(path.dirname(requireHere.resolve("oxlint/package.json")), "bin", "oxlint");
|
|
183
|
+
const ran = yield* runCommand(process.execPath, [oxlint, "--config", config, "--format", "json", "--", ...files], { cwd: tree, guardMillis: LINT_GUARD_MILLIS });
|
|
184
|
+
if (ran.timedOut || (ran.code !== 0 && ran.code !== 1)) return yield* new FactoryLoadFailed({ message: `oxlint failed (${ran.code ?? "stopped"}): ${ran.stderr.slice(0, 500)}` });
|
|
185
|
+
const report = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OxlintReport))(ran.stdout).pipe(Effect.mapError(failed("oxlint answered in a form this loader does not read.")));
|
|
186
|
+
return report.diagnostics.map((diagnostic) => finding("lint", diagnostic.message, {
|
|
187
|
+
file: path.relative(tree, path.resolve(tree, diagnostic.filename)).split(path.sep).join("/"),
|
|
188
|
+
line: diagnostic.labels?.[0]?.span.line ?? null,
|
|
189
|
+
}));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** The type check: bun's checker, with the folder's own tsconfig. */
|
|
193
|
+
const typecheck = Effect.fn("factory.load.type")(function* (tree: string) {
|
|
194
|
+
const ran = yield* runCommand(process.execPath, ["check", "--no-pretty", "-p", tree], { cwd: tree, guardMillis: TYPE_GUARD_MILLIS });
|
|
195
|
+
if (ran.timedOut) return [finding("type", `The type check ran past its runaway guard of ${TYPE_GUARD_MILLIS / 1000} s and was stopped.`)];
|
|
196
|
+
if (ran.code !== 0 && ran.code !== 1) return yield* new FactoryLoadFailed({ message: `bun check failed (${ran.code}): ${ran.stderr.slice(0, 500)}` });
|
|
197
|
+
const found = `${ran.stdout}\n${ran.stderr}`.split("\n").flatMap((line) => {
|
|
198
|
+
const match = /^(.+?)\((\d+),\d+\): error (TS\d+): (.*)$/u.exec(line.trim());
|
|
199
|
+
return match === null ? [] : [finding("type", `${match[3]}: ${match[4]}`, {
|
|
200
|
+
file: path.relative(tree, path.resolve(tree, match[1]!)).split(path.sep).join("/"),
|
|
201
|
+
line: Number(match[2]),
|
|
202
|
+
})];
|
|
203
|
+
});
|
|
204
|
+
if (ran.code === 1 && found.length === 0) return [finding("type", `The type check failed: ${`${ran.stdout}\n${ran.stderr}`.trim().slice(0, 500)}`)];
|
|
205
|
+
return found;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
type BunBuild = (options: Readonly<{
|
|
209
|
+
entrypoints: ReadonlyArray<string>; outdir: string; naming: string; target: "bun"; format: "esm"; sourcemap: "inline"; metafile: true; packages: "external"; throw: false;
|
|
210
|
+
}>) => Promise<Readonly<{
|
|
211
|
+
success: boolean;
|
|
212
|
+
outputs: ReadonlyArray<Readonly<{ path: string }>>;
|
|
213
|
+
logs: ReadonlyArray<Readonly<{ message: string; position?: Readonly<{ file: string; line: number }> | null }>>;
|
|
214
|
+
metafile?: Readonly<{ inputs: Readonly<Record<string, Readonly<{ imports: ReadonlyArray<Readonly<{ path: string }>> }>>> }>;
|
|
215
|
+
}>>;
|
|
216
|
+
|
|
217
|
+
type Bundled = Readonly<{ bundle: string | null; imports: Readonly<Record<string, ReadonlyArray<string>>>; findings: ReadonlyArray<Finding> }>;
|
|
218
|
+
|
|
219
|
+
/** The content-keyed bundle of the factory's code, and its import graph inside the folder. */
|
|
220
|
+
const bundle = Effect.fn("factory.load.bundle")(function* (tree: string, key: string, cache: string) {
|
|
221
|
+
const build = (globalThis as { Bun?: { build: BunBuild } }).Bun?.build;
|
|
222
|
+
if (build === undefined) return yield* new FactoryLoadFailed({ message: "The factory loader runs under bun: run it with bunx @thinair/factory." });
|
|
223
|
+
const result = yield* Effect.tryPromise({
|
|
224
|
+
// Written by the bundler into the folder's own node_modules: its packages resolve from there, and its
|
|
225
|
+
// source map names the factory's files, so a throw or a made-at line points at the code that made it.
|
|
226
|
+
try: () => build({
|
|
227
|
+
entrypoints: [path.join(tree, "factory.ts")], outdir: path.join(cache, "bundles"), naming: `${key}.js`,
|
|
228
|
+
target: "bun", format: "esm", sourcemap: "inline", metafile: true, packages: "external", throw: false,
|
|
229
|
+
}),
|
|
230
|
+
catch: (error) => new FactoryLoadFailed({ message: `The bundler failed: ${error instanceof Error ? error.message : String(error)}` }),
|
|
231
|
+
});
|
|
232
|
+
const inTree = (file: string): string | null => {
|
|
233
|
+
for (const base of [process.cwd(), tree]) {
|
|
234
|
+
const relative = path.relative(tree, path.resolve(base, file)).split(path.sep).join("/");
|
|
235
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative) && !relative.startsWith("node_modules/")) return relative;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
if (!result.success || result.outputs[0] === undefined)
|
|
240
|
+
return { bundle: null, imports: {}, findings: result.logs.map((log) => finding("bundle", log.message, { file: log.position ? inTree(log.position.file) : null, line: log.position?.line ?? null })) } satisfies Bundled;
|
|
241
|
+
const imports: Record<string, string[]> = {};
|
|
242
|
+
for (const [input, facts] of Object.entries(result.metafile?.inputs ?? {})) {
|
|
243
|
+
const from = inTree(input);
|
|
244
|
+
if (from !== null) imports[from] = facts.imports.flatMap((imported) => { const to = inTree(imported.path); return to === null ? [] : [to]; });
|
|
245
|
+
}
|
|
246
|
+
return { bundle: result.outputs[0].path, imports, findings: [] } satisfies Bundled;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** Run the evaluator on the bundle in its own process, with a runaway deadline. */
|
|
250
|
+
const evaluate = Effect.fn("factory.load.evaluate")(function* (input: Omit<EvaluateInput, "out">, cache: string, key: string) {
|
|
251
|
+
const folder = path.join(cache, "evaluations");
|
|
252
|
+
const inputFile = path.join(folder, `${key}.input.json`);
|
|
253
|
+
const out = path.join(folder, `${key}.output.json`);
|
|
254
|
+
yield* Effect.tryPromise({ try: () => mkdir(folder, { recursive: true }).then(() => writeFile(inputFile, JSON.stringify({ ...input, out }))), catch: failed("Could not write the evaluator's input.") });
|
|
255
|
+
const ran = yield* runCommand(process.execPath, [path.join(here, "evaluate.ts"), inputFile], { cwd: input.tree, guardMillis: EVALUATE_GUARD_MILLIS });
|
|
256
|
+
if (ran.timedOut)
|
|
257
|
+
return { program: null, findings: [finding("evaluate", `Evaluating the factory's code ran past its runaway guard of ${EVALUATE_GUARD_MILLIS / 1000} s and was stopped. Factory code declares; it never waits.`)] };
|
|
258
|
+
if (ran.code !== 0) return yield* new FactoryLoadFailed({ message: `The evaluator failed (${ran.code}): ${ran.stderr.slice(-1_000)}` });
|
|
259
|
+
const output = yield* Effect.tryPromise({ try: () => readFile(out, "utf8"), catch: failed("The evaluator wrote nothing.") }).pipe(
|
|
260
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(EvaluateOutputJson))),
|
|
261
|
+
Effect.mapError((error) => new FactoryLoadFailed({ message: `The evaluator's answer does not decode: ${error.message}` })),
|
|
262
|
+
);
|
|
263
|
+
return {
|
|
264
|
+
program: output.program,
|
|
265
|
+
findings: output.problems.map((problem) => finding(problem.step, problem.message, { node: problem.node, file: problem.at?.file ?? null, line: problem.at?.line ?? null })),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/** A finding in a file pinned to the node made nearest above it; in factory.ts, above every node, to the root. */
|
|
270
|
+
const pinned = (program: Program | null) => (found: Finding): Finding => {
|
|
271
|
+
if (found.node !== null || found.file === null || program === null) return found;
|
|
272
|
+
const made = (Object.values(program.nodes) as Node[]).filter((node) => node.at.file === found.file).sort((left, right) => left.at.line - right.at.line);
|
|
273
|
+
const above = found.line === null ? [] : made.filter((node) => node.at.line <= found.line!);
|
|
274
|
+
const node = above.at(-1) ?? (found.file === "factory.ts" ? program.nodes[program.root] : made.at(-1));
|
|
275
|
+
return node === undefined ? found : { ...found, node: node.id };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type Timed = { step: Load["steps"][number]["step"]; millis: number };
|
|
279
|
+
const timed = <A, E, R>(steps: Timed[], step: Timed["step"], effect: Effect.Effect<A, E, R>) => Effect.gen(function* () {
|
|
280
|
+
const started = performance.now();
|
|
281
|
+
const value = yield* effect;
|
|
282
|
+
steps.push({ step, millis: Math.round(performance.now() - started) });
|
|
283
|
+
return value;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/** Load a factory folder once. */
|
|
287
|
+
export const loadFactory = Effect.fn("factory.load")(function* (input: Readonly<{ tree: string; framework?: string }>) {
|
|
288
|
+
const tree = yield* Effect.tryPromise({ try: () => realpath(path.resolve(input.tree)), catch: failed(`There is no factory folder at ${input.tree}.`) });
|
|
289
|
+
const framework = input.framework === undefined ? (yield* versions()).framework
|
|
290
|
+
: yield* Schema.decodeUnknownEffect(FrameworkVersion)(input.framework).pipe(Effect.mapError(failed(`${input.framework} is not a framework version.`)));
|
|
291
|
+
const steps: Timed[] = [];
|
|
292
|
+
const snapshot = yield* timed(steps, "snapshot", Effect.gen(function* () {
|
|
293
|
+
const paths = yield* listTree(tree);
|
|
294
|
+
const digests: Record<string, string> = {};
|
|
295
|
+
for (const file of paths) digests[file] = sha256(yield* Effect.tryPromise({ try: () => readFile(path.join(tree, file)), catch: failed(`Could not read ${file}.`) }));
|
|
296
|
+
return { paths, digests, key: folderKey(framework, digests, sha256) };
|
|
297
|
+
}));
|
|
298
|
+
const installed = (yield* exists(path.join(tree, "node_modules", "@thinair", "factory", "package.json"))) && (yield* exists(path.join(tree, "node_modules", "effect", "package.json")));
|
|
299
|
+
const cache = path.join(tree, "node_modules", ".thinair-factory");
|
|
300
|
+
const cacheFile = path.join(cache, "loads", `${snapshot.key}.json`);
|
|
301
|
+
if (installed && (yield* exists(cacheFile))) {
|
|
302
|
+
const cached = yield* Effect.tryPromise({ try: () => readFile(cacheFile, "utf8"), catch: failed("Could not read the load cache.") }).pipe(
|
|
303
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(LoadJson))),
|
|
304
|
+
Effect.result,
|
|
305
|
+
);
|
|
306
|
+
if (Result.isSuccess(cached)) return { ...cached.success, cached: true, steps } satisfies Load;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const findings: Finding[] = [];
|
|
310
|
+
const manifest = snapshot.paths.includes("package.json") ? yield* readJson(path.join(tree, "package.json")) : {};
|
|
311
|
+
const dependencies = (manifest["dependencies"] ?? {}) as Readonly<Record<string, string>>;
|
|
312
|
+
yield* timed(steps, "shape", Effect.sync(() => {
|
|
313
|
+
for (const problem of appShapeProblems(snapshot.paths)) findings.push(finding("shape", problem.message, { file: problem.path }));
|
|
314
|
+
const pin = dependencies["@thinair/factory"];
|
|
315
|
+
if (pin !== undefined && /^\d+\.\d+\.\d+/u.test(pin) && compareVersions(pin, framework) > 0)
|
|
316
|
+
findings.push(finding("framework", `This factory needs @thinair/factory ${pin}; this loader is ${framework}. It loads when the newer framework runs here.`, { file: "package.json" }));
|
|
317
|
+
if (!installed) findings.push(finding("shape", "Its packages are not installed: run bun install in the factory folder.", { file: "package.json" }));
|
|
318
|
+
}));
|
|
319
|
+
if (!installed || !snapshot.paths.includes("factory.ts"))
|
|
320
|
+
return { version: LOAD_VERSION, framework, key: snapshot.key, state: "held", program: null, holes: [], findings, cached: false, steps } satisfies Load;
|
|
321
|
+
|
|
322
|
+
yield* Effect.tryPromise({ try: () => mkdir(cache, { recursive: true }), catch: failed(`Could not make the loader's cache at ${cache}.`) });
|
|
323
|
+
const packages = Object.keys(dependencies).filter((name) => name !== "@thinair/factory");
|
|
324
|
+
findings.push(...yield* timed(steps, "lint", lint(tree, snapshot.paths, packages, cache)));
|
|
325
|
+
const [bundled, types] = yield* Effect.all([timed(steps, "bundle", bundle(tree, snapshot.key, cache)), timed(steps, "type", typecheck(tree))], { concurrency: 2 });
|
|
326
|
+
findings.push(...bundled.findings, ...types);
|
|
327
|
+
|
|
328
|
+
let program: Program | null = null;
|
|
329
|
+
if (bundled.bundle !== null) {
|
|
330
|
+
const files = Object.fromEntries(snapshot.paths.filter((file) => file.endsWith(".ts")).map((file) => [file, { digest: snapshot.digests[file]!, imports: bundled.imports[file] ?? [] }]));
|
|
331
|
+
const evaluated = yield* timed(steps, "evaluate", evaluate({ tree, bundle: bundled.bundle, framework, files }, cache, snapshot.key));
|
|
332
|
+
program = evaluated.program;
|
|
333
|
+
findings.push(...evaluated.findings);
|
|
334
|
+
yield* timed(steps, "value", Effect.sync(() => {
|
|
335
|
+
for (const member of memberFiles(snapshot.paths))
|
|
336
|
+
if (member !== "factory.ts" && bundled.imports[member] === undefined)
|
|
337
|
+
findings.push(finding("value", `${member} is not reachable from factory.ts: list what it makes there, or delete it.`, { file: member }));
|
|
338
|
+
for (const node of Object.values(program?.nodes ?? {}) as Node[])
|
|
339
|
+
if (node._tag === "Agent" && !snapshot.paths.includes(node.instructions))
|
|
340
|
+
findings.push(finding("value", `Its instructions, ${node.instructions}, are not in the factory folder.`, { node: node.id, file: node.at.file, line: node.at.line }));
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const load: Load = yield* timed(steps, "encode", Effect.sync((): Load => {
|
|
345
|
+
const all = findings.map(pinned(program));
|
|
346
|
+
return { version: LOAD_VERSION, framework, key: snapshot.key, state: all.length === 0 ? "ready" : "held", program, holes: program === null ? [] : holesOf(program), findings: all, cached: false, steps };
|
|
347
|
+
}));
|
|
348
|
+
const encoded = yield* Schema.encodeEffect(LoadJson)(load).pipe(Effect.mapError((error) => new FactoryLoadFailed({ message: `The load does not encode: ${error.message}` })));
|
|
349
|
+
yield* Effect.tryPromise({ try: () => mkdir(path.dirname(cacheFile), { recursive: true }).then(() => writeFile(cacheFile, JSON.stringify(encoded))), catch: failed("Could not write the load cache.") });
|
|
350
|
+
return load;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
/** Write the blank factory template into an empty or new folder. */
|
|
354
|
+
export const initFactory = Effect.fn("factory.init")(function* (input: Readonly<{ directory: string; framework?: string }>) {
|
|
355
|
+
const directory = path.resolve(input.directory);
|
|
356
|
+
const present = yield* Effect.tryPromise({ try: () => readdir(directory), catch: () => null }).pipe(Effect.orElseSucceed(() => null));
|
|
357
|
+
if (present !== null && present.length > 0) return yield* new FactoryLoadFailed({ message: `${directory} is not empty: init writes a new factory folder.` });
|
|
358
|
+
const pins = yield* versions();
|
|
359
|
+
const files = templateFiles({ framework: input.framework ?? pins.framework, effect: pins.effect });
|
|
360
|
+
for (const [file, text] of Object.entries(files))
|
|
361
|
+
yield* Effect.tryPromise({ try: () => mkdir(path.dirname(path.join(directory, file)), { recursive: true }).then(() => writeFile(path.join(directory, file), text)), catch: failed(`Could not write ${file}.`) });
|
|
362
|
+
return { directory, files: Object.keys(files).sort() };
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const branchOf = Effect.fn("factory.dev.branch")(function* (tree: string) {
|
|
366
|
+
const ran = yield* runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: tree, guardMillis: 15_000 });
|
|
367
|
+
return ran.code === 0 && ran.stdout.trim() !== "" ? ran.stdout.trim() : "working-copy";
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
/** The commit the working copy sits on; its load may hold changes not committed yet. */
|
|
371
|
+
const commitOf = Effect.fn("factory.dev.commit")(function* (tree: string) {
|
|
372
|
+
const ran = yield* runCommand("git", ["rev-parse", "HEAD"], { cwd: tree, guardMillis: 15_000 });
|
|
373
|
+
return ran.code === 0 && /^[0-9a-f]{40}$/u.test(ran.stdout.trim()) ? ran.stdout.trim() : null;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const summary = (load: Load) => [
|
|
377
|
+
`${load.state} ${load.key.slice(0, 12)}${load.cached ? " (cached)" : ""}: ${Object.keys(load.program?.nodes ?? {}).length} nodes, ${load.holes.length} holes, ${load.findings.length} findings`,
|
|
378
|
+
...load.findings.map((found) => ` ${found.node ?? "-"} ${found.file ?? ""}${found.line === null ? "" : `:${found.line}`} ${found.step}: ${found.message}`),
|
|
379
|
+
].join("\n");
|
|
380
|
+
|
|
381
|
+
/** The folder's files with their sizes and modification times: it changes when a save lands. */
|
|
382
|
+
const signatureOf = (tree: string) => listTree(tree).pipe(Effect.flatMap((paths) => Effect.forEach(paths, (file) =>
|
|
383
|
+
Effect.tryPromise({ try: () => stat(path.join(tree, file)), catch: failed(`Could not read ${file}.`) }).pipe(
|
|
384
|
+
Effect.map((facts) => `${file} ${facts.size} ${facts.mtimeMs}`),
|
|
385
|
+
Effect.orElseSucceed(() => `${file} gone`),
|
|
386
|
+
))), Effect.map((lines) => lines.join("\n")));
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Load on every save, and post each load as the branch's preview. It polls
|
|
390
|
+
* the folder rather than asking for file events: VM shared folders and some
|
|
391
|
+
* hosts never deliver them, and a factory folder is a few dozen files. A
|
|
392
|
+
* post that fails is said on stderr and the watch goes on: the next save
|
|
393
|
+
* posts again.
|
|
394
|
+
*/
|
|
395
|
+
export const watchFactory = Effect.fn("factory.dev")(function* (input: Readonly<{ tree: string; factory: string }>) {
|
|
396
|
+
const posts = yield* LoadPosts;
|
|
397
|
+
const tree = path.resolve(input.tree);
|
|
398
|
+
const loadAndPost = Effect.gen(function* () {
|
|
399
|
+
const load = yield* loadFactory({ tree });
|
|
400
|
+
const ref = yield* branchOf(tree);
|
|
401
|
+
process.stderr.write(`${summary(load)}\n`);
|
|
402
|
+
const started = performance.now();
|
|
403
|
+
const posted = yield* posts.post({ factory: input.factory, ref, commit: yield* commitOf(tree), load }).pipe(Effect.result);
|
|
404
|
+
process.stderr.write(posted._tag === "Success"
|
|
405
|
+
? `posted ${ref}: ${posted.success.state}, drawing ${posted.success.revision?.slice(0, 12) ?? "nothing yet"} (${Math.round(performance.now() - started)} ms)\n`
|
|
406
|
+
: `not posted: ${posted.failure.message}\n`);
|
|
407
|
+
});
|
|
408
|
+
let seen = yield* signatureOf(tree);
|
|
409
|
+
yield* loadAndPost;
|
|
410
|
+
return yield* Effect.forever(Effect.gen(function* () {
|
|
411
|
+
yield* Effect.sleep(POLL_MILLIS);
|
|
412
|
+
const now = yield* signatureOf(tree);
|
|
413
|
+
if (now === seen) return;
|
|
414
|
+
yield* Effect.sleep(SETTLE_MILLIS);
|
|
415
|
+
seen = yield* signatureOf(tree);
|
|
416
|
+
yield* loadAndPost;
|
|
417
|
+
}));
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const USAGE = `thinair-factory: the factory loader
|
|
421
|
+
init [dir] [--framework <spec>] write the blank factory template (dir: factory)
|
|
422
|
+
load [tree] [--framework <x.y.z>] load once and print the Load as JSON (tree: .)
|
|
423
|
+
dev --factory <id> [--tree <dir>] load on every save and post each load
|
|
424
|
+
`;
|
|
425
|
+
|
|
426
|
+
const flag = (args: ReadonlyArray<string>, name: string) => {
|
|
427
|
+
const index = args.indexOf(name);
|
|
428
|
+
return index === -1 ? undefined : args[index + 1];
|
|
429
|
+
};
|
|
430
|
+
const positional = (args: ReadonlyArray<string>) => args.filter((arg, index) => !arg.startsWith("--") && !(args[index - 1] ?? "").startsWith("--"));
|
|
431
|
+
|
|
432
|
+
const main = Effect.fn("factory.cli")(function* (argv: ReadonlyArray<string>) {
|
|
433
|
+
const [command, ...args] = argv;
|
|
434
|
+
if (command === "init") {
|
|
435
|
+
const framework = flag(args, "--framework");
|
|
436
|
+
const made = yield* initFactory({ directory: positional(args)[0] ?? "factory", ...(framework === undefined ? {} : { framework }) });
|
|
437
|
+
process.stderr.write(`Wrote ${made.files.join(", ")} to ${made.directory}. Next: bun install there, then load.\n`);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
if (command === "load") {
|
|
441
|
+
const framework = flag(args, "--framework");
|
|
442
|
+
const load = yield* loadFactory({ tree: positional(args)[0] ?? ".", ...(framework === undefined ? {} : { framework }) });
|
|
443
|
+
process.stdout.write(`${JSON.stringify(Schema.encodeSync(LoadJson)(load))}\n`);
|
|
444
|
+
process.stderr.write(`${summary(load)}\n`);
|
|
445
|
+
return load.state === "ready" ? 0 : 1;
|
|
446
|
+
}
|
|
447
|
+
if (command === "dev") {
|
|
448
|
+
const factory = flag(args, "--factory");
|
|
449
|
+
if (factory === undefined) return yield* new FactoryLoadFailed({ message: `dev needs --factory <id>.\n${USAGE}` });
|
|
450
|
+
return yield* watchFactory({ tree: flag(args, "--tree") ?? ".", factory }).pipe(Effect.provide(LoadPostsThroughThinair));
|
|
451
|
+
}
|
|
452
|
+
process.stderr.write(USAGE);
|
|
453
|
+
return command === undefined || command === "--help" ? 0 : 2;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
if (import.meta.main) {
|
|
457
|
+
const exit = await Effect.runPromiseExit(main(process.argv.slice(2)));
|
|
458
|
+
if (exit._tag === "Success") process.exitCode = exit.value;
|
|
459
|
+
else {
|
|
460
|
+
process.stderr.write(`${String(exit.cause)}\n`);
|
|
461
|
+
process.exitCode = 2;
|
|
462
|
+
}
|
|
463
|
+
}
|
package/cli/template.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What `init` writes: a blank factory. Every slot of the root is a hole by
|
|
3
|
+
* the tag it expects, so the floor draws a blank floor of dashed amber
|
|
4
|
+
* parts. Filling a hole is one change in place: write the member file, and
|
|
5
|
+
* list it in factory.ts where its hole was.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TemplatePins = Readonly<{ framework: string; effect: string }>;
|
|
9
|
+
|
|
10
|
+
const factoryTs = `import { defineFactory } from "@thinair/factory/domain/system-elements/factory";
|
|
11
|
+
import { hole } from "@thinair/factory/domain/system-elements/hole";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* This factory's root: what it lists runs and shows; nothing else does.
|
|
15
|
+
* Each hole is a part still to build. Fill one by writing its member file
|
|
16
|
+
* (repositories/, intake/, stages/, jobs/, agents/) with one default export,
|
|
17
|
+
* and listing it here where its hole was.
|
|
18
|
+
*/
|
|
19
|
+
export default defineFactory({
|
|
20
|
+
repositories: [hole("Repository", "main")],
|
|
21
|
+
rules: [hole("Rule", "intake")],
|
|
22
|
+
stages: [hole("Stage", "change")],
|
|
23
|
+
fleet: [hole("Pool", "macs")],
|
|
24
|
+
agents: [hole("Agent", "coder")],
|
|
25
|
+
});
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const tsconfig = {
|
|
29
|
+
compilerOptions: {
|
|
30
|
+
target: "ES2022",
|
|
31
|
+
module: "ESNext",
|
|
32
|
+
moduleResolution: "Bundler",
|
|
33
|
+
moduleDetection: "force",
|
|
34
|
+
lib: ["ES2022"],
|
|
35
|
+
types: [],
|
|
36
|
+
strict: true,
|
|
37
|
+
noUncheckedIndexedAccess: true,
|
|
38
|
+
isolatedModules: true,
|
|
39
|
+
skipLibCheck: true,
|
|
40
|
+
noEmit: true,
|
|
41
|
+
},
|
|
42
|
+
include: ["**/*.ts"],
|
|
43
|
+
exclude: ["node_modules"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const readme = `# factory
|
|
47
|
+
|
|
48
|
+
This folder is the code of a Thinair factory. It declares what should be
|
|
49
|
+
true: repositories, stages of work, jobs, agents, the fleet and standing
|
|
50
|
+
goals. \`@thinair/factory\` compiles it to one program; the factory's
|
|
51
|
+
\`.factory\` File runs it.
|
|
52
|
+
|
|
53
|
+
- \`bunx @thinair/factory load .\` checks it and prints the program.
|
|
54
|
+
- \`bunx @thinair/factory dev --factory <id>\` reloads it on every save.
|
|
55
|
+
|
|
56
|
+
Factory code imports only the authoring modules of \`@thinair/factory\` and
|
|
57
|
+
its own npm packages. Steps are the only imperative code, and outward writes
|
|
58
|
+
are acts.
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
/** The template's files, by path relative to the factory folder. */
|
|
62
|
+
export const templateFiles = (pins: TemplatePins): Readonly<Record<string, string>> => ({
|
|
63
|
+
"factory.ts": factoryTs,
|
|
64
|
+
"package.json": `${JSON.stringify({
|
|
65
|
+
name: "factory",
|
|
66
|
+
private: true,
|
|
67
|
+
type: "module",
|
|
68
|
+
dependencies: { "@thinair/factory": pins.framework, effect: pins.effect },
|
|
69
|
+
}, null, 2)}\n`,
|
|
70
|
+
"tsconfig.json": `${JSON.stringify(tsconfig, null, 2)}\n`,
|
|
71
|
+
"README.md": readme,
|
|
72
|
+
".gitignore": "node_modules\n",
|
|
73
|
+
});
|
package/domain/load.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { NodeId, NodeTag } from "./system-elements/node";
|
|
3
|
+
import { FrameworkVersion, Program } from "./system-elements/program";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* What one load of a factory's code gives: the program, its state, and its
|
|
7
|
+
* findings, each pinned to a node, a file and a line where it knows them.
|
|
8
|
+
* The loader prints it; `factory.load.post` carries it to the plane, which
|
|
9
|
+
* keeps one load row per (factory, ref): the live one for the followed ref,
|
|
10
|
+
* a preview per branch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LOAD_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/** Which step found it: the folder's shape, the factory-code lint, the type check, the bundle, evaluation, or the value checks. */
|
|
16
|
+
export const FindingStep = Schema.Literals(["shape", "lint", "type", "bundle", "evaluate", "value", "framework"]);
|
|
17
|
+
export type FindingStep = typeof FindingStep.Type;
|
|
18
|
+
|
|
19
|
+
export const Finding = Schema.Struct({
|
|
20
|
+
step: FindingStep,
|
|
21
|
+
node: Schema.NullOr(NodeId),
|
|
22
|
+
file: Schema.NullOr(Schema.String),
|
|
23
|
+
line: Schema.NullOr(Schema.Int),
|
|
24
|
+
message: Schema.String,
|
|
25
|
+
});
|
|
26
|
+
export type Finding = typeof Finding.Type;
|
|
27
|
+
|
|
28
|
+
/** `ready` runs (live) or draws (preview). `held` keeps the last good revision: its findings say why. */
|
|
29
|
+
export const LoadState = Schema.Literals(["ready", "held"]);
|
|
30
|
+
export type LoadState = typeof LoadState.Type;
|
|
31
|
+
|
|
32
|
+
export const LoadStep = Schema.Literals(["snapshot", "shape", "lint", "bundle", "type", "evaluate", "value", "encode"]);
|
|
33
|
+
|
|
34
|
+
export const Load = Schema.Struct({
|
|
35
|
+
version: Schema.Literal(LOAD_VERSION),
|
|
36
|
+
/** The `@thinair/factory` version that loaded it. */
|
|
37
|
+
framework: FrameworkVersion,
|
|
38
|
+
/** The content key of the folder: the same files under the same framework give the same key. */
|
|
39
|
+
key: Schema.String,
|
|
40
|
+
state: LoadState,
|
|
41
|
+
/** The program, when its code evaluated, even while held. */
|
|
42
|
+
program: Schema.NullOr(Program),
|
|
43
|
+
/** The holes still to build, by the tag each expects. */
|
|
44
|
+
holes: Schema.Array(Schema.Struct({ expects: NodeTag, id: NodeId })),
|
|
45
|
+
findings: Schema.Array(Finding),
|
|
46
|
+
/** This load came from the cache of an earlier load with the same key. */
|
|
47
|
+
cached: Schema.Boolean,
|
|
48
|
+
/** How long each step took, for the record; never a pass condition. */
|
|
49
|
+
steps: Schema.Array(Schema.Struct({ step: LoadStep, millis: Schema.Number })),
|
|
50
|
+
});
|
|
51
|
+
export type Load = typeof Load.Type;
|
|
52
|
+
export const LoadJson = Schema.toCodecJson(Load);
|
|
53
|
+
|
|
54
|
+
/** A git ref or a commit, as a load names it: one line, no spaces. */
|
|
55
|
+
export const RefName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(255), Schema.isPattern(/^[^\s]+$/u));
|
|
56
|
+
|
|
57
|
+
/** What `factory.load.post` takes: a load of one ref of one factory, posted as the agent. */
|
|
58
|
+
export const LoadPost = Schema.Struct({
|
|
59
|
+
/** The `.factory` File's id. */
|
|
60
|
+
factory: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)),
|
|
61
|
+
/** The branch the working copy is on. */
|
|
62
|
+
ref: RefName,
|
|
63
|
+
/** The commit the working copy sat on when it loaded, when it knows; the load may hold changes not committed yet. */
|
|
64
|
+
commit: Schema.optionalKey(Schema.NullOr(RefName)),
|
|
65
|
+
load: Load,
|
|
66
|
+
});
|
|
67
|
+
export type LoadPost = typeof LoadPost.Type;
|