Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions docs/st3/program-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,15 +389,17 @@ command (`Fractal.command(argv, { priority: 'background' })`); this is the progr

### 11. Program API

The author-facing API is the `Fractal` namespace of the `fractal` package (D7), on `effect/workflow`:
The author-facing API is the `Fractal` namespace of the `fractal` package's `fractal/effect` entry
point (D7), on `effect/workflow`:

| `Fractal` | `effect/workflow` | Engine |
|---|---|---|
| `Fractal.mission(intent, program)` | `Workflow.make` with `intent.input` as payload and `intent.output` as success schema | one `program` step; the bundle's entry runs the workflow body for the step run's current attempt |
| `Fractal.step({ id, success, error }, effect)` | `Activity.make` named by the step's key (`id`, `id#2`, …) | a `program.step` entry under that key, replayed by the key |
| `Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))` | `DurableDeferred.make` named by the step's key | a worker turn: a linked child run of `mission` (§6); the value is its product `resource/<child run>/<product>` |
| `Fractal.ask({ id, for, request, deadline, default })` | `DurableDeferred.make` named by the ask's key | a native person ask on the program step; the answer is journaled under the key |
| `Fractal.run(mission, input)` | a child workflow execution | a linked child run (§6), its terminal result journaled under the key |
| — | `WorkflowEngine` | the host's engine: `activityExecute` returns a journaled result or runs the activity and records it through the host protocol; `deferredResult` reads the journal |
| `Fractal.run(mission, input)` | `DurableDeferred.make` named by the run's key | a linked child run (§6), its terminal result journaled under the key |
| — | `WorkflowEngine` | the host's engine: `activityExecute` returns a journaled result or runs the activity and records it through the host protocol; `deferredResult` reads the journal, else waits for the host's `ctx.child` or `ctx.ask` |

Inputs and outputs are Effect Schemas. `intent.input` is a Struct: each field renders as a native
mission `input`, `missions start` checks the inputs against it, and the program receives the decoded
Expand All @@ -407,6 +409,15 @@ value. The success value is encoded with `intent.output` and journaled under the
A program waiting on an ask or a child run stays loaded in the host. A replaced host replays the
program to the same wait.

**Publication.** The `fractal` package's tree loader (`loadSubjectTree({ root, bundleDir })`)
bundles each `<id>/mission.ts` that declares a program with `bun build --target=bun`, from the tree
root so the bytes do not depend on where the tree is checked out, and writes it to
`<bundleDir>/<sha256>.mjs` after checking that its default export carries the entry. The mission
loads as its declaration: the header from the intent (`timeout`, `concurrentRuns`, `authority`),
one text `input` per field of `intent.input`, and one step `program` pinning the bundle by path and
sha256 (`stalledAfter` becomes its `stalled-after`). Rendering it and running `fractal apply` on the
KDL publishes the mission and carries the bundle (§4).

## Design questions

- **DQ1 Bundle distribution:** decided: replicated blobs stored at `apply` (§4).
Expand Down
3 changes: 2 additions & 1 deletion packages/fractal/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Authoritative grammar: `compoundingtech/smalltalk`, `crates/st3/src/graph.rs` an
| `resource` | Partial | vcs.repository, filesystem.file, vcs.pull-request and vcs.ref. Additional native resource kinds are not modeled. |
| `doc`, `observer`, `subscription` | Partial | Immutable document bindings and step pins, named/pinned document gates, github.ref observers selecting head/ancestors, and message subscriptions with field conditions. Mission/step/round declarations preserve native run scoping and alias rewriting. Other observer providers and subscription delivery modes are not modeled. |
| mission `lane` | Covered | Unique run-owned names, optional slash-terminated `entries` prefixes and `person/` approvers. Lane blocks precede execution blocks and omit absent children. Root lane declarations are not modeled. |
| step `program`, mission `authority`, `concurrent-runs` | Covered | Absolute bundle path, lowercase 64-hex `sha256` and optional `stalled-after`; a program step has no assignee, gates, products, exec or produced/used mission and is never a finally step. Authority rules `publish`, `start`, `revise` (mission IDs) and `write` (subjects), exact or `/*`, rendered in canonical order; the native parser checks `write` subjects against the schema registry. `cancel` is not offered. |
| `objective` | Covered | `Fractal.objective`, `indicator.*` and `response.fault` check the native reader's rules (`fractal_kernel::objective`) and `objectiveNode` lowers every indicator, target, window and breach response. A subject tree declares interval objectives only; native telemetry objectives are rendered by `slo/objectives.kdl.genie.ts`. |
| `account`, `pty`, `host`, `person`, `mission-run`, `planning-session`, `message`, `repair`, `stop` | Not covered | Additional root grammar nodes omitted. |
| `version 2` | Covered | All emitted documents start with this directive. |
Expand All @@ -38,7 +39,7 @@ Schedule work accepts either a pinned `mission@revision` or an unpinned mission

