@thinair/factory 0.1.120 → 0.1.128

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/README.md CHANGED
@@ -32,7 +32,12 @@ bunx @thinair/factory load .
32
32
 
33
33
  ## Check it
34
34
 
35
- `load` checks the folder and prints the program as JSON:
35
+ `check` checks the folder and prints the Load as JSON, including its program,
36
+ findings and holes. `load` uses the same gate and output:
37
+
38
+ ```sh
39
+ bunx @thinair/factory check .
40
+ ```
36
41
 
37
42
  1. the folder's shape;
38
43
  2. the import rule: factory code imports only the authoring modules below,
@@ -73,6 +78,22 @@ import change from "./stages/change";
73
78
  stages: [change],
74
79
  ```
75
80
 
81
+ ## Declare a builder lifecycle
82
+
83
+ A stage that creates an agent Run can declare `lifecycle: ticketLifecycle()`:
84
+
85
+ ```ts
86
+ import { ticketLifecycle } from "@thinair/factory/domain/lifecycles/ticket";
87
+ // In the stage settings:
88
+ // lifecycle: ticketLifecycle(),
89
+ ```
90
+
91
+ Its ticket follows Inbox, Ready, Running, Asking, Review, Merged or Dropped.
92
+ The stage's `together` groups follow the framework Run lifecycle by default;
93
+ `runs: runLifecycle({ bootingGuardMillis: 300_000 })` customizes its boot guard.
94
+ The package includes these machine definitions and their XState authoring
95
+ libraries. Thinair owns observing events and running the lifecycle host.
96
+
76
97
  ## What factory code imports
77
98
 
78
99
  - `@thinair/factory/domain/system-elements/factory`
@@ -88,6 +109,10 @@ import change from "./stages/change";
88
109
  - `@thinair/factory/domain/system-elements/hole`
89
110
  - `@thinair/factory/domain/system-elements/compose`
90
111
  - `@thinair/factory/domain/step`
112
+ - `@thinair/factory/domain/ticket-state`
113
+ - `@thinair/factory/domain/lifecycles/lifecycle`
114
+ - `@thinair/factory/domain/lifecycles/ticket`
115
+ - `@thinair/factory/domain/lifecycles/run`
91
116
 
92
117
  `@thinair/factory/domain/system-elements/program` and
93
118
  `@thinair/factory/domain/load` hold the schemas of a program and of a load,
package/cli/load.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
+ import { access, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -19,6 +19,7 @@ import { templateFiles } from "./template";
19
19
  *
20
20
  * init [dir] [--framework <spec>] write the blank factory template
21
21
  * load [tree] [--framework <x.y.z>] load once; print the Load as JSON
22
+ * check [tree] [--framework <x.y.z>] check once with the same load gate
22
23
  * dev --factory <id> [--tree <dir>] load on every save and post each load
23
24
  *
24
25
  * A load: snapshot → shape → the factory-code lint → bundle ∥ type check →
@@ -190,20 +191,27 @@ const lint = Effect.fn("factory.load.lint")(function* (tree: string, paths: Read
190
191
  });
191
192
 
192
193
  /** 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
- });
194
+ const typecheck = Effect.fn("factory.load.type")((tree: string, cache: string) => Effect.acquireUseRelease(
195
+ Effect.tryPromise({ try: () => mkdtemp(path.join(cache, "checker-")), catch: failed("Could not create the typecheck folder.") }),
196
+ (cwd) => Effect.gen(function* () {
197
+ // Bun resolves package scripts before built-ins. This private package has no scripts or check file, so the
198
+ // consumer's check script (including one that calls this loader) can never replace or recurse into the checker.
199
+ yield* Effect.tryPromise({ try: () => writeFile(path.join(cwd, "package.json"), "{}\n"), catch: failed("Could not isolate the typecheck.") });
200
+ const ran = yield* runCommand(process.execPath, ["check", "--no-pretty", "--threads=1", "-p", tree], { cwd, guardMillis: TYPE_GUARD_MILLIS });
201
+ if (ran.timedOut) return [finding("type", `The type check ran past its runaway guard of ${TYPE_GUARD_MILLIS / 1000} s and was stopped.`)];
202
+ if (ran.code !== 0 && ran.code !== 1) return yield* new FactoryLoadFailed({ message: `bun check failed (${ran.code}): ${ran.stderr.slice(0, 500)}` });
203
+ const found = `${ran.stdout}\n${ran.stderr}`.split("\n").flatMap((line) => {
204
+ const match = /^(.+?)\((\d+),\d+\): error (TS\d+): (.*)$/u.exec(line.trim());
205
+ return match === null ? [] : [finding("type", `${match[3]}: ${match[4]}`, {
206
+ file: path.relative(tree, path.resolve(cwd, match[1]!)).split(path.sep).join("/"),
207
+ line: Number(match[2]),
208
+ })];
209
+ });
210
+ if (ran.code === 1 && found.length === 0) return [finding("type", `The type check failed: ${`${ran.stdout}\n${ran.stderr}`.trim().slice(0, 500)}`)];
211
+ return found;
212
+ }),
213
+ (cwd) => Effect.promise(() => rm(cwd, { recursive: true, force: true })),
214
+ ));
207
215
 
208
216
  type BunBuild = (options: Readonly<{
209
217
  entrypoints: ReadonlyArray<string>; outdir: string; naming: string; target: "bun"; format: "esm"; sourcemap: "inline"; metafile: true; packages: "external"; throw: false;
@@ -322,7 +330,7 @@ export const loadFactory = Effect.fn("factory.load")(function* (input: Readonly<
322
330
  yield* Effect.tryPromise({ try: () => mkdir(cache, { recursive: true }), catch: failed(`Could not make the loader's cache at ${cache}.`) });
