@chernabog/sdk 0.0.0-stage → 0.2.0
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 +69 -2
- package/package.json +22 -4
- package/src/errors.ts +33 -0
- package/src/flow.ts +155 -0
- package/src/implement.ts +502 -0
- package/src/index.ts +10 -0
- package/src/json.ts +18 -0
- package/src/markers.ts +39 -0
- package/src/paths.ts +38 -0
- package/src/prompt.ts +37 -0
- package/src/review.ts +400 -0
- package/src/services.ts +281 -0
- package/src/stage.ts +25 -0
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chernabog/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The services and helpers for writing Chernabog flows and steps. At runtime the `chernabog` binary supplies its own copy of this package and of `effect`, so a repo needs them only as devDependencies, for types.
|
|
4
|
+
|
|
5
|
+
## Flows
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineFlow, Implement, step } from '@chernabog/sdk';
|
|
9
|
+
import { Effect, Schema } from 'effect';
|
|
10
|
+
|
|
11
|
+
export default defineFlow({
|
|
12
|
+
trigger: Implement.trigger, // the work items to act on
|
|
13
|
+
gate: Implement.gate, // checked before each run starts; fail with Skip to hold it back
|
|
14
|
+
concurrency: 1,
|
|
15
|
+
workflow: (item) =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const issue = Implement.issueOf(item);
|
|
18
|
+
const { branch } = yield* Implement.claim(issue);
|
|
19
|
+
const checked = yield* step('check-scope', { issue }, { output: Schema.Struct({ ok: Schema.Boolean }) });
|
|
20
|
+
// ...
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Helper | What it does |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `activity(name, effect, { success })` | One durable step. It runs once and its result is saved; a replay returns the saved result. Names are unique within a run, so a step in a loop carries its round: `fix-ci-2`. |
|
|
28
|
+
| `step(name, input, { output })` | Runs `.chernabog/stages/<name>` in its own process, as an activity. |
|
|
29
|
+
| `sleep(name, duration)` | A durable pause that survives restarts. |
|
|
30
|
+
| `awaitSignal(signals, { id, timeout })` | Waits for one of the signals (which the trigger reports in `WorkItem.signals`) or the timeout, whichever comes first, and says which. A trigger can report an item with `wakeOnly` to wake a waiting run without starting a new one. |
|
|
31
|
+
| `renderPrompt(name, values)` | Fills `.chernabog/prompts/<name>.md`. |
|
|
32
|
+
| `Skip`, `NeedsHuman` | End a run cleanly, or hand its item to a person. |
|
|
33
|
+
|
|
34
|
+
`Implement` and `Review` hold the building blocks of the default flows, each an activity a flow can reorder, leave out or wrap.
|
|
35
|
+
|
|
36
|
+
Side effects belong inside activities or steps, never in the workflow body, which runs again on every replay.
|
|
37
|
+
|
|
38
|
+
## Steps
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// .chernabog/stages/find-work.ts
|
|
42
|
+
import { defineStage, GitHub, Skip } from '@chernabog/sdk';
|
|
43
|
+
import { Effect, Schema } from 'effect';
|
|
44
|
+
|
|
45
|
+
export default defineStage({
|
|
46
|
+
name: 'find-work',
|
|
47
|
+
input: Schema.Struct({}),
|
|
48
|
+
output: Schema.Struct({ issue: Schema.Number }),
|
|
49
|
+
run: () =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const issue = yield* (yield* GitHub).oldestQueued;
|
|
52
|
+
if (issue === undefined) return yield* new Skip({ reason: 'nothing queued' });
|
|
53
|
+
return { issue };
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Services
|
|
59
|
+
|
|
60
|
+
| Service | Covers |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `Repo` | The repo's merged settings: slug, base branch, labels, limits, gates, notes |
|
|
63
|
+
| `Run` | The run: flow, run id and work item |
|
|
64
|
+
| `GitHub` | Queued issues, claims, labels, comments, branches, pull requests, CI state |
|
|
65
|
+
| `Agent` | Starting and resuming agent sessions through an adapter |
|
|
66
|
+
| `Git`, `Worktree` | Commits ahead, changed files, pushes; one worktree per branch |
|
|
67
|
+
| `Usage` | The account check and the usage gate |
|
|
68
|
+
| `RunLog` | The run's log |
|
|
69
|
+
|
|
70
|
+
Each service is a layer, so a team can replace one with its own implementation, and tests can use fakes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chernabog/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Services and helpers for writing Chernabog flows and steps",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dataquail/chernabog.git",
|
|
9
|
+
"directory": "packages/sdk"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"effect": "4.0.5"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as Schema from 'effect/Schema';
|
|
2
|
+
|
|
3
|
+
// Ends a run cleanly: nothing to do, or a gate said not now. Never a failure.
|
|
4
|
+
export class Skip extends Schema.TaggedError<Skip, { readonly brand: 'Skip' }>()('Skip', {
|
|
5
|
+
reason: Schema.String,
|
|
6
|
+
}) {}
|
|
7
|
+
|
|
8
|
+
// Hands a work item to a person: the flow labels it and no flow picks it up again.
|
|
9
|
+
export class NeedsHuman extends Schema.TaggedError<NeedsHuman, { readonly brand: 'NeedsHuman' }>()('NeedsHuman', {
|
|
10
|
+
reason: Schema.String,
|
|
11
|
+
}) {}
|
|
12
|
+
|
|
13
|
+
export class GitHubError extends Schema.TaggedError<GitHubError, { readonly brand: 'GitHubError' }>()('GitHubError', {
|
|
14
|
+
message: Schema.String,
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
export class GitError extends Schema.TaggedError<GitError, { readonly brand: 'GitError' }>()('GitError', {
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
export class AgentError extends Schema.TaggedError<AgentError, { readonly brand: 'AgentError' }>()('AgentError', {
|
|
22
|
+
message: Schema.String,
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
export class UsageError extends Schema.TaggedError<UsageError, { readonly brand: 'UsageError' }>()('UsageError', {
|
|
26
|
+
message: Schema.String,
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
// A step or activity that failed for any other reason, named so the run log says which.
|
|
30
|
+
export class StepFailed extends Schema.TaggedError<StepFailed, { readonly brand: 'StepFailed' }>()('StepFailed', {
|
|
31
|
+
step: Schema.String,
|
|
32
|
+
message: Schema.String,
|
|
33
|
+
}) {}
|
package/src/flow.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Flows and the durable building blocks inside them. A flow's workflow is an
|
|
2
|
+
// Effect that the daemon runs durably: every activity's result is saved, so after
|
|
3
|
+
// a crash or restart the workflow replays the saved results and continues after
|
|
4
|
+
// the last completed one. Side effects belong inside activities, never in the
|
|
5
|
+
// workflow body, because the body runs again on every replay.
|
|
6
|
+
|
|
7
|
+
import * as Context from 'effect/Context';
|
|
8
|
+
import * as Duration from 'effect/Duration';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import type * as FileSystem from 'effect/FileSystem';
|
|
11
|
+
import * as Option from 'effect/Option';
|
|
12
|
+
import * as Predicate from 'effect/Predicate';
|
|
13
|
+
import type * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
14
|
+
import * as Schema from 'effect/Schema';
|
|
15
|
+
import type * as Scope from 'effect/Scope';
|
|
16
|
+
import * as Activity from 'effect/workflow/Activity';
|
|
17
|
+
import * as DurableClock from 'effect/workflow/DurableClock';
|
|
18
|
+
import * as DurableDeferred from 'effect/workflow/DurableDeferred';
|
|
19
|
+
import type * as WorkflowEngine from 'effect/workflow/WorkflowEngine';
|
|
20
|
+
|
|
21
|
+
import { NeedsHuman, Skip, StepFailed } from './errors.ts';
|
|
22
|
+
import type { Json } from './json.ts';
|
|
23
|
+
import { type Agent, type Git, type GitHub, type Repo, type Run, RunLog, type Usage, type WorkItem, type Worktree } from './services.ts';
|
|
24
|
+
|
|
25
|
+
// Runs a step from .chernabog/stages/ in its own process. Provided by Chernabog.
|
|
26
|
+
export class StepRunner extends Context.Service<
|
|
27
|
+
StepRunner,
|
|
28
|
+
{
|
|
29
|
+
run(name: string, input: Json): Effect.Effect<Json, Skip | StepFailed>;
|
|
30
|
+
}
|
|
31
|
+
>()('@chernabog/sdk/StepRunner') {}
|
|
32
|
+
|
|
33
|
+
// Records that a run waits on a signal, so the next tick's trigger can wake it. Provided by Chernabog.
|
|
34
|
+
export class Signals extends Context.Service<
|
|
35
|
+
Signals,
|
|
36
|
+
{
|
|
37
|
+
register(signal: string, deferred: string, token: string): Effect.Effect<void>;
|
|
38
|
+
}
|
|
39
|
+
>()('@chernabog/sdk/Signals') {}
|
|
40
|
+
|
|
41
|
+
export type TriggerServices = Repo | GitHub | Git | Worktree | Usage | Agent | RunLog | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner;
|
|
42
|
+
|
|
43
|
+
export type WorkflowServices =
|
|
44
|
+
| TriggerServices
|
|
45
|
+
| Run
|
|
46
|
+
| StepRunner
|
|
47
|
+
| Signals
|
|
48
|
+
| WorkflowEngine.WorkflowEngine
|
|
49
|
+
| WorkflowEngine.WorkflowInstance
|
|
50
|
+
| Scope.Scope;
|
|
51
|
+
|
|
52
|
+
export type FlowError = Skip | NeedsHuman | StepFailed;
|
|
53
|
+
|
|
54
|
+
// What a flow's trigger, gate and workflow may fail with: anything, which the engine reports by its message.
|
|
55
|
+
export type FlowFailure = { readonly _tag: string } | Error;
|
|
56
|
+
|
|
57
|
+
export type Flow = {
|
|
58
|
+
// The work items in the state this flow acts on, such as issues labelled auto:eligible.
|
|
59
|
+
readonly trigger: Effect.Effect<ReadonlyArray<WorkItem>, FlowFailure, TriggerServices>;
|
|
60
|
+
// Checked before each run starts; fail with Skip to hold the run back, such as on usage.
|
|
61
|
+
readonly gate?: Effect.Effect<void, FlowFailure, TriggerServices>;
|
|
62
|
+
// Runs of this flow in one repo at once. Defaults to 1; machine limits still apply.
|
|
63
|
+
readonly concurrency?: number;
|
|
64
|
+
// A cron schedule for the trigger, overriding the repo's default.
|
|
65
|
+
readonly schedule?: string;
|
|
66
|
+
// The durable work for one item. Its result, if a string, is the run's summary.
|
|
67
|
+
readonly workflow: (item: WorkItem) => Effect.Effect<string | void, FlowFailure, WorkflowServices>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const defineFlow = (flow: Flow): Flow => flow;
|
|
71
|
+
|
|
72
|
+
const FlowErrorSchema = Schema.Union([Skip, NeedsHuman, StepFailed]);
|
|
73
|
+
const isFlowError = Schema.is(FlowErrorSchema);
|
|
74
|
+
|
|
75
|
+
// An error's message, whatever shape it came in.
|
|
76
|
+
export const describeError = <E>(error: E): string => {
|
|
77
|
+
if (Predicate.hasProperty(error, 'step') && Predicate.hasProperty(error, 'message')) return `${String(error.step)}: ${String(error.message)}`;
|
|
78
|
+
|
|
79
|
+
if (Predicate.hasProperty(error, 'message') && Predicate.isString(error.message)) return error.message;
|
|
80
|
+
|
|
81
|
+
if (Predicate.hasProperty(error, 'reason') && Predicate.isString(error.reason)) return error.reason;
|
|
82
|
+
|
|
83
|
+
return String(error);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Keeps the SDK's own outcomes, and names the activity in any other failure.
|
|
87
|
+
const toFlowError =
|
|
88
|
+
(name: string) =>
|
|
89
|
+
<E>(error: E): FlowError => {
|
|
90
|
+
if (isFlowError(error)) return error;
|
|
91
|
+
|
|
92
|
+
return new StepFailed({ step: name, message: describeError(error) });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const withRunLog = (use: (log: RunLog['Service']) => Effect.Effect<void>) =>
|
|
96
|
+
Effect.serviceOption(RunLog).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: use })));
|
|
97
|
+
|
|
98
|
+
// One durable step of a workflow. It runs once; its result is saved, and a
|
|
99
|
+
// replay returns the saved result. Names must be unique within a run, so a
|
|
100
|
+
// step inside a loop carries its round: `fix-ci-2`. `success` is the result's
|
|
101
|
+
// schema, which the journal stores it with; leave it out for a step that
|
|
102
|
+
// returns nothing.
|
|
103
|
+
export const activity = <S extends Schema.Constraint = typeof Schema.Void, E = never, R = never>(name: string, execute: Effect.Effect<S['Type'], E, R>, success?: S) =>
|
|
104
|
+
Activity.make({
|
|
105
|
+
name,
|
|
106
|
+
success,
|
|
107
|
+
error: FlowErrorSchema,
|
|
108
|
+
execute: withRunLog((log) => log.step(name)).pipe(
|
|
109
|
+
Effect.andThen(execute),
|
|
110
|
+
Effect.ensuring(withRunLog((log) => log.stepDone(name))),
|
|
111
|
+
Effect.mapError(toFlowError(name)),
|
|
112
|
+
),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Runs .chernabog/stages/<name>.ts (or an executable <name>) in a child
|
|
116
|
+
// process, checks its output against `output`, and saves the result. `id`
|
|
117
|
+
// tells apart two runs of one step in a run.
|
|
118
|
+
export const step = <A>(name: string, input: Json, output: Schema.Codec<A, Json>, id: Option.Option<string> = Option.none()) =>
|
|
119
|
+
activity(
|
|
120
|
+
Option.match(id, { onNone: () => name, onSome: (suffix) => `${name}#${suffix}` }),
|
|
121
|
+
StepRunner.use((runner) => runner.run(name, input)).pipe(
|
|
122
|
+
Effect.flatMap((raw) => Schema.decodeEffect(output)(raw).pipe(Effect.mapError((issue) => new StepFailed({ step: name, message: `output: ${issue.message}` })))),
|
|
123
|
+
),
|
|
124
|
+
output,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// A durable pause: it survives restarts, and a timer that came due while the
|
|
128
|
+
// daemon was down fires as soon as it is back.
|
|
129
|
+
export const sleep = (name: string, duration: Duration.Input) => DurableClock.sleep({ name, duration, inMemoryThreshold: Duration.zero });
|
|
130
|
+
|
|
131
|
+
// The durable deferred behind one wait, by name. Chernabog completes it with `true` when the signal comes.
|
|
132
|
+
export const signalDeferred = (name: string) => DurableDeferred.make(name, { success: Schema.Literal(true) });
|
|
133
|
+
|
|
134
|
+
export type Wait = { readonly id: string; readonly timeout: Duration.Input };
|
|
135
|
+
|
|
136
|
+
// Waits for whichever comes first: one of the signals, which a trigger reports
|
|
137
|
+
// for this run's work item (see WorkItem.signals), or the timeout. Returns the
|
|
138
|
+
// signal's name, or "timeout". `id` makes each wait in a loop distinct:
|
|
139
|
+
// `awaitSignal(["pushed", "closed"], { id: "round-2", timeout: "6 hours" })`.
|
|
140
|
+
export const awaitSignal = (signals: ReadonlyArray<string>, wait: Wait) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const waits = signals.map((signal) => ({ signal, deferred: signalDeferred(`${signal}:${wait.id}`) }));
|
|
143
|
+
|
|
144
|
+
for (const pending of waits) {
|
|
145
|
+
const token = yield* DurableDeferred.token(pending.deferred);
|
|
146
|
+
yield* activity(`await-${pending.deferred.name}`, Signals.use((registry) => registry.register(pending.signal, pending.deferred.name, token)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const [first, ...rest] = [
|
|
150
|
+
...waits.map((pending) => DurableDeferred.await(pending.deferred).pipe(Effect.as(pending.signal))),
|
|
151
|
+
sleep(`timeout:${wait.id}`, wait.timeout).pipe(Effect.as('timeout')),
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
return yield* DurableDeferred.raceAll({ name: `race:${wait.id}`, success: Schema.String, error: Schema.Never, effects: [first, ...rest] });
|
|
155
|
+
});
|