## Subject trees (`fractal/tree`, Node only)

`loadSubjectTree({ root, context })` discovers `agent.ts|kdl`, `mission.ts|kdl`, `schedule.ts|kdl` and `objective.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected.
`loadSubjectTree({ root, context, bundleDir? })` discovers `agent.ts|kdl`, `mission.ts|kdl`, `schedule.ts|kdl` and `objective.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected. A `mission.ts` declaring a program (`fractal/effect`) is bundled with `bun build --target=bun` into the absolute `bundleDir` as `<sha256>.mjs` and loads as a mission with one `program` step pinning it; the loader refuses a program tree without `bundleDir`, a declared id other than the directory, and a bundle whose default export lacks the program entry. `src/program-loader.unit.test.ts` covers rendering against `fixtures/land-program.kdl`, determinism and refusals; with `ST_BIN` it also runs `fractal validate` on the rendering, which checks the bundle's sha256.

TS modules export an intent, a context factory, or `defineAgent|defineMission|defineSchedule({ meta: import.meta, intent })`. Typed agent refs are accepted for `under.target`, required mission `reportTo`, mission/step `assignedTo`, and loop/final workers; they lower to validated `{ id }` agent references, never legacy assignee strings. Typed mission refs lower to unpinned schedule work IDs. Plain imported declarations and attributed people retain the authoring schemas' contracts.

Expand Down
31 changes: 28 additions & 3 deletions packages/fractal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Typed authoring for Fractal declarations: agents, missions, schedules and object
| Entrypoint | Runtime | Contents |
|---|---|---|
| `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule`, `objectiveNode` nodes, `emit`, `fractalKdl`) as named exports |
| `fractal/tree` | Node | `loadSubjectTree` plus the define forms and reference types |
| `fractal/tree` | Node | `loadSubjectTree`, `programMission`, the define forms and reference types |
| `fractal/testing` | Node | `scratchDaemonLayer`, an isolated daemon for conformance tests |
| `fractal/effect` | Bun program host, isomorphic | The `Fractal` namespace with the program forms of `mission` and `step`, plus `ask`, `run`, `command`, `env`, `cwd` and `Worker`; the host contract (`ProgramEntry`, `HostContextV1`) as named exports |

