@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.
@@ -0,0 +1,189 @@
1
+ import { Schema } from "effect";
2
+
3
+ /*
4
+ * What every node of a factory program shares: its tag, its id, where it was
5
+ * made, its digest, and typed references to other nodes. A program is a
6
+ * graph: each node is stored once under its id, and every edge is a Ref.
7
+ *
8
+ * Authored values are what factory code builds with the constructors
9
+ * (`stage(...)`, `job(...)`, `hole(...)`). Each carries a head under one
10
+ * well-known symbol, so the encoder reads it the same from any copy of this
11
+ * package. Encoding turns them into nodes and their links into Refs.
12
+ *
13
+ * This file and every file beside it import only `effect`: factory code
14
+ * imports them, and they are published as `@thinair/factory`.
15
+ */
16
+
17
+ export const TAGS = ["Factory", "Repository", "Source", "Rule", "Stage", "Job", "Service", "Pool", "Agent", "Goal", "Hole"] as const;
18
+ export const Tag = Schema.Literals(TAGS);
19
+ export type Tag = typeof Tag.Type;
20
+
21
+ /** The tags a Ref points at. A Hole stands in for one of them until it is built. */
22
+ export const NODE_TAGS = ["Factory", "Repository", "Source", "Rule", "Stage", "Job", "Service", "Pool", "Agent", "Goal"] as const;
23
+ export const NodeTag = Schema.Literals(NODE_TAGS);
24
+ export type NodeTag = typeof NodeTag.Type;
25
+
26
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
27
+ /** Proof: the Ref tags are every tag but Hole. */
28
+ export const nodeTagsAreTagsWithoutHole: Same<NodeTag, Exclude<Tag, "Hole">> = true;
29
+
30
+ const NAME = "[a-z0-9]+(?:[.-][a-z0-9]+)*";
31
+
32
+ /** A name an author gives a node: lowercase words and digits joined by dashes or dots. */
33
+ export const Name = Schema.String.check(Schema.isPattern(new RegExp(`^${NAME}$`, "u")), Schema.isMaxLength(64));
34
+
35
+ /** `<lowercase tag>:<name>`, as `stage:change`. A Hole keeps the id of the node it stands for. */
36
+ export const NodeId = Schema.String.check(
37
+ Schema.isPattern(new RegExp(`^(?:${NODE_TAGS.map((tag) => tag.toLowerCase()).join("|")}):${NAME}$`, "u")),
38
+ Schema.isMaxLength(80),
39
+ ).pipe(Schema.brand("NodeId"));
40
+ export type NodeId = typeof NodeId.Type;
41
+
42
+ export const nodeId = (tag: NodeTag, name: string): NodeId => `${tag.toLowerCase()}:${name}` as NodeId;
43
+
44
+ /** The root's name: a factory has no name in code; its File names it. */
45
+ export const ROOT_NAME = "root";
46
+ export const ROOT_ID = nodeId("Factory", ROOT_NAME);
47
+
48
+ /** Where a node was made: a file relative to the factory folder, and a line. */
49
+ export const SourceAt = Schema.Struct({
50
+ file: Schema.String.check(Schema.isMinLength(1)),
51
+ line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
52
+ });
53
+ export type SourceAt = typeof SourceAt.Type;
54
+
55
+ /** A SHA-256, in lowercase hex. */
56
+ export const Digest = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/u)).pipe(Schema.brand("Digest"));
57
+ export type Digest = typeof Digest.Type;
58
+
59
+ /** A reference to a node of tag T, or to a Hole that expects one. */
60
+ export const Ref = <const T extends NodeTag>(to: T) => Schema.TaggedStruct("Ref", { to: Schema.Literal(to), id: NodeId });
61
+ export interface Ref<T extends NodeTag> {
62
+ readonly _tag: "Ref";
63
+ readonly to: T;
64
+ readonly id: NodeId;
65
+ }
66
+ export const AnyRef = Schema.TaggedStruct("Ref", { to: NodeTag, id: NodeId });
67
+
68
+ /** The fields every node has besides its tag. The digest covers what it is, not where it was made. */
69
+ export const head = { id: NodeId, at: SourceAt, digest: Digest } as const;
70
+
71
+ // Authored values.
72
+
73
+ /** The key of an authored value's head. `Symbol.for`, so every copy of this package reads it. */
74
+ export const AUTHORED: unique symbol = Symbol.for("@thinair/factory/authored");
75
+
76
+ /** Where an authored value was made, as the runtime saw it: an absolute file. */
77
+ export type MadeAt = Readonly<{ file: string; line: number }>;
78
+
79
+ export type Head<T extends Tag, N extends string = string> = Readonly<{
80
+ tag: T;
81
+ name: N;
82
+ at: MadeAt | null;
83
+ /** It holds code (compose, a step, a route): its digest covers the import closure of its file. */
84
+ code: boolean;
85
+ }>;
86
+
87
+ export interface Authored<T extends Tag, N extends string = string> {
88
+ readonly [AUTHORED]: Head<T, N>;
89
+ }
90
+
91
+ /** A Hole planned where a node of tag T will go. */
92
+ export interface HoleFor<T extends NodeTag, N extends string = string> {
93
+ readonly [AUTHORED]: Head<"Hole", N> & Readonly<{ expects: T }>;
94
+ }
95
+
96
+ /** What a place for a node of tag T accepts: the node, or a Hole planned in its place. */
97
+ export type Slot<T extends NodeTag> = Authored<T> | HoleFor<T>;
98
+
99
+ /** A kind of fact a repository or a source posts, with its value's schema. Rules listen on it. */
100
+ export interface FactKind<A> {
101
+ readonly from: Authored<"Repository"> | Authored<"Source">;
102
+ /** camelCase, unique in its repository or source: `pullRequest`. */
103
+ readonly kind: string;
104
+ readonly schema: Schema.Codec<A, any, never, never>;
105
+ }
106
+ export const FactKindName = Schema.String.check(Schema.isPattern(/^[a-z][A-Za-z0-9]{0,63}$/u));
107
+
108
+ export const isAuthored = (value: unknown): value is Authored<Tag> =>
109
+ typeof value === "object" && value !== null && AUTHORED in value;
110
+
111
+ export const headOf = (value: Authored<Tag>): Head<Tag> & Readonly<{ expects?: NodeTag }> => value[AUTHORED];
112
+
113
+ /** The id an authored value encodes to: a Hole's is the id of the node it stands for. */
114
+ export const idOf = (value: Authored<Tag>): NodeId => {
115
+ const made = headOf(value);
116
+ return nodeId(made.tag === "Hole" ? (made.expects ?? "Factory") : made.tag, made.name);
117
+ };
118
+
119
+ /** A Ref to an authored node, without encoding it. */
120
+ export const refTo = <T extends NodeTag>(to: T, value: Slot<T>): Ref<T> => ({ _tag: "Ref", to, id: idOf(value) });
121
+
122
+ /** The head of a new authored value, with where its constructor was called from. */
123
+ export const authoredHead = <T extends Tag, N extends string>(tag: T, name: N, code: boolean): Head<T, N> =>
124
+ ({ tag, name, at: madeAt(), code });
125
+
126
+ /** `at /abs/file.ts:12:3`, `at fn (/abs/file.ts:12:3)` or `at file:///abs/file.ts:12:3`. */
127
+ const FRAME = /\(?((?:file:\/\/)?\/[^():]+|[A-Za-z]:[\\/][^():]+):(\d+):\d+\)?\s*$/u;
128
+
129
+ const directoryOf = (file: string) => file.slice(0, Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")));
130
+
131
+ /**
132
+ * The first caller outside this folder: the factory file whose constructor
133
+ * call made the value. Every constructor lives beside this file, so frames
134
+ * in this folder are the framework's own.
135
+ */
136
+ const madeAt = (): MadeAt | null => {
137
+ const frames = (new Error().stack ?? "").split("\n").slice(1).flatMap((line): MadeAt[] => {
138
+ const match = FRAME.exec(line);
139
+ if (match === null) return [];
140
+ const raw = match[1]!;
141
+ const file = raw.startsWith("file://") ? decodeURIComponent(raw.slice("file://".length)) : raw;
142
+ return [{ file, line: Number(match[2]) }];
143
+ });
144
+ const own = frames[0];
145
+ if (own === undefined) return null;
146
+ const framework = directoryOf(own.file);
147
+ return frames.find((frame) => directoryOf(frame.file) !== framework) ?? null;
148
+ };
149
+
150
+ // Engine meanings.
151
+
152
+ /** Which engine gives a tag its meaning: the plane, the factory VM, or neither. */
153
+ export type Owner = "plane" | "vm" | "none";
154
+
155
+ /** What a tag's make() may ask while it encodes one authored value. */
156
+ export interface Encoder {
157
+ /** The Ref to a node it holds, encoding that node once. */
158
+ readonly ref: <T extends NodeTag>(to: T, value: Slot<T>) => Ref<T>;
159
+ /** A problem with this node's settings, pinned to it. */
160
+ readonly problem: (message: string) => void;
161
+ }
162
+
163
+ /** The rows a floor reads, by the node each belongs to. The records name their nodes by id. */
164
+ export const LiveRows = Schema.Struct({
165
+ tickets: Schema.Array(Schema.Struct({ id: Schema.String, stage: NodeId, goal: Schema.NullOr(NodeId), state: Schema.String })),
166
+ jobs: Schema.Array(Schema.Struct({
167
+ id: Schema.String,
168
+ node: NodeId,
169
+ state: Schema.Literals(["waiting", "bound", "running", "succeeded", "failed", "stopped"]),
170
+ machine: Schema.NullOr(Schema.String),
171
+ })),
172
+ machines: Schema.Array(Schema.Struct({ pool: NodeId, machine: Schema.String, ready: Schema.Boolean })),
173
+ facts: Schema.Array(Schema.Struct({ from: NodeId, kind: Schema.String, key: Schema.String, at: Schema.String })),
174
+ });
175
+ export type LiveRows = typeof LiveRows.Type;
176
+
177
+ /** A node's settings: everything make() decides, without its tag, id, place or digest. */
178
+ export type SettingsOf<N> = N extends unknown ? Omit<N, "_tag" | "id" | "at" | "digest"> : never;
179
+
180
+ /**
181
+ * One tag's definition: its node's schema, how an authored value becomes its
182
+ * settings, its live read (a data-element), and the engine that owns it.
183
+ */
184
+ export type Definition<T extends Tag> = Readonly<{
185
+ Node: Readonly<{ Type: Readonly<{ _tag: T }> }>;
186
+ make: (authored: never, encoder: Encoder) => object;
187
+ live: (node: never, rows: LiveRows) => unknown;
188
+ owner: Owner;
189
+ }>;
@@ -0,0 +1,63 @@
1
+ import { Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, type Authored, type LiveRows } from "./node";
3
+
4
+ /*
5
+ * A pool names a provider and an optional narrowing, nothing else.
6
+ * `pool.macs()` means every Mac that granted this factory `work`. Room, os,
7
+ * arch, VMs, identities and reserve come only from each machine's facts, so
8
+ * two factories never give placement two truths about one machine. The
9
+ * plane gives pools their meaning: its executors.
10
+ */
11
+
12
+ export const PROVIDERS = ["macs", "thinair-cloud", "namespace", "github-actions"] as const;
13
+ export const Provider = Schema.Literals(PROVIDERS);
14
+ export type Provider = typeof Provider.Type;
15
+
16
+ export const Os = Schema.Literals(["mac", "linux", "windows"]);
17
+ export type Os = typeof Os.Type;
18
+ export const Arch = Schema.Literals(["arm64", "x64"]);
19
+ export type Arch = typeof Arch.Type;
20
+
21
+ /** An empty list means any. */
22
+ export const Narrowing = Schema.Struct({ os: Schema.Array(Os), arch: Schema.Array(Arch) });
23
+ export type Narrowing = typeof Narrowing.Type;
24
+
25
+ export const Node = Schema.TaggedStruct("Pool", { ...head, provider: Provider, ...Narrowing.fields });
26
+ export type Node = typeof Node.Type;
27
+
28
+ export interface AuthoredPool<P extends Provider = Provider> extends Authored<"Pool", P> {
29
+ readonly provider: P;
30
+ readonly os: ReadonlyArray<Os>;
31
+ readonly arch: ReadonlyArray<Arch>;
32
+ }
33
+
34
+ type Narrow = Readonly<{ os?: ReadonlyArray<Os>; arch?: ReadonlyArray<Arch> }>;
35
+
36
+ const made = <const P extends Provider>(provider: P, narrow: Narrow | undefined): AuthoredPool<P> => ({
37
+ [AUTHORED]: authoredHead("Pool", provider, false),
38
+ provider,
39
+ os: narrow?.os ?? [],
40
+ arch: narrow?.arch ?? [],
41
+ });
42
+
43
+ /** The pools a factory can name. Each is named after its provider: `pool:macs`. */
44
+ export const pool = {
45
+ /** Every Mac that granted this factory `work`. */
46
+ macs: (narrow?: Narrow) => made("macs", narrow),
47
+ /** Thinair Cloud VMs, on Thinair Computer Inc's account. */
48
+ thinairCloud: (narrow?: Narrow) => made("thinair-cloud", narrow),
49
+ /** Namespace instances, with the Profile's key. */
50
+ namespace: (narrow?: Narrow) => made("namespace", narrow),
51
+ /** GitHub Actions runners, dispatched with the Agents App. */
52
+ githubActions: (narrow?: Narrow) => made("github-actions", narrow),
53
+ };
54
+
55
+ export const make = (authored: AuthoredPool) => ({ provider: authored.provider, os: [...authored.os], arch: [...authored.arch] });
56
+
57
+ /** The machines placement can use now, from their own facts. */
58
+ export const live = (node: Node, rows: LiveRows) => {
59
+ const machines = rows.machines.filter((machine) => machine.pool === node.id);
60
+ return { machines: machines.map(({ machine, ready }) => ({ machine, ready })), ready: machines.filter((machine) => machine.ready).length };
61
+ };
62
+
63
+ export const owner = "plane" as const;
@@ -0,0 +1,123 @@
1
+ import { Schema } from "effect";
2
+ import * as agent from "./agent";
3
+ import * as factory from "./factory";
4
+ import * as goal from "./goal";
5
+ import * as hole from "./hole";
6
+ import * as job from "./job";
7
+ import { NodeId, ROOT_ID, type NodeTag, type Ref } from "./node";
8
+ import * as pool from "./pool";
9
+ import * as repository from "./repository";
10
+ import * as rule from "./rule";
11
+ import * as service from "./service";
12
+ import * as source from "./source";
13
+ import * as stage from "./stage";
14
+
15
+ /*
16
+ * The language: a factory's code compiles to one Program. It is pure data,
17
+ * versioned, and decoded at every boundary: the loader encodes it, the plane
18
+ * stores it by revision, the factory VM runs it and the Frame draws it.
19
+ *
20
+ * It is a graph, not a tree. Stages share jobs; rules, agents and stages
21
+ * point at each other. Every node is stored once under its id, and every
22
+ * edge is a typed Ref. A node's digest covers what it is (its settings, and
23
+ * the import closure of its file when it holds code), never the nodes it
24
+ * refers to: editing a job redraws that job, not every stage that holds it.
25
+ *
26
+ * `version` is the language. A break bumps it, with a decode migration.
27
+ */
28
+
29
+ export const LANGUAGE_VERSION = 1;
30
+
31
+ export const Node = Schema.Union([
32
+ factory.Node, repository.Node, source.Node, rule.Node, stage.Node, job.Node, service.Node, pool.Node, agent.Node, goal.Node, hole.Node,
33
+ ]);
34
+ export type Node = typeof Node.Type;
35
+
36
+ /** A `@thinair/factory` version: plain semver, with an optional prerelease. */
37
+ export const FrameworkVersion = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u));
38
+
39
+ export const Program = Schema.Struct({
40
+ version: Schema.Literal(LANGUAGE_VERSION),
41
+ /** The `@thinair/factory` version the code was loaded with. */
42
+ framework: FrameworkVersion,
43
+ /** The Factory node. */
44
+ root: NodeId,
45
+ /** Every node exactly once. Edges are Refs inside settings. */
46
+ nodes: Schema.Record(NodeId, Node),
47
+ });
48
+ export type Program = typeof Program.Type;
49
+
50
+ /** The program as JSON: the form every boundary carries. */
51
+ export const ProgramJson = Schema.toCodecJson(Program);
52
+
53
+ /** Every Ref inside a node's settings, in order: the floor's belts. */
54
+ export const edgesOf = (node: Node): ReadonlyArray<Ref<NodeTag>> => {
55
+ const edges: Array<Ref<NodeTag>> = [];
56
+ const walk = (value: unknown): void => {
57
+ if (Array.isArray(value)) {
58
+ for (const item of value) walk(item);
59
+ } else if (typeof value === "object" && value !== null) {
60
+ const record = value as Readonly<Record<string, unknown>>;
61
+ if (record["_tag"] === "Ref" && typeof record["id"] === "string" && typeof record["to"] === "string") edges.push(record as unknown as Ref<NodeTag>);
62
+ else for (const [key, item] of Object.entries(record)) if (key !== "at") walk(item);
63
+ }
64
+ };
65
+ for (const [key, value] of Object.entries(node)) if (key !== "at" && key !== "id" && key !== "digest") walk(value);
66
+ return edges;
67
+ };
68
+
69
+ /** A problem with the graph itself, pinned to a node. */
70
+ export type GraphProblem = Readonly<{ node: NodeId; message: string }>;
71
+
72
+ /**
73
+ * The value checks every program passes before it runs: the root is a
74
+ * Factory, every node sits under its own id, and every Ref resolves to a
75
+ * node of its tag or a Hole that expects one.
76
+ */
77
+ export const graphProblems = (program: Program): ReadonlyArray<GraphProblem> => {
78
+ const problems: GraphProblem[] = [];
79
+ const root = program.nodes[program.root];
80
+ if (root === undefined || root._tag !== "Factory" || program.root !== ROOT_ID)
81
+ problems.push({ node: program.root, message: `The root must be the factory itself (${ROOT_ID}), made by defineFactory in factory.ts.` });
82
+ for (const [id, node] of Object.entries(program.nodes) as Array<[NodeId, Node]>) {
83
+ if (node.id !== id) problems.push({ node: id, message: `The node under ${id} names itself ${node.id}.` });
84
+ for (const edge of edgesOf(node)) {
85
+ const target = program.nodes[edge.id];
86
+ if (target === undefined) problems.push({ node: id, message: `It refers to ${edge.id}, which the program does not hold.` });
87
+ else if (target._tag !== edge.to && !(target._tag === "Hole" && target.expects === edge.to))
88
+ problems.push({ node: id, message: `It refers to ${edge.id} as a ${edge.to}, but that node is a ${target._tag === "Hole" ? `hole for a ${target.expects}` : target._tag}.` });
89
+ }
90
+ }
91
+ return problems;
92
+ };
93
+
94
+ /** The holes still to build, by the tag each expects, then by id. */
95
+ export const holesOf = (program: Program): ReadonlyArray<Readonly<{ expects: NodeTag; id: NodeId }>> =>
96
+ (Object.values(program.nodes) as Node[])
97
+ .flatMap((node) => (node._tag === "Hole" ? [{ expects: node.expects, id: node.id }] : []))
98
+ .sort((left, right) => left.expects.localeCompare(right.expects) || left.id.localeCompare(right.id));
99
+
100
+ export type NodeDiff = Readonly<{ added: ReadonlyArray<NodeId>; removed: ReadonlyArray<NodeId>; changed: ReadonlyArray<NodeId> }>;
101
+
102
+ /**
103
+ * The approvals a new program loosens against the one that runs: each the running root declares that the new root
104
+ * drops or asks of someone else. Such a revision waits for the owner's own press before it runs.
105
+ */
106
+ export const loosenedApprovals = (running: Program | null, next: Program): ReadonlyArray<string> => {
107
+ const before = running?.nodes[running.root];
108
+ const after = next.nodes[next.root];
109
+ if (before?._tag !== "Factory") return [];
110
+ const kept = after?._tag === "Factory" ? after.approvals : {};
111
+ return Object.entries(before.approvals).filter(([name, approval]) => kept[name]?.by !== approval.by).map(([name]) => name).sort();
112
+ };
113
+
114
+ /** What changed between two programs, node by node, by digest. A filled hole is one change in place. */
115
+ export const nodeDiff = (before: Program | null, after: Program): NodeDiff => {
116
+ const old = before?.nodes ?? {};
117
+ const ids = (nodes: Readonly<Record<string, Node>>) => Object.keys(nodes).sort() as NodeId[];
118
+ return {
119
+ added: ids(after.nodes).filter((id) => old[id] === undefined),
120
+ removed: ids(old).filter((id) => after.nodes[id] === undefined),
121
+ changed: ids(after.nodes).filter((id) => old[id] !== undefined && (old[id]._tag !== after.nodes[id]!._tag || old[id].digest !== after.nodes[id]!.digest)),
122
+ };
123
+ };
@@ -0,0 +1,173 @@
1
+ import { Result, Schema } from "effect";
2
+ import * as agent from "./agent";
3
+ import * as factory from "./factory";
4
+ import * as goal from "./goal";
5
+ import * as hole from "./hole";
6
+ import * as job from "./job";
7
+ import {
8
+ headOf, idOf, isAuthored, Name, nodeId, ROOT_ID,
9
+ type Definition, type Encoder, type MadeAt, type NodeId, type NodeTag, type Ref, type SourceAt, type Tag,
10
+ } from "./node";
11
+ import * as pool from "./pool";
12
+ import { graphProblems, LANGUAGE_VERSION, Program, type Node } from "./program";
13
+ import * as repository from "./repository";
14
+ import * as rule from "./rule";
15
+ import * as service from "./service";
16
+ import * as source from "./source";
17
+ import * as stage from "./stage";
18
+
19
+ /*
20
+ * One definition per tag: its node, make, live and owner. A new tag does not
21
+ * compile until its definition exists here, and each engine's registry is
22
+ * exhaustive over the tags it owns: the plane over PlaneTag, the factory VM
23
+ * over VmTag. Nothing is discovered from folders.
24
+ *
25
+ * The encoder turns authored values into a Program: it walks from the root,
26
+ * stores each node once under its id (interning), and writes every link as
27
+ * a Ref. The same id with different content is a problem pinned to that id.
28
+ */
29
+
30
+ export const definitions = {
31
+ Factory: factory,
32
+ Repository: repository,
33
+ Source: source,
34
+ Rule: rule,
35
+ Stage: stage,
36
+ Job: job,
37
+ Service: service,
38
+ Pool: pool,
39
+ Agent: agent,
40
+ Goal: goal,
41
+ Hole: hole,
42
+ } satisfies { readonly [T in Tag]: Definition<T> };
43
+
44
+ type OwnerOf<T extends Tag> = (typeof definitions)[T]["owner"];
45
+ /** The tags the plane gives a meaning: the repository watcher and the executors. */
46
+ export type PlaneTag = { [T in Tag]: OwnerOf<T> extends "plane" ? T : never }[Tag];
47
+ /** The tags the factory VM gives a meaning: everything a pass runs. */
48
+ export type VmTag = { [T in Tag]: OwnerOf<T> extends "vm" ? T : never }[Tag];
49
+
50
+ /** Proof: no tag has two engine meanings. */
51
+ export const planeAndVmAreDisjoint: [Extract<PlaneTag, VmTag>] extends [never] ? true : false = true;
52
+
53
+ /** Where the encoder found a problem: a node when it knows one, and where it was made. */
54
+ export type EncodeProblem = Readonly<{ node: NodeId | null; at: SourceAt | null; message: string }>;
55
+
56
+ /** A file of the factory folder: its content digest and the files it imports, both relative to the folder. */
57
+ export type FileFacts = Readonly<{ digest: string; imports: ReadonlyArray<string> }>;
58
+
59
+ export type EncodeOptions = Readonly<{
60
+ /** The factory folder, absolute: where made-at paths are made relative to. */
61
+ tree: string;
62
+ /** The `@thinair/factory` version doing the encoding. */
63
+ framework: string;
64
+ /** SHA-256 of a text, in lowercase hex. */
65
+ hash: (text: string) => string;
66
+ /** The folder's import graph, by relative path. Without it, digests cover settings only. */
67
+ files: Readonly<Record<string, FileFacts>>;
68
+ }>;
69
+
70
+ export type Encoded = Readonly<{ program: Program | null; problems: ReadonlyArray<EncodeProblem> }>;
71
+
72
+ /** JSON with keys sorted at every level: two values are the same when this is. */
73
+ const canonicalJson = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) =>
74
+ item !== null && typeof item === "object" && !Array.isArray(item)
75
+ ? Object.fromEntries(Object.entries(item as Record<string, unknown>).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
76
+ : item);
77
+
78
+ type Draft = Readonly<{ node: Readonly<Record<string, unknown>> & { readonly at: SourceAt }; code: boolean; content: string }>;
79
+
80
+ /** Encode a factory's root, as `factory.ts` exports it, into its Program. */
81
+ export const encodeFactory = (root: unknown, options: EncodeOptions): Encoded => {
82
+ const problems: EncodeProblem[] = [];
83
+ const drafts = new Map<NodeId, Draft>();
84
+ const encodedValues = new Map<object, NodeId>();
85
+ const visiting = new Set<object>();
86
+ let broken = false;
87
+
88
+ const tree = options.tree.replace(/[\\/]+$/u, "");
89
+ const sourceAt = (at: MadeAt | null): SourceAt => {
90
+ if (at === null) return { file: "factory.ts", line: 1 };
91
+ const file = at.file.split("\\").join("/");
92
+ return { file: file.startsWith(`${tree}/`) ? file.slice(tree.length + 1) : file, line: Math.max(1, at.line) };
93
+ };
94
+
95
+ const encode = <T extends NodeTag>(to: T, value: unknown, from: Readonly<{ node: NodeId; at: SourceAt }> | null): Ref<T> => {
96
+ if (!isAuthored(value)) {
97
+ broken = true;
98
+ problems.push({ node: from?.node ?? null, at: from?.at ?? null, message: `A ${to} goes here, but this value was not made by a factory constructor.` });
99
+ return { _tag: "Ref", to, id: nodeId(to, "not-a-node") };
100
+ }
101
+ const made = headOf(value);
102
+ const at = sourceAt(made.at);
103
+ const id = idOf(value);
104
+ const ref: Ref<T> = { _tag: "Ref", to, id };
105
+ if (made.tag !== to && !(made.tag === "Hole" && made.expects === to)) {
106
+ broken = true;
107
+ problems.push({ node: from?.node ?? id, at: from?.at ?? at, message: `A ${to} goes here, but ${id} is ${made.tag === "Hole" ? `a hole for a ${made.expects}` : `a ${made.tag}`}.` });
108
+ return ref;
109
+ }
110
+ if (encodedValues.has(value) || visiting.has(value)) return ref;
111
+ if (Result.isFailure(Schema.decodeUnknownResult(Name)(made.name))) {
112
+ broken = true;
113
+ problems.push({ node: null, at, message: `"${made.name}" is not a ${made.tag === "Hole" ? made.expects : made.tag} name: use lowercase words and digits joined by dashes or dots, at most 64 characters.` });
114
+ return ref;
115
+ }
116
+ visiting.add(value);
117
+ const encoder: Encoder = {
118
+ ref: (tag, slot) => encode(tag, slot, { node: id, at }),
119
+ problem: (message) => problems.push({ node: id, at, message }),
120
+ };
121
+ const settings = (definitions[made.tag] as Definition<Tag>).make(value as never, encoder);
122
+ visiting.delete(value);
123
+ encodedValues.set(value, id);
124
+ const node = { _tag: made.tag, id, at, ...settings };
125
+ const content = canonicalJson({ ...node, at: undefined });
126
+ const first = drafts.get(id);
127
+ if (first === undefined) drafts.set(id, { node, code: made.code, content });
128
+ else if (first.content !== content)
129
+ problems.push({ node: id, at, message: `Two different nodes are named ${id}: one made at ${first.node.at.file}:${first.node.at.line}, one here. Rename one, or make them the same.` });
130
+ return ref;
131
+ };
132
+
133
+ if (!isAuthored(root) || headOf(root).tag !== "Factory") {
134
+ problems.push({ node: null, at: { file: "factory.ts", line: 1 }, message: "factory.ts's default export is the factory: export default defineFactory({ ... })." });
135
+ return { program: null, problems };
136
+ }
137
+ encode("Factory", root, null);
138
+ if (broken) return { program: null, problems };
139
+
140
+ // A node's digest covers its content and, when it holds code, the import closure of its file,
141
+ // stopping at files that make other nodes: those nodes have digests of their own.
142
+ const madeIn = new Set([...drafts.values()].map((draft) => draft.node.at.file));
143
+ const closures = new Map<string, string>();
144
+ const closureOf = (start: string): string => {
145
+ const known = closures.get(start);
146
+ if (known !== undefined) return known;
147
+ if (options.files[start] === undefined) return "";
148
+ const seen = new Set<string>();
149
+ const pending = [start];
150
+ while (pending.length > 0) {
151
+ const file = pending.pop()!;
152
+ if (seen.has(file)) continue;
153
+ seen.add(file);
154
+ for (const imported of options.files[file]?.imports ?? [])
155
+ if (options.files[imported] !== undefined && !(imported !== start && madeIn.has(imported))) pending.push(imported);
156
+ }
157
+ const closure = options.hash([...seen].sort().map((file) => `${file} ${options.files[file]!.digest}`).join("\n"));
158
+ closures.set(start, closure);
159
+ return closure;
160
+ };
161
+ const nodes: Record<string, unknown> = {};
162
+ for (const [id, draft] of [...drafts].sort(([left], [right]) => (left < right ? -1 : 1)))
163
+ nodes[id] = { ...draft.node, digest: options.hash(`${draft.content}\n${draft.code ? closureOf(draft.node.at.file) : ""}`) };
164
+
165
+ const decoded = Schema.decodeUnknownResult(Program)({ version: LANGUAGE_VERSION, framework: options.framework, root: ROOT_ID, nodes });
166
+ if (Result.isFailure(decoded)) {
167
+ problems.push({ node: null, at: null, message: `The program does not decode: ${decoded.failure.message}` });
168
+ return { program: null, problems };
169
+ }
170
+ for (const problem of graphProblems(decoded.success))
171
+ problems.push({ node: problem.node, at: (decoded.success.nodes[problem.node] as Node | undefined)?.at ?? null, message: problem.message });
172
+ return { program: decoded.success, problems };
173
+ };
@@ -0,0 +1,79 @@
1
+ import { Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, type Authored, type FactKind, type LiveRows } from "./node";
3
+
4
+ /*
5
+ * A repository the factory builds. It owns its GitHub facts; there is no
6
+ * separate GitHub source. `web.facts.pullRequest`, `.mergeGroup` and
7
+ * `.head` are typed, and rules listen on them. The plane's repository
8
+ * watcher gives it its meaning.
9
+ */
10
+
11
+ /** A git commit: 40 or 64 hex characters. */
12
+ export const Commit = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u));
13
+
14
+ export const PullRequestFact = Schema.Struct({
15
+ number: Schema.Int.check(Schema.isGreaterThan(0)),
16
+ head: Commit,
17
+ base: Schema.String,
18
+ branch: Schema.String,
19
+ state: Schema.Literals(["open", "closed", "merged"]),
20
+ draft: Schema.Boolean,
21
+ title: Schema.String,
22
+ });
23
+ export type PullRequestFact = typeof PullRequestFact.Type;
24
+ export const MergeGroupFact = Schema.Struct({ head: Commit, base: Schema.String });
25
+ export type MergeGroupFact = typeof MergeGroupFact.Type;
26
+ /** A branch's head, keyed by the branch. */
27
+ export const HeadFact = Schema.Struct({ branch: Schema.String, commit: Commit });
28
+ export type HeadFact = typeof HeadFact.Type;
29
+
30
+ export const Node = Schema.TaggedStruct("Repository", {
31
+ ...head,
32
+ /** `owner/name` on GitHub. */
33
+ github: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/u)),
34
+ base: Schema.String.check(Schema.isMinLength(1)),
35
+ devcontainer: Schema.NullOr(Schema.String),
36
+ });
37
+ export type Node = typeof Node.Type;
38
+
39
+ export interface AuthoredRepository<N extends string = string> extends Authored<"Repository", N> {
40
+ readonly github: string;
41
+ readonly base: string;
42
+ readonly devcontainer: string | null;
43
+ readonly facts: Readonly<{
44
+ pullRequest: FactKind<PullRequestFact>;
45
+ mergeGroup: FactKind<MergeGroupFact>;
46
+ head: FactKind<HeadFact>;
47
+ }>;
48
+ }
49
+
50
+ /** `repository("web", { github: "acme/website", base: "main" })`. */
51
+ export const repository = <const N extends string>(name: N, settings: Readonly<{ github: string; base: string; devcontainer?: string }>): AuthoredRepository<N> => {
52
+ const made = {
53
+ [AUTHORED]: authoredHead("Repository", name, false),
54
+ github: settings.github,
55
+ base: settings.base,
56
+ devcontainer: settings.devcontainer ?? null,
57
+ };
58
+ const from: Authored<"Repository"> = made;
59
+ return Object.assign(made, {
60
+ facts: {
61
+ pullRequest: { from, kind: "pullRequest", schema: PullRequestFact },
62
+ mergeGroup: { from, kind: "mergeGroup", schema: MergeGroupFact },
63
+ head: { from, kind: "head", schema: HeadFact },
64
+ },
65
+ });
66
+ };
67
+
68
+ export const make = (authored: AuthoredRepository) => ({ github: authored.github, base: authored.base, devcontainer: authored.devcontainer });
69
+
70
+ /** Its newest facts: the base's head and the open pull requests the watcher saw. */
71
+ export const live = (node: Node, rows: LiveRows) => {
72
+ const facts = rows.facts.filter((fact) => fact.from === node.id);
73
+ return {
74
+ head: facts.filter((fact) => fact.kind === "head" && fact.key === node.base).at(-1)?.at ?? null,
75
+ pullRequests: new Set(facts.filter((fact) => fact.kind === "pullRequest").map((fact) => fact.key)).size,
76
+ };
77
+ };
78
+
79
+ export const owner = "plane" as const;