323
331
  const packages = Object.keys(dependencies).filter((name) => name !== "@thinair/factory");
324
332
  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 });
333
+ const [bundled, types] = yield* Effect.all([timed(steps, "bundle", bundle(tree, snapshot.key, cache)), timed(steps, "type", typecheck(tree, cache))], { concurrency: 2 });
326
334
  findings.push(...bundled.findings, ...types);
327
335
 
328
336
  let program: Program | null = null;
@@ -420,6 +428,7 @@ export const watchFactory = Effect.fn("factory.dev")(function* (input: Readonly<
420
428
  const USAGE = `thinair-factory: the factory loader
421
429
  init [dir] [--framework <spec>] write the blank factory template (dir: factory)
422
430
  load [tree] [--framework <x.y.z>] load once and print the Load as JSON (tree: .)
431
+ check [tree] [--framework <x.y.z>] check once; same findings and exit codes as load
423
432
  dev --factory <id> [--tree <dir>] load on every save and post each load
424
433
  `;
425
434
 
@@ -437,7 +446,7 @@ const main = Effect.fn("factory.cli")(function* (argv: ReadonlyArray<string>) {
437
446
  process.stderr.write(`Wrote ${made.files.join(", ")} to ${made.directory}. Next: bun install there, then load.\n`);
438
447
  return 0;
439
448
  }
440
- if (command === "load") {
449
+ if (command === "load" || command === "check") {
441
450
  const framework = flag(args, "--framework");
442
451
  const load = yield* loadFactory({ tree: positional(args)[0] ?? ".", ...(framework === undefined ? {} : { framework }) });
443
452
  process.stdout.write(`${JSON.stringify(Schema.encodeSync(LoadJson)(load))}\n`);
package/cli/template.ts CHANGED
@@ -50,7 +50,7 @@ true: repositories, stages of work, jobs, agents, the fleet and standing
50
50
  goals. \`@thinair/factory\` compiles it to one program; the factory's
51
51
  \`.factory\` File runs it.
52
52
 
53
- - \`bunx @thinair/factory load .\` checks it and prints the program.
53
+ - \`bunx @thinair/factory check .\` checks it and prints findings and the program.
54
54
  - \`bunx @thinair/factory dev --factory <id>\` reloads it on every save.
55
55
 
56
56
  Factory code imports only the authoring modules of \`@thinair/factory\` and
@@ -0,0 +1,12 @@
1
+ import type { AnyStateMachine, StateValue } from "xstate";
2
+
3
+ /**
4
+ * A lifecycle as the host runs it: its machine, and the state an open record re-enters from what is observed (null
5
+ * for the machine's own initial state, with its entry actions and timers).
6
+ */
7
+ export interface Lifecycle<Observed> {
8
+ readonly name: string;
9
+ readonly machine: AnyStateMachine;
10
+ readonly enter: (observed: Observed) => Readonly<{ value: StateValue; context: Readonly<Record<string, unknown>> }> | null;
11
+ }
12
+
@@ -0,0 +1,175 @@
1
+ import { setupEffect } from "@xstate/effect";
2
+ import { Schema } from "effect";
3
+ import type { Lifecycle } from "./lifecycle";
4
+
5
+ /*
6
+ * The framework's Run lifecycle (the design's 3.2, 5.3 and 8.3). A Run is one `together` group: a ticket's repository
7
+ * copy and the agent session that works in it, admitted whole and placed member by member. Its states:
8
+ *
9
+ * admitting → booting (copy → session) → working ⇄ asking → proposed
10
+ * ↘ failed (the booting runaway guard) any → stopped (Disarm, or unwanted)
11
+ *
12
+ * Every event is an observation of the Run's members, the session and the switch, with an observation key the host
13
+ * keeps once taken: `LOST{member, epoch}` is taken once, so the Run never loops back on it. The session's asks are
14
+ * read from the session (its Agent Note), never written as acts. A proposal is the one outward finalizer: it runs
15
+ * only from `proposed`, never from `stopped`.
16
+ */
17
+
18
+ export const RUN_STATES = ["admitting", "booting", "working", "asking", "proposed", "stopped", "ended", "failed"] as const;
19
+ export const RunState = Schema.Literals(RUN_STATES);
20
+ export type RunState = typeof RunState.Type;
21
+ /** A Run in one of these is over: its members stop with `rest`. */
22
+ export const RUN_ENDINGS = ["proposed", "stopped", "ended", "failed"] as const satisfies ReadonlyArray<RunState>;
23
+ export const runEnded = (state: string) => (RUN_ENDINGS as ReadonlyArray<string>).includes(state.split(".")[0] ?? "");
24
+ /** The top state of a flattened value: `booting.copy` is `booting`. */
25
+ export const runStateOf = (value: string): RunState => {
26
+ const top = value.split(".")[0] ?? "";
27
+ return (RUN_STATES as ReadonlyArray<string>).includes(top) ? (top as RunState) : "admitting";
28
+ };
29
+
30
+ const Member = Schema.Literals(["copy", "session"]);
31
+
32
+ export const RunEvents = {
33
+ /** Placement admitted the whole group. */
34
+ ADMITTED: Schema.Struct({}),
35
+ /** The copy is cloned and ready, at this epoch of its attempt. */
36
+ COPY_READY: Schema.Struct({ epoch: Schema.Number }),
37
+ /** The session's turn started on the copy, at this epoch of its attempt. */
38
+ SESSION_STARTED: Schema.Struct({ epoch: Schema.Number }),
39
+ /** The session asked its person: an open Agent Note of its own. */
40
+ ASKED: Schema.Struct({ note: Schema.String }),
41
+ /** Its note was answered, or closed. */
42
+ ANSWERED: Schema.Struct({ note: Schema.String }),
43
+ /** The session handed in its work from its own branch. */
44
+ PROPOSED: Schema.Struct({ branch: Schema.String, title: Schema.String }),
45
+ /** A member's machine or process went away at this epoch: the plane binds it again. */
46
+ LOST: Schema.Struct({ member: Member, epoch: Schema.Number }),
47
+ /** The factory was Disarmed. */
48
+ DISARMED: Schema.Struct({}),
49
+ /** Its ticket no longer wants it. */
50
+ STOP: Schema.Struct({ why: Schema.String }),
51
+ /** The session ended its work without a proposal. */
52
+ ENDED: Schema.Struct({ why: Schema.String }),
53
+ };
54
+
55
+ /** What a Run asks to be done outward, as its acts: a proposal from its own branch, or a wake to the owner. */
56
+ export const RunAct = Schema.Union([
57
+ Schema.TaggedStruct("Propose", { branch: Schema.String, title: Schema.String }),
58
+ Schema.TaggedStruct("Wake", { to: Schema.Literal("owner"), text: Schema.String }),
59
+ ]);
60
+ export type RunAct = typeof RunAct.Type;
61
+
62
+ export const RunContext = Schema.Struct({
63
+ copyEpoch: Schema.NullOr(Schema.Number),
64
+ sessionEpoch: Schema.NullOr(Schema.Number),
65
+ note: Schema.NullOr(Schema.String),
66
+ proposal: Schema.NullOr(Schema.Struct({ branch: Schema.String, title: Schema.String })),
67
+ why: Schema.NullOr(Schema.String),
68
+ });
69
+ export type RunContext = typeof RunContext.Type;
70
+
71
+ const EMPTY: RunContext = { copyEpoch: null, sessionEpoch: null, note: null, proposal: null, why: null };
72
+
73
+ /** The settings a factory may give its Runs. Changing one changes the lifecycle's version. */
74
+ export type RunSettings = Readonly<{
75
+ /** How long a Run may boot before it fails: a runaway guard, never a speed check. */
76
+ bootingGuardMillis?: number;
77
+ }>;
78
+
79
+ /** The default booting guard: a fresh clone and a first turn take minutes, never this long. */
80
+ export const BOOTING_GUARD_MILLIS = 30 * 60_000;
81
+
82
+ /** What the pass observed of a Run, for re-entering it under a new version of its lifecycle. */
83
+ export type RunObserved = Readonly<{
84
+ armed: boolean;
85
+ admitted: boolean;
86
+ copy: Readonly<{ ready: boolean; epoch: number | null }>;
87
+ session: Readonly<{ started: boolean; epoch: number | null; asking: string | null }>;
88
+ proposal: Readonly<{ branch: string; title: string }> | null;
89
+ }>;
90
+
91
+ const machineOf = (guard: number) => setupEffect({
92
+ schemas: { context: RunContext, events: RunEvents, emitted: { act: Schema.Struct({ act: RunAct }) } },
93
+ }).createMachine({
94
+ id: "run",
95
+ context: EMPTY,
96
+ initial: "admitting",
97
+ on: {
98
+ DISARMED: ({ context }) => ({ target: ".stopped", context: { ...context, why: "The factory was Disarmed." } }),
99
+ STOP: ({ context, event }) => ({ target: ".stopped", context: { ...context, why: event.why } }),
100
+ },
101
+ states: {
102
+ admitting: { on: { ADMITTED: { target: "booting" } } },
103
+ booting: {
104
+ initial: "copy",
105
+ after: { [guard]: { target: "failed" } },
106
+ on: {
107
+ // A member lost while booting is placed again inside booting: the guard keeps its deadline.
108
+ LOST: ({ context, event }) => ({ target: ".copy", context: { ...context, ...(event.member === "copy" ? { copyEpoch: null } : { sessionEpoch: null }) } }),
109
+ },
110
+ states: {
111
+ // A copy bound again after a loss, with its session still up, goes straight back: to its ask, if one is open.
112
+ copy: { on: { COPY_READY: ({ context, event }) => ({
113
+ target: context.sessionEpoch === null ? "session" : context.note !== null ? "#run.asking" : "#run.working", context: { ...context, copyEpoch: event.epoch },
114
+ }) } },
115
+ session: { on: { SESSION_STARTED: ({ context, event }) => ({ target: "#run.working", context: { ...context, sessionEpoch: event.epoch } }) } },
116
+ },
117
+ },
118
+ working: {
119
+ on: {
120
+ ASKED: ({ context, event }) => ({ target: "asking", context: { ...context, note: event.note } }),
121
+ PROPOSED: ({ context, event }) => ({ target: "proposed", context: { ...context, proposal: { branch: event.branch, title: event.title } } }),
122
+ ENDED: ({ context, event }) => ({ target: "ended", context: { ...context, why: event.why } }),
123
+ // Only the member's current attempt: a loss of an older epoch was handled when it happened.
124
+ LOST: ({ context, event }) => (event.member === "copy" ? context.copyEpoch : context.sessionEpoch) === event.epoch
125
+ ? { target: "booting", context: { ...context, ...(event.member === "copy" ? { copyEpoch: null } : { sessionEpoch: null }) } }
126
+ : undefined,
127
+ },
128
+ },
129
+ asking: {
130
+ on: {
131
+ ANSWERED: ({ context, event }) => event.note === context.note ? { target: "working", context: { ...context, note: null } } : undefined,
132
+ PROPOSED: ({ context, event }) => ({ target: "proposed", context: { ...context, note: null, proposal: { branch: event.branch, title: event.title } } }),
133
+ ENDED: ({ context, event }) => ({ target: "ended", context: { ...context, why: event.why } }),
134
+ // Its open ask is kept: once the member is back, the Run is asking again.
135
+ LOST: ({ context, event }) => (event.member === "copy" ? context.copyEpoch : context.sessionEpoch) === event.epoch
136
+ ? { target: "booting", context: { ...context, ...(event.member === "copy" ? { copyEpoch: null } : { sessionEpoch: null }) } }
137
+ : undefined,
138
+ },
139
+ },
140
+ proposed: {
141
+ type: "final",
142
+ entry: ({ context }, enq) => {
143
+ if (context.proposal !== null) enq.emit({ type: "act", act: { _tag: "Propose", branch: context.proposal.branch, title: context.proposal.title } });
144
+ },
145
+ },
146
+ stopped: { type: "final" },
147
+ ended: { type: "final" },
148
+ failed: {
149
+ type: "final",
150
+ entry: (_args, enq) => { enq.emit({ type: "act", act: { _tag: "Wake", to: "owner", text: `A run could not boot within ${Math.round(guard / 60_000)} minutes and stopped. Its copy and conversation are kept.` } }); },
151
+ },
152
+ },
153
+ });
154
+
155
+ export type RunMachine = ReturnType<typeof machineOf>;
156
+
157
+ /** The state an open Run re-enters from what is observed, under a new version of its lifecycle. */
158
+ const enter = (observed: RunObserved) => {
159
+ const context: RunContext = { ...EMPTY, copyEpoch: observed.copy.ready ? observed.copy.epoch : null, sessionEpoch: observed.session.started ? observed.session.epoch : null };
160
+ if (observed.proposal !== null) return { value: "proposed", context: { ...context, proposal: observed.proposal } };
161
+ if (!observed.armed) return { value: "stopped", context: { ...context, why: "The factory was Disarmed." } };
162
+ if (observed.session.started && observed.session.asking !== null) return { value: "asking", context: { ...context, note: observed.session.asking } };
163
+ if (observed.session.started) return { value: "working", context };
164
+ if (observed.copy.ready) return { value: { booting: "session" }, context };
165
+ if (observed.admitted) return { value: { booting: "copy" }, context };
166
+ return null;
167
+ };
168
+
169
+ /** A Run lifecycle. `runLifecycle()` is the framework's; settings give another version of it. */
170
+ export const runLifecycle = (settings: RunSettings = {}): Lifecycle<RunObserved> & Readonly<{ settings: RunSettings }> => ({
171
+ name: "run",
172
+ machine: machineOf(settings.bootingGuardMillis ?? BOOTING_GUARD_MILLIS),
173
+ enter,
174
+ settings,
175
+ });
@@ -0,0 +1,97 @@
1
+ import { setupEffect } from "@xstate/effect";
2
+ import { Schema } from "effect";
3
+ import type { TicketState } from "../ticket-state";
4
+ import type { Lifecycle } from "./lifecycle";
5
+
6
+ /*
7
+ * The framework's ticket lifecycle (the design's 3.2 `fix`, 5.1): Inbox → Ready → Running ⇄ Asking → Review → Merged.
8
+ * Its states are the product's ticket states, so its `stateOf` is the state itself.
9
+ *
10
+ * People and agents never write a ticket's state. `factory.ticket.move` posts a move, which the engine hands this
11
+ * lifecycle as `MOVE`; the ticket's Runs are observed as `RUN_*`; a merged proposal as `MERGED`. Each is an
12
+ * observation with a key, taken once.
13
+ */
14
+
15
+ export const TICKET_LIFECYCLE_STATES = ["Inbox", "Ready", "Running", "Asking", "Review", "Merged", "Dropped"] as const satisfies ReadonlyArray<TicketState>;
16
+ type LifecycleState = (typeof TICKET_LIFECYCLE_STATES)[number];
17
+ const MoveTarget = Schema.Literals(["Inbox", "Ready", "Review", "Merged", "Dropped"]);
18
+
19
+ export const TicketEvents = {
20
+ /** A person or an agent moved it (`factory.ticket.move`). */
21
+ MOVE: Schema.Struct({ to: MoveTarget }),
22
+ /** One of its Runs started, asked, went back to work, proposed, stopped or ended. */
23
+ RUN_STARTED: Schema.Struct({ run: Schema.String }),
24
+ RUN_ASKING: Schema.Struct({ run: Schema.String }),
25
+ RUN_WORKING: Schema.Struct({ run: Schema.String }),
26
+ RUN_PROPOSED: Schema.Struct({ run: Schema.String }),
27
+ RUN_STOPPED: Schema.Struct({ run: Schema.String }),
28
+ RUN_ENDED: Schema.Struct({ run: Schema.String, why: Schema.String }),
29
+ /** Its proposal merged. */
30
+ MERGED: Schema.Struct({}),
31
+ };
32
+
33
+ const TicketContext = Schema.Struct({ run: Schema.NullOr(Schema.String) });
34
+
35
+ const machine = setupEffect({ schemas: { context: TicketContext, events: TicketEvents } }).createMachine({
36
+ id: "ticket",
37
+ context: { run: null },
38
+ initial: "Inbox",
39
+ states: {
40
+ Inbox: {
41
+ on: { MOVE: ({ event }) => event.to === "Ready" ? { target: "Ready" } : event.to === "Dropped" ? { target: "Dropped" } : undefined },
42
+ },
43
+ Ready: {
44
+ on: {
45
+ RUN_STARTED: ({ event }) => ({ target: "Running", context: { run: event.run } }),
46
+ MOVE: ({ event }) => event.to === "Inbox" ? { target: "Inbox" } : event.to === "Dropped" ? { target: "Dropped" } : undefined,
47
+ },
48
+ },
49
+ Running: {
50
+ on: {
51
+ RUN_ASKING: ({ context, event }) => event.run === context.run ? { target: "Asking" } : undefined,
52
+ RUN_PROPOSED: ({ context, event }) => event.run === context.run ? { target: "Review" } : undefined,
53
+ RUN_STOPPED: ({ context, event }) => event.run === context.run ? { target: "Ready", context: { run: null } } : undefined,
54
+ // A Run that ended without a proposal (it failed to boot, or its session stopped) waits for a person in Inbox.
55
+ RUN_ENDED: ({ context, event }) => event.run === context.run ? { target: "Inbox", context: { run: null } } : undefined,
56
+ MOVE: ({ event }) => event.to === "Inbox" ? { target: "Inbox" } : event.to === "Dropped" ? { target: "Dropped" } : undefined,
57
+ },
58
+ },
59
+ Asking: {
60
+ on: {
61
+ RUN_WORKING: ({ context, event }) => event.run === context.run ? { target: "Running" } : undefined,
62
+ RUN_PROPOSED: ({ context, event }) => event.run === context.run ? { target: "Review" } : undefined,
63
+ RUN_STOPPED: ({ context, event }) => event.run === context.run ? { target: "Ready", context: { run: null } } : undefined,
64
+ RUN_ENDED: ({ context, event }) => event.run === context.run ? { target: "Inbox", context: { run: null } } : undefined,
65
+ MOVE: ({ event }) => event.to === "Dropped" ? { target: "Dropped" } : undefined,
66
+ },
67
+ },
68
+ Review: {
69
+ on: {
70
+ MERGED: { target: "Merged" },
71
+ MOVE: ({ event }) => event.to === "Ready" ? { target: "Ready", context: { run: null } } : event.to === "Merged" ? { target: "Merged" }
72
+ : event.to === "Dropped" ? { target: "Dropped" } : undefined,
73
+ },
74
+ },
75
+ Merged: { type: "final" },
76
+ Dropped: { type: "final" },
77
+ },
78
+ });
79
+
80
+ /** What the pass observed of a ticket: its row's state, and its open Run. */
81
+ export type TicketObserved = Readonly<{ state: TicketState; run: string | null }>;
82
+
83
+ const isLifecycleState = (state: string): state is LifecycleState => (TICKET_LIFECYCLE_STATES as ReadonlyArray<string>).includes(state);
84
+
85
+ /** The ticket re-enters the state its row holds: a new ticket the state it was filed in. */
86
+ const enter = (observed: TicketObserved) => ({ value: isLifecycleState(observed.state) ? observed.state : "Inbox", context: { run: observed.run } });
87
+
88
+ /** A ticket's state from its lifecycle's: the product's ticket states, one for one. */
89
+ export const ticketStateOf = (value: string): TicketState => {
90
+ const top = value.split(".")[0] ?? "";
91
+ return isLifecycleState(top) ? top : "Inbox";
92
+ };
93
+
94
+ export type TicketLifecycle = Lifecycle<TicketObserved> & Readonly<{ stateOf: (value: string) => TicketState }>;
95
+
96
+ /** The framework's ticket lifecycle. */
97
+ export const ticketLifecycle = (): TicketLifecycle => ({ name: "ticket", machine, enter, stateOf: ticketStateOf });
@@ -29,6 +29,10 @@ export const AUTHORING_MODULES = [
29
29
  "@thinair/factory/domain/system-elements/hole",
30
30
  "@thinair/factory/domain/system-elements/compose",
31
31
  "@thinair/factory/domain/step",
32
+ "@thinair/factory/domain/ticket-state",
33
+ "@thinair/factory/domain/lifecycles/lifecycle",
34
+ "@thinair/factory/domain/lifecycles/ticket",
35
+ "@thinair/factory/domain/lifecycles/run",
32
36
  ] as const;
33
37
  export type AuthoringModule = (typeof AUTHORING_MODULES)[number];
34
38
 
@@ -15,6 +15,8 @@ export const PullRequestFact = Schema.Struct({
15
15
  number: Schema.Int.check(Schema.isGreaterThan(0)),
16
16
  head: Commit,
17
17
  base: Schema.String,
18
+ /** The observed base commit: selection is against this exact tree, never a moving branch. */
19
+ baseCommit: Schema.optionalKey(Commit),
18
20
  branch: Schema.String,
19
21
  state: Schema.Literals(["open", "closed", "merged"]),
20
22
  draft: Schema.Boolean,
@@ -1,4 +1,6 @@
1
1
  import { Schema } from "effect";
2
+ import { TICKET_STATES } from "../ticket-state";
3
+ import type { Lifecycle } from "../lifecycles/lifecycle";
2
4
  import type { Out, Question, Seen, TicketView, Work } from "./compose";
3
5
  import { AUTHORED, authoredHead, head, Name, Ref, type Authored, type Encoder, type LiveRows, type Slot } from "./node";
4
6
 
@@ -8,9 +10,21 @@ import { AUTHORED, authoredHead, head, Name, Ref, type Authored, type Encoder, t
8
10
  * and returns what should exist now: wanted jobs and services, child
9
11
  * tickets, pins, judgments and acts. Compose may want only the jobs the
10
12
  * stage lists, and types enforce it. A stage's ticket state comes from its
11
- * verdict, or from its lifecycle when it has one.
13
+ * verdict, or from its lifecycle when it has one: a typed `stateOf` maps each
14
+ * of the lifecycle's states to one of the product's ticket states, checked at
15
+ * load. The Runs its `together` groups make follow the framework's Run
16
+ * lifecycle, or the one `runs` gives (another version of it).
12
17
  */
13
18
 
19
+ /** A ticket lifecycle as a stage declares it: the machine, and the product state of each of its states. */
20
+ export type StageTicketLifecycle = Lifecycle<never> & Readonly<{ stateOf: (value: string) => string }>;
21
+ /** A Run lifecycle as a stage declares it. */
22
+ export type StageRunLifecycle = Lifecycle<never>;
23
+
24
+ /** The states a lifecycle's machine has at its top. */
25
+ const statesOf = (lifecycle: Lifecycle<never>): ReadonlyArray<string> =>
26
+ Object.keys((lifecycle.machine as unknown as Readonly<{ root: Readonly<{ states: Readonly<Record<string, unknown>> }> }>).root.states);
27
+
14
28
  export const Node = Schema.TaggedStruct("Stage", {
15
29
  ...head,
16
30
  /** The only jobs and services compose may want. */
@@ -19,6 +33,10 @@ export const Node = Schema.TaggedStruct("Stage", {
19
33
  children: Schema.Array(Ref("Stage")),
20
34
  /** Questions compose may ask the judge, each a choice one of its services offers. */
21
35
  questions: Schema.Record(Name, Ref("Service")),
36
+ /** Its tickets' lifecycle, by its states; absent when its verdict decides their state. */
37
+ lifecycle: Schema.optionalKey(Schema.Struct({ name: Schema.String, states: Schema.Array(Schema.String) })),
38
+ /** The Run lifecycle its `together` groups follow, when it gives its own. */
39
+ runs: Schema.optionalKey(Schema.Struct({ name: Schema.String, states: Schema.Array(Schema.String) })),
22
40
  });
23
41
  export type Node = typeof Node.Type;
24
42
 
@@ -27,6 +45,10 @@ export interface AuthoredStage<N extends string = string, Spec = any, W extends
27
45
  readonly jobs: ReadonlyArray<W>;
28
46
  readonly children: ReadonlyArray<C>;
29
47
  readonly questions: Readonly<Record<string, Question<unknown>>>;
48
+ /** Its tickets' lifecycle; null when its verdict decides their state. */
49
+ readonly lifecycle: StageTicketLifecycle | null;
50
+ /** The Run lifecycle its groups follow; null for the framework's. */
51
+ readonly runs: StageRunLifecycle | null;
30
52
  readonly compose: (ticket: TicketView<Spec>, seen: Seen<W>) => ReadonlyArray<Out<W, C>>;
31
53
  }
32
54
  export type AnyStage = AuthoredStage<string, any, any, any>;
@@ -38,6 +60,10 @@ export const stage = <const N extends string, Spec, const W extends ReadonlyArra
38
60
  jobs?: W;
39
61
  children?: C;
40
62
  questions?: Readonly<Record<string, Question<any>>>;
63
+ /** Its tickets' lifecycle: `ticketLifecycle()` from `@thinair/factory/domain/lifecycles/ticket`. */
64
+ lifecycle?: StageTicketLifecycle;
65
+ /** Its Runs' lifecycle: `runLifecycle({ ... })` from `@thinair/factory/domain/lifecycles/run`. */
66
+ runs?: StageRunLifecycle;
41
67
  compose: (ticket: TicketView<Spec>, seen: Seen<W[number]>) => ReadonlyArray<Out<W[number], C[number]>>;
42
68
  }>): AuthoredStage<N, Spec, W[number], C[number]> => ({
43
69
  [AUTHORED]: authoredHead("Stage", name, true),
@@ -45,6 +71,8 @@ export const stage = <const N extends string, Spec, const W extends ReadonlyArra
45
71
  jobs: settings.jobs ?? [],
46
72
  children: settings.children ?? [],
47
73
  questions: settings.questions ?? {},
74
+ lifecycle: settings.lifecycle ?? null,
75
+ runs: settings.runs ?? null,
48
76
  compose: settings.compose,
49
77
  });
50
78
 
@@ -57,8 +85,20 @@ export const make = (authored: AnyStage, encoder: Encoder) => ({
57
85
  if (!authored.jobs.includes(question.service)) encoder.problem(`Question ${name} asks ${question.service[AUTHORED].name}, which this stage does not list in its jobs.`);
58
86
  return [name, encoder.ref("Service", question.service)];
59
87
  })),
88
+ ...(authored.lifecycle == null ? {} : { lifecycle: lifecycleOf(authored.lifecycle, encoder) }),
89
+ ...(authored.runs == null ? {} : { runs: { name: authored.runs.name, states: [...statesOf(authored.runs)] } }),
60
90
  });
61
91
 
92
+ /** A ticket lifecycle's states, each checked to map to one of the product's ticket states. */
93
+ const lifecycleOf = (lifecycle: StageTicketLifecycle, encoder: Encoder) => {
94
+ const states = statesOf(lifecycle);
95
+ for (const state of states) {
96
+ const product = lifecycle.stateOf(state);
97
+ if (!(TICKET_STATES as ReadonlyArray<string>).includes(product)) encoder.problem(`Its lifecycle's state ${state} maps to ${product}, which is not a ticket state.`);
98
+ }
99
+ return { name: lifecycle.name, states: [...states] };
100
+ };
101
+
62
102
  /** Its tickets by state. */
63
103
  export const live = (node: Node, rows: LiveRows) => {
64
104
  const byState: Record<string, number> = {};
@@ -0,0 +1,5 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const TICKET_STATES = ["Inbox", "Ready", "Running", "Asking", "Review", "Merged", "Shipped", "Waiting", "Dropped"] as const;
4
+ export const TicketState = Schema.Literals(TICKET_STATES);
5
+ export type TicketState = typeof TicketState.Type;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinair/factory",
3
- "version": "0.1.120",
3
+ "version": "0.1.128",
4
4
  "description": "The Thinair factory SDK: the language a factory's code is written in, and the loader that checks it.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,13 +21,19 @@
21
21
  "./domain/system-elements/hole": "./domain/system-elements/hole.ts",
22
22
  "./domain/system-elements/compose": "./domain/system-elements/compose.ts",
23
23
  "./domain/step": "./domain/step.ts",
24
+ "./domain/ticket-state": "./domain/ticket-state.ts",
25
+ "./domain/lifecycles/lifecycle": "./domain/lifecycles/lifecycle.ts",
26
+ "./domain/lifecycles/ticket": "./domain/lifecycles/ticket.ts",
27
+ "./domain/lifecycles/run": "./domain/lifecycles/run.ts",
24
28
  "./domain/system-elements/program": "./domain/system-elements/program.ts",
25
29
  "./domain/load": "./domain/load.ts",
26
30
  "./cli/load": "./cli/load.ts"
27
31
  },
28
32
  "dependencies": {
29
33
  "effect": "4.0.0-rc.117",
30
- "oxlint": "1.88.0"
34
+ "oxlint": "1.88.0",
35
+ "xstate": "6.0.0-alpha.66",
36
+ "@xstate/effect": "0.1.0-alpha.7"
31
37
  },
32
38
  "engines": {
33
39
  "bun": ">=1.4.3"