Expand Down Expand Up @@ -75,7 +75,7 @@ The namespace also holds `step`, `gate`, `person`, `input`, `product`, `t`, `doc

## Loading and rendering

`loadSubjectTree({ root, context })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, decodes the result with the strict schemas, and checks objectives with `Fractal.objective`. Render a loaded subject with the named exports:
`loadSubjectTree({ root, context, bundleDir? })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, decodes the result with the strict schemas, and checks objectives with `Fractal.objective`. Program missions are bundled into `bundleDir` (see [Publishing a program](#publishing-a-program)). Render a loaded subject with the named exports:

```ts
import { agent, emit, mission, objectiveNode } from 'fractal'
Expand Down Expand Up @@ -131,7 +131,32 @@ export default Fractal.mission(

Every `input` field must be required and encode to a string, since each one is a native `input "name" kind="text"`; a payload that does not decode refuses the run with `InvalidInput`. Without `error`, a step or program journals any tagged error as `_tag` plus its fields and the step fails with that plain object (typed `Journaled<E>`), live and on replay, so `catchTag` matches; an untagged error is a defect. With `error`, the schema rebuilds the error as its class, which a program's `error` class needs. Only one fiber may use a step id: concurrent branches would take `id#n` keys in completion order, which replay does not reproduce, so a second branch using the id is a defect. A journal entry the daemon refuses ends the attempt with a defect that the body cannot catch.

A program mission's default export carries `[ProgramEntry]` (`Symbol.for('fractal.program.v1')`), the entry the program host calls with a `HostContextV1`; `fixtures/programs/factory/land-program` is the factory's landing program, and `src/program-bundle.unit.test.ts` runs its `bun build --target=bun` bundle. Bundling a program and rendering its `program` step declaration are not part of `loadSubjectTree` yet.
A program mission's default export carries `[ProgramEntry]` (`Symbol.for('fractal.program.v1')`), the entry the program host calls with a `HostContextV1`. `fixtures/programs/factory/land-program` is the factory's landing program; `src/program-bundle.unit.test.ts` runs its bundle against an in-memory host.

### Publishing a program

`loadSubjectTree({ root, context, bundleDir })` bundles every program mission it loads with `bun build --target=bun` (`bun` on `PATH`, or the `bun` option) and writes the bundle to `<bundleDir>/<sha256>.mjs`; `bundleDir` must be absolute, and a tree with a program mission and no `bundleDir` is refused. Bun names inlined modules by their path from the tree root, so the same tree and lockfile give the same bytes wherever they are checked out. Before it pins the bundle the loader checks that its default export carries the entry. The mission then loads like any other, so `emit([mission(intent)])` renders it:

```kdl
mission "factory/land-program" report-to="agent/factory" state="ready" timeout="2h" {
concurrent-runs max=8
input "change" kind="text"
input "reviewer" kind="text"
goal "Land one change on main and prove it live, or leave main as it was."
authority {
publish "scratch/*"
start "factory/step/review"
write "resource/factory/*"
}
step "program" timeout="2h" {
program "/bundles/<sha256>.mjs" sha256="<sha256>" stalled-after="10m"
}
}
```

The intent's `timeout` bounds the mission and its program step, `stalledAfter` becomes the program's `stalled-after`, `concurrentRuns` becomes `concurrent-runs max=N`, and `authority` (`publish`, `start`, `revise` and `write` rules, rendered in canonical order) becomes the run authority block. `fractal validate` checks the KDL and that each bundle still has its pinned sha256; `fractal apply` publishes the mission and carries the bundle to the daemon. `fixtures/land-program.kdl` is the landing program's rendering; after an intended change to the program or to code its bundle inlines, `FRACTAL_UPDATE_GOLDENS=1 pnpm --filter fractal test` rewrites it.

A loader that walks its own tree renders one program mission the same way with `programMission({ root, file, definition, bundleDir, bun? })`: `file` is a `mission.ts` below the tree `root`, `definition` its default export, and the subject ID its real directory below `root`'s real path. The result is `{ kdl, bundle, sha256 }`, the bytes `loadSubjectTree` would render for that subject.

## Checks

Expand Down
15 changes: 15 additions & 0 deletions packages/fractal/fixtures/land-program.kdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version 2
mission "factory/land-program" report-to="agent/factory" state="ready" timeout="2h" {
concurrent-runs max=8
input "change" kind="text"
input "reviewer" kind="text"
goal "Land one change on main and prove it live, or leave main as it was."
authority {
publish "scratch/*"
start "factory/step/review"
write "resource/factory/*"
}
step "program" timeout="2h" {
program "/bundles/1fbec08c0ddb3fb320a50696b91a1bdb33f429b6bd6a0aab5b1c1f5b8e5dff7d.mjs" sha256="1fbec08c0ddb3fb320a50696b91a1bdb33f429b6bd6a0aab5b1c1f5b8e5dff7d" stalled-after="10m"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Fractal } from '../../../../src/index.ts'

export default Fractal.agent({ meta: import.meta, workspace: '/work/factory', restart: 'always' })
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Effect, Schema } from 'effect'

import { Fractal } from '../../../../../src/effect.ts'
import factory from '../agent.ts'

/** Declares an id that is not its directory. */
export default Fractal.mission(
{
meta: import.meta,
id: 'factory/elsewhere',
goal: 'Land.',
reportTo: factory,
input: Schema.Struct({ change: Schema.String }),
},
() => Effect.void,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Fractal } from '../../../../src/index.ts'

export default Fractal.agent({ meta: import.meta, workspace: '/work/factory', restart: 'always' })
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Effect, Schema } from 'effect'

import { Fractal } from '../../../../../src/effect.ts'
import factory from '../agent.ts'

/** A number input does not encode to a native text input. */
export default Fractal.mission(
{
meta: import.meta,
id: 'factory/land',
goal: 'Land.',
reportTo: factory,
// @ts-expect-error a number does not encode to a native text input
input: Schema.Struct({ attempts: Schema.Number }),
},
() => Effect.void,
)
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export default Fractal.mission(
goal: 'Land one change on main and prove it live, or leave main as it was.',
reportTo: factory,
timeout: '2h',
stalledAfter: '10m',
concurrentRuns: 8,
authority: {
publish: ['scratch/*'],
start: ['factory/step/review'],
write: ['resource/factory/*'],
},
input: Schema.Struct({ change: Schema.String, reviewer: Schema.Literals(['llm', 'scripted']) }),
output: Schema.Struct({ class: steps.Class, merged: Schema.String, head: Schema.String }),
},
Expand Down
Loading
Loading