@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/domain/step.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+
3
+ /*
4
+ * The only holes a job's step may use. A step is
5
+ * `Effect<Out, StepFailed | Lost, Machine | Store>`: it checks out a commit on the
6
+ * machine its attempt is bound to, runs commands there, and keeps bytes in
7
+ * the content store. Every outward write (publish, push, merge) is an act,
8
+ * never a step.
9
+ *
10
+ * Keys are the framework's, not the author's: a command's identity is
11
+ * H(job id, job digest, epoch, label), so a step that runs again after its
12
+ * VM restarts adopts the same command. Secrets are names; the executor binds
13
+ * them into that command's environment and the values never come back.
14
+ * Hole calls fail only with Lost (the machine or its link went away), which
15
+ * means "retry elsewhere". StepFailed and defects are red and final.
16
+ */
17
+
18
+ /** A checked-out commit on the bound machine. */
19
+ export const Tree = Schema.Struct({ path: Schema.String, commit: Schema.String });
20
+ export type Tree = typeof Tree.Type;
21
+
22
+ /** What a command did: plain data, no methods. */
23
+ export const Ran = Schema.Struct({
24
+ exit: Schema.Int,
25
+ seconds: Schema.Number,
26
+ /** The last lines of its output. */
27
+ tail: Schema.String,
28
+ /** Where its whole log is kept: a content key. */
29
+ log: Schema.String,
30
+ });
31
+ export type Ran = typeof Ran.Type;
32
+
33
+ /** A content-store key: the digest of the bytes. */
34
+ export const ContentKey = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/u)).pipe(Schema.brand("ContentKey"));
35
+ export type ContentKey = typeof ContentKey.Type;
36
+
37
+ /** The machine or its link went away: the attempt is retried elsewhere. */
38
+ export class Lost extends Schema.TaggedError<Lost>()("Lost", { message: Schema.String }) {}
39
+
40
+ /** The step's own red result: final, never retried. */
41
+ export class StepFailed extends Schema.TaggedError<StepFailed>()("StepFailed", { summary: Schema.String, log: Schema.String }) {}
42
+
43
+ export type RunOptions = Readonly<{
44
+ cwd: string;
45
+ argv: ReadonlyArray<string>;
46
+ /** Names of secrets the executor binds into this command's environment. */
47
+ secrets: ReadonlyArray<string>;
48
+ }>;
49
+
50
+ /** The machine this attempt is bound to. */
51
+ export class Machine extends Context.Service<Machine, {
52
+ /** The executor brings the commit: a bundle, never a token. */
53
+ readonly checkout: (commit: string) => Effect.Effect<Tree, Lost>;
54
+ /** `label` is a literal, unique in the step: with the attempt it names the command. */
55
+ readonly run: (label: string, options: RunOptions) => Effect.Effect<Ran, Lost>;
56
+ }>()("@thinair/factory/domain/step/Machine") {}
57
+
58
+ /** The content store: create-only, by digest. */
59
+ export class Store extends Context.Service<Store, {
60
+ readonly put: (bytes: Uint8Array) => Effect.Effect<ContentKey, Lost>;
61
+ readonly get: (key: ContentKey) => Effect.Effect<Uint8Array, Lost>;
62
+ }>()("@thinair/factory/domain/step/Store") {}
63
+
64
+ /** A step: the only imperative code in a factory. A Lost from a hole passes through: the engine retries it elsewhere. */
65
+ export type Step<In, Out> = (input: In) => Effect.Effect<Out, StepFailed | Lost, Machine | Store>;
66
+
67
+ /** Something a machine provides, matched against its facts: `capability("vms")`. */
68
+ export const Capability = Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)).pipe(Schema.brand("Capability"));
69
+ export type Capability = typeof Capability.Type;
70
+ export const capability = (name: string): Capability => name as Capability;
@@ -0,0 +1,41 @@
1
+ import { Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, type Authored, type LiveRows } from "./node";
3
+
4
+ /*
5
+ * An agent of this factory: its instructions (a Markdown file in the factory
6
+ * folder) and the engine it thinks with. Its conversations live in the
7
+ * factory's File and are named "<agent> · <factory>". Where it works is a
8
+ * Run's business, never the agent's.
9
+ */
10
+
11
+ export const Engine = Schema.Literals(["claude", "codex", "any"]);
12
+ export type Engine = typeof Engine.Type;
13
+
14
+ export const Node = Schema.TaggedStruct("Agent", {
15
+ ...head,
16
+ /** Its instructions: a `.md` path relative to the factory folder. */
17
+ instructions: Schema.String.check(Schema.isPattern(/^(?!\/)(?!.*\.\.\/).+\.md$/u)),
18
+ engine: Engine,
19
+ });
20
+ export type Node = typeof Node.Type;
21
+
22
+ export interface AuthoredAgent<N extends string = string> extends Authored<"Agent", N> {
23
+ readonly instructions: string;
24
+ readonly engine: Engine;
25
+ }
26
+
27
+ /** `agent("web", { instructions: "agents/web.md" })`. */
28
+ export const agent = <const N extends string>(name: N, settings: Readonly<{ instructions: string; engine?: Engine }>): AuthoredAgent<N> => ({
29
+ [AUTHORED]: authoredHead("Agent", name, false),
30
+ instructions: settings.instructions,
31
+ engine: settings.engine ?? "any",
32
+ });
33
+
34
+ export const make = (authored: AuthoredAgent) => ({ instructions: authored.instructions, engine: authored.engine });
35
+
36
+ /** The sessions it has up now, across the factory's Runs. */
37
+ export const live = (node: Node, rows: LiveRows) => ({
38
+ sessions: rows.jobs.filter((row) => row.node === node.id && row.state === "running").length,
39
+ });
40
+
41
+ export const owner = "none" as const;
@@ -0,0 +1,88 @@
1
+ /*
2
+ * The shape of a factory app: the `factory/` folder in the repository it
3
+ * builds. It sits outside the pnpm workspace, pins `@thinair/factory`, and
4
+ * imports only the authoring modules below and its own npm packages, never
5
+ * the host repository. The load gate owns this shape, its lint and its
6
+ * reachability, the same for live, preview and the PR self-check.
7
+ *
8
+ * factory/
9
+ * factory.ts export default defineFactory({ ... }): the one root
10
+ * package.json pins @thinair/factory and effect
11
+ * tsconfig.json bun.lock README.md .gitignore
12
+ * repositories/ intake/ stages/ jobs/ fleet/ agents/ lifecycles/
13
+ * one default export per .ts file, each a node;
14
+ * agents/ also holds their instructions (.md)
15
+ */
16
+
17
+ /** The modules factory code may import from `@thinair/factory`. Nothing else of the framework is its to reach. */
18
+ export const AUTHORING_MODULES = [
19
+ "@thinair/factory/domain/system-elements/factory",
20
+ "@thinair/factory/domain/system-elements/repository",
21
+ "@thinair/factory/domain/system-elements/source",
22
+ "@thinair/factory/domain/system-elements/rule",
23
+ "@thinair/factory/domain/system-elements/stage",
24
+ "@thinair/factory/domain/system-elements/job",
25
+ "@thinair/factory/domain/system-elements/service",
26
+ "@thinair/factory/domain/system-elements/pool",
27
+ "@thinair/factory/domain/system-elements/agent",
28
+ "@thinair/factory/domain/system-elements/goal",
29
+ "@thinair/factory/domain/system-elements/hole",
30
+ "@thinair/factory/domain/system-elements/compose",
31
+ "@thinair/factory/domain/step",
32
+ ] as const;
33
+ export type AuthoringModule = (typeof AUTHORING_MODULES)[number];
34
+
35
+ /** The folders whose `.ts` files are members: one default export each, a node. */
36
+ export const MEMBER_FOLDERS = ["repositories", "intake", "stages", "jobs", "fleet", "agents", "lifecycles"] as const;
37
+
38
+ /** The files at the folder's top, besides the member folders. */
39
+ export const ROOT_FILES = ["factory.ts", "package.json", "tsconfig.json", "bun.lock", "README.md", ".gitignore"] as const;
40
+ export const REQUIRED_FILES = ["factory.ts", "package.json", "tsconfig.json"] as const;
41
+
42
+ /** What the loader never reads: installed packages and the loader's own cache live there. */
43
+ export const IGNORED_FOLDERS = ["node_modules"] as const;
44
+
45
+ /**
46
+ * Whether the loader reads a file of the folder, by its path relative to it: never what sits in an ignored folder or
47
+ * `.git`, and no dot entry but `.gitignore`. The plane fetches the same files a loader reads, so both name one folder.
48
+ */
49
+ export const isLoadedPath = (path: string): boolean =>
50
+ path.split("/").every((part) => part !== "" && part !== ".git" && !(IGNORED_FOLDERS as ReadonlyArray<string>).includes(part) && (!part.startsWith(".") || part === ".gitignore"));
51
+
52
+ /**
53
+ * The content key of a factory folder: the framework that loads it and every file it reads, each by the SHA-256 of
54
+ * its bytes. The same files under the same framework give the same key. `hash` is SHA-256 to hex.
55
+ */
56
+ export const folderKey = (framework: string, digests: Readonly<Record<string, string>>, hash: (text: string) => string): string =>
57
+ hash(`${framework}\n${Object.keys(digests).sort().map((file) => `${file} ${digests[file]}`).join("\n")}`);
58
+
59
+ export type ShapeProblem = Readonly<{ path: string; message: string }>;
60
+
61
+ const isMemberFile = (path: string) => path.endsWith(".ts") && !path.endsWith(".d.ts");
62
+
63
+ /** Check the folder's file list (paths relative to it, `/`-separated) against the app shape. */
64
+ export const appShapeProblems = (paths: ReadonlyArray<string>): ReadonlyArray<ShapeProblem> => {
65
+ const problems: ShapeProblem[] = [];
66
+ const present = new Set(paths);
67
+ for (const required of REQUIRED_FILES)
68
+ if (!present.has(required)) problems.push({ path: required, message: `A factory folder has ${required}.` });
69
+ for (const path of paths) {
70
+ const [top, ...rest] = path.split("/");
71
+ if (rest.length === 0) {
72
+ if (!(ROOT_FILES as ReadonlyArray<string>).includes(path))
73
+ problems.push({ path, message: `Not part of a factory app: the top of factory/ holds ${ROOT_FILES.join(", ")} and the folders ${MEMBER_FOLDERS.join(", ")}.` });
74
+ continue;
75
+ }
76
+ if (!(MEMBER_FOLDERS as ReadonlyArray<string>).includes(top!)) {
77
+ problems.push({ path, message: `Not part of a factory app: its folders are ${MEMBER_FOLDERS.join(", ")}.` });
78
+ continue;
79
+ }
80
+ if (!isMemberFile(path) && !(top === "agents" && path.endsWith(".md")))
81
+ problems.push({ path, message: `${top}/ holds TypeScript members${top === "agents" ? " and their instructions (.md)" : ""} only.` });
82
+ }
83
+ return problems;
84
+ };
85
+
86
+ /** The files the factory-code lint holds to one default export: factory.ts and every member. */
87
+ export const memberFiles = (paths: ReadonlyArray<string>): ReadonlyArray<string> =>
88
+ paths.filter((path) => path === "factory.ts" || ((MEMBER_FOLDERS as ReadonlyArray<string>).includes(path.split("/")[0]!) && path.includes("/") && isMemberFile(path)));
@@ -0,0 +1,211 @@
1
+ import { Schema } from "effect";
2
+ import type { AuthoredAgent } from "./agent";
3
+ import type { AnyJob, JobOutput } from "./job";
4
+ import { AnyRef, Ref, type FactKind } from "./node";
5
+ import type { AuthoredRepository } from "./repository";
6
+ import type { AnyService } from "./service";
7
+ import type { AnyStage, StageSpec } from "./stage";
8
+
9
+ /*
10
+ * The per-pass language: what a stage's compose and a rule's route return,
11
+ * and what they may read. It is closed.
12
+ *
13
+ * Out, children: want(work, input), together(...), child(stage, key, spec),
14
+ * done(note), hold(reason): what should exist now; owned, and collected
15
+ * when no longer wanted.
16
+ * Out, engine requests: pin(name, value) writes a pin once; judge(question)
17
+ * asks the judge once and composes this ticket again in the same pass.
18
+ * Act, one-time and outward: wake, checkRun, mergePullRequest, post, call.
19
+ * They go through the act outbox, keyed, with a lease, settled by reading.
20
+ * Seen, read only: output, verdict, ticket, children, fact, judged, pinned,
21
+ * lifecycle. App-specific questions become facts or job outputs.
22
+ *
23
+ * A rule routes one fact: it returns ticket(stage, key, spec) to file or
24
+ * update a ticket, and acts.
25
+ */
26
+
27
+ /** What a stage may want: a batch job or a service. */
28
+ export type Work = AnyJob | AnyService;
29
+
30
+ declare const PHANTOM: unique symbol;
31
+ /** The types a value carries for compose and nothing else. */
32
+ export interface Carries<In, Out> {
33
+ readonly [PHANTOM]?: Readonly<{ input: In; output: Out }>;
34
+ }
35
+ export type InputOf<W> = W extends Carries<infer In, unknown> ? In : never;
36
+ export type OutputOf<W> = W extends Carries<unknown, infer Out> ? Out : never;
37
+
38
+ // Out: children.
39
+
40
+ export interface Want<W extends Work = Work> {
41
+ readonly _tag: "Want";
42
+ readonly work: W;
43
+ readonly input: InputOf<W>;
44
+ }
45
+ /** Want this job or service, with this input: it exists while a pass wants it. */
46
+ export const want = <W extends Work>(work: W, input: InputOf<W>): Want<W> => ({ _tag: "Want", work, input });
47
+
48
+ /**
49
+ * One Run: admitted whole or not at all. Its members are placed separately
50
+ * and may land on different machines. A later member may take an earlier
51
+ * one's output as a value: `together(want(copy, { base }), (repository) =>
52
+ * want(coder, { agent, brief, repository }))`.
53
+ */
54
+ export interface Together<W extends Work = Work> {
55
+ readonly _tag: "Together";
56
+ readonly first: Want<W>;
57
+ /** The next member, from the first one's output. */
58
+ readonly next: ((output: never) => Want<W>) | null;
59
+ }
60
+ export function together<A extends Work, B extends Work>(first: Want<A>, next: (output: OutputOf<A>) => Want<B>): Together<A | B>;
61
+ export function together<W extends Work>(first: Want<W>): Together<W>;
62
+ export function together(first: Want, next?: (output: never) => Want): Together {
63
+ return { _tag: "Together", first, next: next ?? null };
64
+ }
65
+
66
+ /** A ticket owned by this one: its stage, its key, its spec. */
67
+ export interface Child<S extends AnyStage = AnyStage> {
68
+ readonly _tag: "Child";
69
+ readonly stage: S;
70
+ readonly key: string;
71
+ readonly spec: StageSpec<S>;
72
+ }
73
+ export const child = <S extends AnyStage>(stage: S, key: string, spec: StageSpec<S>): Child<S> => ({ _tag: "Child", stage, key, spec });
74
+
75
+ export interface Done { readonly _tag: "Done"; readonly note: string }
76
+ /** This ticket's work is done. */
77
+ export const done = (note: string): Done => ({ _tag: "Done", note });
78
+
79
+ export interface Hold { readonly _tag: "Hold"; readonly reason: string }
80
+ /** This ticket waits, and says why. */
81
+ export const hold = (reason: string): Hold => ({ _tag: "Hold", reason });
82
+
83
+ // Out: engine requests.
84
+
85
+ export interface Pin { readonly _tag: "Pin"; readonly name: string; readonly value: Schema.Json }
86
+ /** Write a pin once. A status rewrite never drops it. */
87
+ export const pin = (name: string, value: Schema.Json): Pin => ({ _tag: "Pin", name, value });
88
+
89
+ /** A question the judge answers from a legal set code computes; the safe default is the first. */
90
+ export interface Question<A> {
91
+ readonly _tag: "Question";
92
+ readonly name: string;
93
+ readonly service: AnyService;
94
+ readonly legal: ReadonlyArray<A>;
95
+ }
96
+ export interface Judge { readonly _tag: "Judge"; readonly question: Question<unknown> }
97
+ /** Ask once, while compose waits; the ticket composes again when it is answered. */
98
+ export const judge = <A>(question: Question<A>): Judge => ({ _tag: "Judge", question: question as Question<unknown> });
99
+
100
+ export type Judgment<A> = Readonly<{ _tag: "Pending" }> | Readonly<{ _tag: "Judged"; value: A }>;
101
+
102
+ // Acts.
103
+
104
+ export interface Wake {
105
+ readonly _tag: "Wake";
106
+ readonly target: AuthoredAgent | "proposer" | "owner";
107
+ readonly text: string;
108
+ }
109
+ /** Wake an agent, the run that proposed this ticket's change, or the owner. */
110
+ export const wake = (target: AuthoredAgent | "proposer" | "owner", text: string): Wake => ({ _tag: "Wake", target, text });
111
+
112
+ export const Verdict = Schema.Union([
113
+ Schema.TaggedStruct("Running", {}),
114
+ Schema.TaggedStruct("Green", {}),
115
+ Schema.TaggedStruct("Red", { summary: Schema.String }),
116
+ ]);
117
+ export type Verdict = typeof Verdict.Type;
118
+
119
+ export interface CheckRun {
120
+ readonly _tag: "CheckRun";
121
+ readonly repository: AuthoredRepository;
122
+ readonly head: string;
123
+ readonly verdict: Verdict;
124
+ }
125
+ /** Post a check run on this head. */
126
+ export const checkRun = (repository: AuthoredRepository, head: string, verdict: Verdict): CheckRun => ({ _tag: "CheckRun", repository, head, verdict });
127
+
128
+ export interface MergePullRequest { readonly _tag: "MergePullRequest"; readonly repository: AuthoredRepository; readonly pullRequest: number }
129
+ /** Merge through the repository's merge queue. A run never merges its own ticket's pull request. */
130
+ export const mergePullRequest = (repository: AuthoredRepository, pullRequest: number): MergePullRequest =>
131
+ ({ _tag: "MergePullRequest", repository, pullRequest });
132
+
133
+ export interface Post<A = unknown> { readonly _tag: "Post"; readonly fact: FactKind<A>; readonly key: string; readonly value: A }
134
+ /** Post a fact of one of the factory's sources, by its key. */
135
+ export const post = <A>(fact: FactKind<A>, key: string, value: A): Post<A> => ({ _tag: "Post", fact, key, value });
136
+
137
+ export interface Call { readonly _tag: "Call"; readonly connection: string; readonly tool: string; readonly args: Schema.Json }
138
+ /** Call a tool of one of the Profile's connections. */
139
+ export const call = (input: Readonly<{ connection: string; tool: string; args?: Schema.Json }>): Call =>
140
+ ({ _tag: "Call", connection: input.connection, tool: input.tool, args: input.args ?? {} });
141
+
142
+ export type Act = Wake | CheckRun | MergePullRequest | Post | Call;
143
+
144
+ /** What a stage's compose returns: children of its own jobs and stages, engine requests and acts. */
145
+ export type Out<W extends Work = Work, C extends AnyStage = AnyStage> = Want<W> | Together<W> | Child<C> | Done | Hold | Pin | Judge | Act;
146
+
147
+ // Rules.
148
+
149
+ /** File a ticket of this stage under this key, or update the one that has it. */
150
+ export interface Ticket<S extends AnyStage = AnyStage> {
151
+ readonly _tag: "Ticket";
152
+ readonly stage: S;
153
+ readonly key: string;
154
+ readonly spec: StageSpec<S>;
155
+ }
156
+ export const ticket = <S extends AnyStage>(stage: S, key: string, spec: StageSpec<S>): Ticket<S> => ({ _tag: "Ticket", stage, key, spec });
157
+
158
+ /** What a rule's route returns. */
159
+ export type RuleOut<S extends AnyStage = AnyStage> = Ticket<S> | Act;
160
+
161
+ // Seen.
162
+
163
+ /** A ticket as compose reads it. */
164
+ export type TicketView<Spec = unknown> = Readonly<{ id: string; key: string; state: string; spec: Spec }>;
165
+
166
+ /** What compose may read: nothing else. */
167
+ export interface Seen<W extends Work = Work> {
168
+ /** This ticket. */
169
+ readonly ticket: TicketView;
170
+ /** The output of a job run with this input, from a receipt or this ticket's own jobs. */
171
+ readonly output: <J extends W & AnyJob>(job: J, input: InputOf<J>) => JobOutput<J> | undefined;
172
+ /** Passes from receipts and failures from this ticket's own jobs. */
173
+ readonly verdict: (wants: ReadonlyArray<Want<W> | Together<W>>) => Verdict;
174
+ readonly children: <S extends AnyStage>(stage: S) => ReadonlyArray<TicketView<StageSpec<S>>>;
175
+ readonly fact: <A>(kind: FactKind<A>, key: string) => A | undefined;
176
+ readonly judged: <A>(question: Question<A>) => Judgment<A>;
177
+ readonly pinned: (name: string) => Schema.Json | undefined;
178
+ readonly lifecycle: Readonly<{ matches: (state: string) => boolean }>;
179
+ }
180
+
181
+ /** What a rule may read while it routes one fact. */
182
+ export interface RuleSeen {
183
+ readonly ticket: <S extends AnyStage>(stage: S, key: string) => TicketView<StageSpec<S>> | undefined;
184
+ readonly fact: <A>(kind: FactKind<A>, key: string) => A | undefined;
185
+ }
186
+
187
+ // Records: the outputs as the engine stores them, with Refs.
188
+
189
+ const Work_ = Schema.Union([Ref("Job"), Ref("Service")]);
190
+
191
+ export const WantRecord = Schema.TaggedStruct("Want", { work: Work_, input: Schema.Json });
192
+ export const OutRecord = Schema.Union([
193
+ WantRecord,
194
+ Schema.TaggedStruct("Together", { members: Schema.Array(WantRecord) }),
195
+ Schema.TaggedStruct("Child", { stage: Ref("Stage"), key: Schema.String, spec: Schema.Json }),
196
+ Schema.TaggedStruct("Done", { note: Schema.String }),
197
+ Schema.TaggedStruct("Hold", { reason: Schema.String }),
198
+ Schema.TaggedStruct("Pin", { name: Schema.String, value: Schema.Json }),
199
+ Schema.TaggedStruct("Judge", { question: Schema.String, service: Ref("Service") }),
200
+ ]);
201
+ export type OutRecord = typeof OutRecord.Type;
202
+
203
+ export const ActRecord = Schema.Union([
204
+ Schema.TaggedStruct("Wake", { target: Schema.Union([Ref("Agent"), Schema.Literals(["proposer", "owner"])]), text: Schema.String }),
205
+ Schema.TaggedStruct("CheckRun", { repository: Ref("Repository"), head: Schema.String, verdict: Verdict }),
206
+ Schema.TaggedStruct("MergePullRequest", { repository: Ref("Repository"), pullRequest: Schema.Int }),
207
+ Schema.TaggedStruct("Post", { from: AnyRef, kind: Schema.String, key: Schema.String, value: Schema.Json }),
208
+ Schema.TaggedStruct("Call", { connection: Schema.String, tool: Schema.String, args: Schema.Json }),
209
+ ]);
210
+ export type ActRecord = typeof ActRecord.Type;
211
+
@@ -0,0 +1,61 @@
1
+ import { Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, Name, Ref, ROOT_NAME, type Authored, type Encoder, type LiveRows, type Slot } from "./node";
3
+
4
+ /*
5
+ * The root: `export default defineFactory({ ... })` in `factory/factory.ts`.
6
+ * What it lists runs and shows; nothing else does. It has no name and no
7
+ * Profile: the File's name and Profile are the factory's, so the code in git
8
+ * works for a copy. It holds no money guard: billing owns the runaway guard.
9
+ */
10
+
11
+ /** Who an approval waits for: a contract migration waits for the owner's own press. */
12
+ export const Approval = Schema.Struct({ by: Schema.Literal("owner") });
13
+ export type Approval = typeof Approval.Type;
14
+ export const approval = (by: Approval["by"]): Approval => ({ by });
15
+
16
+ export const Node = Schema.TaggedStruct("Factory", {
17
+ ...head,
18
+ repositories: Schema.Array(Ref("Repository")),
19
+ sources: Schema.Array(Ref("Source")),
20
+ rules: Schema.Array(Ref("Rule")),
21
+ stages: Schema.Array(Ref("Stage")),
22
+ fleet: Schema.Array(Ref("Pool")),
23
+ agents: Schema.Array(Ref("Agent")),
24
+ goals: Schema.Array(Ref("Goal")),
25
+ approvals: Schema.Record(Name, Approval),
26
+ });
27
+ export type Node = typeof Node.Type;
28
+
29
+ export type FactoryLists = Readonly<{
30
+ repositories?: ReadonlyArray<Slot<"Repository">>;
31
+ sources?: ReadonlyArray<Slot<"Source">>;
32
+ rules?: ReadonlyArray<Slot<"Rule">>;
33
+ stages?: ReadonlyArray<Slot<"Stage">>;
34
+ fleet?: ReadonlyArray<Slot<"Pool">>;
35
+ agents?: ReadonlyArray<Slot<"Agent">>;
36
+ goals?: ReadonlyArray<Slot<"Goal">>;
37
+ approvals?: Readonly<Record<string, Approval>>;
38
+ }>;
39
+
40
+ export interface AuthoredFactory extends Authored<"Factory", typeof ROOT_NAME>, FactoryLists {}
41
+
42
+ export const defineFactory = (lists: FactoryLists): AuthoredFactory => ({ [AUTHORED]: authoredHead("Factory", ROOT_NAME, false), ...lists });
43
+
44
+ export const make = (authored: AuthoredFactory, encoder: Encoder) => ({
45
+ repositories: (authored.repositories ?? []).map((node) => encoder.ref("Repository", node)),
46
+ sources: (authored.sources ?? []).map((node) => encoder.ref("Source", node)),
47
+ rules: (authored.rules ?? []).map((node) => encoder.ref("Rule", node)),
48
+ stages: (authored.stages ?? []).map((node) => encoder.ref("Stage", node)),
49
+ fleet: (authored.fleet ?? []).map((node) => encoder.ref("Pool", node)),
50
+ agents: (authored.agents ?? []).map((node) => encoder.ref("Agent", node)),
51
+ goals: (authored.goals ?? []).map((node) => encoder.ref("Goal", node)),
52
+ approvals: { ...authored.approvals },
53
+ });
54
+
55
+ /** Open tickets and running work across the factory. */
56
+ export const live = (_node: Node, rows: LiveRows) => ({
57
+ tickets: rows.tickets.length,
58
+ running: rows.jobs.filter((job) => job.state === "running").length,
59
+ });
60
+
61
+ export const owner = "none" as const;
@@ -0,0 +1,37 @@
1
+ import { Result, Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, Ref, type Authored, type Encoder, type LiveRows } from "./node";
3
+ import type { AnyStage, StageSpec } from "./stage";
4
+
5
+ /*
6
+ * A standing goal: one ticket of a stage, under a fixed key, that the
7
+ * factory keeps open while the program lists it. `goal(mainGreen,
8
+ * "main-green", { branch: "main" })` keeps main green. A goal the program no
9
+ * longer lists is closed.
10
+ */
11
+
12
+ export const Node = Schema.TaggedStruct("Goal", { ...head, stage: Ref("Stage"), spec: Schema.Json });
13
+ export type Node = typeof Node.Type;
14
+
15
+ export interface AuthoredGoal<N extends string = string, S extends AnyStage = AnyStage> extends Authored<"Goal", N> {
16
+ readonly stage: S;
17
+ readonly spec: StageSpec<S>;
18
+ }
19
+
20
+ export const goal = <const S extends AnyStage, const N extends string>(stage: S, name: N, spec: StageSpec<S>): AuthoredGoal<N, S> => ({
21
+ [AUTHORED]: authoredHead("Goal", name, false),
22
+ stage,
23
+ spec,
24
+ });
25
+
26
+ export const make = (authored: AuthoredGoal, encoder: Encoder) => {
27
+ const encoded = Schema.encodeUnknownResult(Schema.toCodecJson(authored.stage.spec))(authored.spec);
28
+ if (Result.isFailure(encoded)) encoder.problem(`Its spec is not a spec of ${authored.stage[AUTHORED].name}: ${encoded.failure.message}`);
29
+ return { stage: encoder.ref("Stage", authored.stage), spec: Result.isSuccess(encoded) ? (encoded.success as Schema.Json) : null };
30
+ };
31
+
32
+ /** Its ticket's state, while the goal holds. */
33
+ export const live = (node: Node, rows: LiveRows) => ({
34
+ state: rows.tickets.find((ticket) => ticket.goal === node.id)?.state ?? null,
35
+ });
36
+
37
+ export const owner = "vm" as const;
@@ -0,0 +1,25 @@
1
+ import { Schema } from "effect";
2
+ import { AUTHORED, authoredHead, head, NodeTag, type HoleFor, type LiveRows } from "./node";
3
+
4
+ /*
5
+ * A Hole: a part still to build. `hole("Pool", "macs")` plans a pool where
6
+ * one will go; the floor draws it dashed amber, "to build". It keeps the id
7
+ * of the node it stands for (`pool:macs`), so filling it is one change in
8
+ * place and every Ref to it still holds. The engine skips holes, and types
9
+ * already stop compose from wanting one.
10
+ */
11
+
12
+ export const Node = Schema.TaggedStruct("Hole", { ...head, expects: NodeTag });
13
+ export type Node = typeof Node.Type;
14
+
15
+ /** Plan a node of tag `expects`, named `name`, to build later. */
16
+ export const hole = <const T extends Exclude<NodeTag, "Factory">, const N extends string>(expects: T, name: N): HoleFor<T, N> => ({
17
+ [AUTHORED]: { ...authoredHead("Hole", name, false), expects },
18
+ });
19
+
20
+ export const make = (planned: HoleFor<NodeTag>) => ({ expects: planned[AUTHORED].expects });
21
+
22
+ /** A hole has nothing live: nothing runs in it. */
23
+ export const live = (_node: Node, _rows: LiveRows) => null;
24
+
25
+ export const owner = "none" as const;
@@ -0,0 +1,78 @@
1
+ import { Schema } from "effect";
2
+ import { Capability, type Step } from "../step";
3
+ import type { Carries } from "./compose";
4
+ import { AUTHORED, authoredHead, head, type Authored, type LiveRows } from "./node";
5
+ import { Arch, Os } from "./pool";
6
+
7
+ /*
8
+ * A batch job: a step that runs once per input, on a machine placement
9
+ * picks. Its receipt key is H(job digest, key(input)): changing the step's
10
+ * code gives new keys, and the same key again is reused. A job declares what
11
+ * it needs; it never names a machine.
12
+ */
13
+
14
+ /** Admission order: urgent, then check, build, agent, background. */
15
+ export const JobClass = Schema.Literals(["urgent", "check", "build", "background"]);
16
+ export type JobClass = typeof JobClass.Type;
17
+
18
+ /** What a job needs from a machine. A missing os or arch means any; placement picks. */
19
+ export const Needs = Schema.Struct({
20
+ cores: Schema.optionalKey(Schema.Number.check(Schema.isGreaterThan(0))),
21
+ memoryGiB: Schema.optionalKey(Schema.Number.check(Schema.isGreaterThan(0))),
22
+ os: Schema.optionalKey(Schema.Array(Os)),
23
+ arch: Schema.optionalKey(Schema.Array(Arch)),
24
+ /** Capabilities, matched against each machine's `provides` facts. */
25
+ uses: Schema.optionalKey(Schema.Array(Capability)),
26
+ });
27
+ export type Needs = typeof Needs.Type;
28
+
29
+ /** How a Disarm stops it: `kill` stops it now; `hold` lets an act already claimed settle and starts nothing new. */
30
+ export const JobStop = Schema.Literals(["kill", "hold"]);
31
+ export type JobStop = typeof JobStop.Type;
32
+
33
+ export const Node = Schema.TaggedStruct("Job", { ...head, class: JobClass, needs: Needs, stop: JobStop });
34
+ export type Node = typeof Node.Type;
35
+
36
+ export interface AuthoredJob<N extends string = string, In = any, Out = any> extends Authored<"Job", N>, Carries<In, Out> {
37
+ readonly class: JobClass;
38
+ readonly input: Schema.Codec<In, any, never, never>;
39
+ readonly output: Schema.Codec<Out, any, never, never>;
40
+ /** The part of the receipt key the author chooses: the content the result depends on. */
41
+ readonly key: (input: In) => string;
42
+ readonly needs: Needs;
43
+ readonly stop: JobStop;
44
+ readonly step: Step<In, Out>;
45
+ }
46
+ export type AnyJob = AuthoredJob<string, any, any>;
47
+ export type JobOutput<J> = J extends AuthoredJob<string, any, infer Out> ? Out : never;
48
+
49
+ /** `job("typecheck", { class, input, output, key, needs, stop, step })`. */
50
+ export const job = <const N extends string, In, Out>(name: N, settings: Readonly<{
51
+ class: JobClass;
52
+ input: Schema.Codec<In, any, never, never>;
53
+ output: Schema.Codec<Out, any, never, never>;
54
+ key: (input: In) => string;
55
+ needs?: Needs;
56
+ stop?: JobStop;
57
+ step: Step<In, Out>;
58
+ }>): AuthoredJob<N, In, Out> => ({
59
+ [AUTHORED]: authoredHead("Job", name, true),
60
+ class: settings.class,
61
+ input: settings.input,
62
+ output: settings.output,
63
+ key: settings.key,
64
+ needs: settings.needs ?? {},
65
+ stop: settings.stop ?? "kill",
66
+ step: settings.step,
67
+ });
68
+
69
+ export const make = (authored: AnyJob) => ({ class: authored.class, needs: authored.needs, stop: authored.stop });
70
+
71
+ /** Its attempts by state: what the floor counts on its housing. */
72
+ export const live = (node: Node, rows: LiveRows) => {
73
+ const jobs = rows.jobs.filter((row) => row.node === node.id);
74
+ const count = (state: (typeof jobs)[number]["state"]) => jobs.filter((row) => row.state === state).length;
75
+ return { waiting: count("waiting") + count("bound"), running: count("running"), succeeded: count("succeeded"), failed: count("failed") };
76
+ };
77
+
78
+ export const owner = "vm" as const;