@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 +26 -1
- package/cli/load.ts +26 -17
- package/cli/template.ts +1 -1
- package/domain/lifecycles/lifecycle.ts +12 -0
- package/domain/lifecycles/run.ts +175 -0
- package/domain/lifecycles/ticket.ts +97 -0
- package/domain/system-elements/app-shape.ts +4 -0
- package/domain/system-elements/repository.ts +2 -0
- package/domain/system-elements/stage.ts +41 -1
- package/domain/ticket-state.ts +5 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -32,7 +32,12 @@ bunx @thinair/factory load .
|
|
|
32
32
|
|
|
33
33
|
## Check it
|
|
34
34
|
|
|
35
|
-
`
|
|
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")(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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.
|
|
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"
|