@smithers-orchestrator/components 0.21.0 → 0.23.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/package.json +10 -10
- package/src/components/Approval.js +9 -7
- package/src/components/CheckSuite.js +66 -13
- package/src/components/EscalationChain.js +63 -19
- package/src/components/ScanFixVerify.js +1 -0
- package/src/components/Supervisor.js +1 -0
- package/src/components/TaskProps.ts +8 -0
- package/src/index.d.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "React components for Smithers workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"react": "^19.2.5",
|
|
25
25
|
"react-dom": "^19.2.5",
|
|
26
26
|
"zod": "^4.3.6",
|
|
27
|
-
"@smithers-orchestrator/
|
|
28
|
-
"@smithers-orchestrator/errors": "0.
|
|
29
|
-
"@smithers-orchestrator/graph": "0.
|
|
30
|
-
"@smithers-orchestrator/memory": "0.
|
|
31
|
-
"@smithers-orchestrator/
|
|
32
|
-
"@smithers-orchestrator/
|
|
33
|
-
"@smithers-orchestrator/
|
|
34
|
-
"@smithers-orchestrator/scheduler": "0.
|
|
35
|
-
"@smithers-orchestrator/
|
|
27
|
+
"@smithers-orchestrator/agents": "0.23.0",
|
|
28
|
+
"@smithers-orchestrator/errors": "0.23.0",
|
|
29
|
+
"@smithers-orchestrator/graph": "0.23.0",
|
|
30
|
+
"@smithers-orchestrator/memory": "0.23.0",
|
|
31
|
+
"@smithers-orchestrator/driver": "0.23.0",
|
|
32
|
+
"@smithers-orchestrator/observability": "0.23.0",
|
|
33
|
+
"@smithers-orchestrator/react-reconciler": "0.23.0",
|
|
34
|
+
"@smithers-orchestrator/scheduler": "0.23.0",
|
|
35
|
+
"@smithers-orchestrator/db": "0.23.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@tanstack/react-query": "^5.99.1",
|
|
@@ -123,16 +123,18 @@ export function Approval(props) {
|
|
|
123
123
|
if ((mode === "select" || mode === "rank") && (!options || options.length === 0)) {
|
|
124
124
|
throw new SmithersError("APPROVAL_OPTIONS_REQUIRED", `Approval ${props.id} requires options when mode="${mode}".`);
|
|
125
125
|
}
|
|
126
|
+
const conditionMet = props.autoApprove
|
|
127
|
+
? evaluateBooleanCallback(props.autoApprove.condition, ctx)
|
|
128
|
+
: undefined;
|
|
129
|
+
const revertOnMet = props.autoApprove
|
|
130
|
+
? evaluateBooleanCallback(props.autoApprove.revertOn, ctx)
|
|
131
|
+
: undefined;
|
|
126
132
|
const autoApprove = props.autoApprove
|
|
127
133
|
? {
|
|
128
134
|
...(typeof props.autoApprove.after === "number" ? { after: props.autoApprove.after } : {}),
|
|
129
135
|
audit: props.autoApprove.audit !== false,
|
|
130
|
-
...(
|
|
131
|
-
|
|
132
|
-
: {}),
|
|
133
|
-
...(evaluateBooleanCallback(props.autoApprove.revertOn, ctx) !== undefined
|
|
134
|
-
? { revertOnMet: evaluateBooleanCallback(props.autoApprove.revertOn, ctx) }
|
|
135
|
-
: {}),
|
|
136
|
+
...(conditionMet !== undefined ? { conditionMet } : {}),
|
|
137
|
+
...(revertOnMet !== undefined ? { revertOnMet } : {}),
|
|
136
138
|
}
|
|
137
139
|
: undefined;
|
|
138
140
|
const requestMeta = {
|
|
@@ -177,7 +179,7 @@ export function Approval(props) {
|
|
|
177
179
|
approved: approval?.status === "approved",
|
|
178
180
|
note: approval?.note ?? null,
|
|
179
181
|
decidedBy: approval?.decidedBy ?? null,
|
|
180
|
-
decidedAt: null,
|
|
182
|
+
decidedAt: approval?.decidedAtMs != null ? new Date(approval.decidedAtMs).toISOString() : null,
|
|
181
183
|
};
|
|
182
184
|
};
|
|
183
185
|
return React.createElement("smithers:task", {
|
|
@@ -3,11 +3,46 @@
|
|
|
3
3
|
// @smithers-type-exports-end
|
|
4
4
|
|
|
5
5
|
import React from "react";
|
|
6
|
+
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
|
|
6
7
|
import { Sequence } from "./Sequence.js";
|
|
7
8
|
import { Parallel } from "./Parallel.js";
|
|
8
9
|
import { Task } from "./Task.js";
|
|
9
10
|
/** @typedef {import("./CheckConfig.ts").CheckConfig} CheckConfig */
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Whether a single check's output row counts as a pass. A missing row (the
|
|
14
|
+
* check never produced output) or an explicit failure signal counts as a fail.
|
|
15
|
+
* @param {unknown} row
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function checkPassed(row) {
|
|
19
|
+
if (row == null)
|
|
20
|
+
return false;
|
|
21
|
+
if (typeof row === "object") {
|
|
22
|
+
const r = /** @type {Record<string, unknown>} */ (row);
|
|
23
|
+
if (r.passed === false || r.ok === false || r.failed === true)
|
|
24
|
+
return false;
|
|
25
|
+
if (r.error != null && r.error !== false)
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the overall pass/fail verdict from the per-check pass count.
|
|
33
|
+
* @param {"all-pass" | "majority" | "any-pass"} strategy
|
|
34
|
+
* @param {number} passCount
|
|
35
|
+
* @param {number} total
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function resolveVerdict(strategy, passCount, total) {
|
|
39
|
+
if (strategy === "any-pass")
|
|
40
|
+
return passCount > 0;
|
|
41
|
+
if (strategy === "majority")
|
|
42
|
+
return passCount * 2 > total;
|
|
43
|
+
return total > 0 && passCount === total;
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
/**
|
|
12
47
|
* @param {CheckConfig[] | Record<string, Omit<CheckConfig, "id">>} checks
|
|
13
48
|
* @returns {CheckConfig[]}
|
|
@@ -29,6 +64,7 @@ function normalizeChecks(checks) {
|
|
|
29
64
|
export function CheckSuite(props) {
|
|
30
65
|
if (props.skipIf)
|
|
31
66
|
return null;
|
|
67
|
+
const ctx = React.useContext(SmithersContext);
|
|
32
68
|
const { id, checks, verdictOutput, strategy = "all-pass", maxConcurrency, continueOnFail = true, } = props;
|
|
33
69
|
const prefix = id ?? "checksuite";
|
|
34
70
|
const normalized = normalizeChecks(checks);
|
|
@@ -51,21 +87,38 @@ export function CheckSuite(props) {
|
|
|
51
87
|
return React.createElement(Task, taskProps, childContent);
|
|
52
88
|
});
|
|
53
89
|
const parallelEl = React.createElement(Parallel, { maxConcurrency }, ...checkTasks);
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
: strategy === "majority"
|
|
63
|
-
? "A MAJORITY of checks must pass for an overall pass verdict."
|
|
64
|
-
: "ANY single check passing is sufficient for an overall pass verdict.";
|
|
90
|
+
// The verdict depends on every check. We use dependsOn (the mechanism the
|
|
91
|
+
// graph extractor honors) so the verdict only runs once all checks have
|
|
92
|
+
// produced output — a `needs` map alone is ignored when no `deps` are set.
|
|
93
|
+
const checkIds = normalized.map((check) => `${prefix}-${check.id}`);
|
|
94
|
+
// Compute the aggregate verdict from the per-check outputs. Reads are taken
|
|
95
|
+
// from the workflow context at render time and captured in the closure; the
|
|
96
|
+
// component re-renders reactively as each check's output becomes available,
|
|
97
|
+
// and the engine defers execution until every dependency has completed.
|
|
65
98
|
const verdictTask = React.createElement(Task, {
|
|
66
99
|
id: `${prefix}-verdict`,
|
|
67
100
|
output: verdictOutput,
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
dependsOn: checkIds,
|
|
102
|
+
label: "verdict",
|
|
103
|
+
}, () => {
|
|
104
|
+
let passCount = 0;
|
|
105
|
+
const results = {};
|
|
106
|
+
for (const check of normalized) {
|
|
107
|
+
const checkId = `${prefix}-${check.id}`;
|
|
108
|
+
const row = ctx?.outputMaybe(verdictOutput, { nodeId: checkId });
|
|
109
|
+
const passed = checkPassed(row);
|
|
110
|
+
results[check.id] = passed;
|
|
111
|
+
if (passed)
|
|
112
|
+
passCount += 1;
|
|
113
|
+
}
|
|
114
|
+
const total = normalized.length;
|
|
115
|
+
return {
|
|
116
|
+
passed: resolveVerdict(strategy, passCount, total),
|
|
117
|
+
passCount,
|
|
118
|
+
total,
|
|
119
|
+
strategy,
|
|
120
|
+
results,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
70
123
|
return React.createElement(Sequence, null, parallelEl, verdictTask);
|
|
71
124
|
}
|
|
@@ -4,10 +4,42 @@
|
|
|
4
4
|
// @smithers-type-exports-end
|
|
5
5
|
|
|
6
6
|
import React from "react";
|
|
7
|
+
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
|
|
7
8
|
import { Sequence } from "./Sequence.js";
|
|
8
9
|
import { Branch } from "./Branch.js";
|
|
9
10
|
import { Task } from "./Task.js";
|
|
10
11
|
import { Approval } from "./Approval.js";
|
|
12
|
+
/**
|
|
13
|
+
* Default escalation predicate: escalate when the previous level has no result
|
|
14
|
+
* yet, or its result signals a failure (`error`/`failed` truthy or `ok === false`).
|
|
15
|
+
* @param {unknown} result
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function defaultEscalateIf(result) {
|
|
19
|
+
if (result == null)
|
|
20
|
+
return true;
|
|
21
|
+
if (typeof result === "object") {
|
|
22
|
+
const row = /** @type {Record<string, unknown>} */ (result);
|
|
23
|
+
if (row.error != null && row.error !== false)
|
|
24
|
+
return true;
|
|
25
|
+
if (row.failed === true)
|
|
26
|
+
return true;
|
|
27
|
+
if (row.ok === false)
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve whether the previous level escalated by invoking its `escalateIf`
|
|
34
|
+
* predicate (or the default) against its actual result.
|
|
35
|
+
* @param {EscalationLevel} prevLevel
|
|
36
|
+
* @param {unknown} prevResult
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function didEscalate(prevLevel, prevResult) {
|
|
40
|
+
const predicate = prevLevel.escalateIf ?? defaultEscalateIf;
|
|
41
|
+
return Boolean(predicate(prevResult));
|
|
42
|
+
}
|
|
11
43
|
/**
|
|
12
44
|
* Escalation chain: tries agents in order, escalating on failure or when
|
|
13
45
|
* `escalateIf` returns `true`. Optionally ends with a human approval fallback.
|
|
@@ -18,6 +50,7 @@ import { Approval } from "./Approval.js";
|
|
|
18
50
|
export function EscalationChain(props) {
|
|
19
51
|
if (props.skipIf)
|
|
20
52
|
return null;
|
|
53
|
+
const ctx = React.useContext(SmithersContext);
|
|
21
54
|
const prefix = props.id ?? "escalation";
|
|
22
55
|
const { levels, children, humanFallback, humanRequest, escalationOutput } = props;
|
|
23
56
|
// Build the chain from the last level forward, nesting each level inside a
|
|
@@ -43,14 +76,14 @@ export function EscalationChain(props) {
|
|
|
43
76
|
}
|
|
44
77
|
else {
|
|
45
78
|
// Subsequent levels are gated by a Branch that checks whether the
|
|
46
|
-
// previous level needs escalation. The
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
// previous level needs escalation. The chain re-renders reactively as
|
|
80
|
+
// outputs become available, so we read the previous level's actual
|
|
81
|
+
// result from the workflow context and run its `escalateIf` predicate
|
|
82
|
+
// (or the default failure predicate) to decide whether this level runs.
|
|
83
|
+
const prevLevel = levels[i - 1];
|
|
84
|
+
const prevLevelId = `${prefix}-level-${i - 1}`;
|
|
85
|
+
const prevResult = ctx?.outputMaybe(prevLevel.output, { nodeId: prevLevelId });
|
|
86
|
+
const escalated = didEscalate(prevLevel, prevResult);
|
|
54
87
|
const checkId = `${prefix}-check-${i - 1}`;
|
|
55
88
|
const checkTask = React.createElement(Task, {
|
|
56
89
|
id: checkId,
|
|
@@ -58,40 +91,51 @@ export function EscalationChain(props) {
|
|
|
58
91
|
continueOnFail: true,
|
|
59
92
|
label: `Check escalation from level ${i - 1}`,
|
|
60
93
|
children: () => {
|
|
61
|
-
//
|
|
62
|
-
//
|
|
94
|
+
// Record the escalation decision for the prior level so it is
|
|
95
|
+
// visible in the escalation output stream.
|
|
63
96
|
return {
|
|
64
|
-
escalated
|
|
97
|
+
escalated,
|
|
65
98
|
fromLevel: i - 1,
|
|
66
99
|
toLevel: i,
|
|
67
100
|
};
|
|
68
101
|
},
|
|
69
102
|
});
|
|
70
|
-
// Gate the current level
|
|
71
|
-
//
|
|
72
|
-
// The Branch uses `true` here because the sequence only reaches this
|
|
73
|
-
// point if the previous task failed or escalateIf was configured.
|
|
103
|
+
// Gate the current level on the previous level's escalation decision:
|
|
104
|
+
// it only mounts when the prior level actually escalated.
|
|
74
105
|
const gatedLevel = React.createElement(Branch, {
|
|
75
|
-
if:
|
|
106
|
+
if: escalated,
|
|
76
107
|
then: taskEl,
|
|
77
108
|
});
|
|
78
109
|
levelElements.push(checkTask);
|
|
79
110
|
levelElements.push(gatedLevel);
|
|
80
111
|
}
|
|
81
112
|
}
|
|
82
|
-
// Append human fallback if requested.
|
|
83
|
-
|
|
113
|
+
// Append human fallback if requested. It only mounts when every automated
|
|
114
|
+
// level escalated (i.e. all automated levels were exhausted). A single
|
|
115
|
+
// level resolving without escalation stops the chain and the fallback, even
|
|
116
|
+
// if later levels never ran and therefore have no recorded result.
|
|
117
|
+
if (humanFallback && levels.length > 0) {
|
|
84
118
|
const humanId = `${prefix}-human-fallback`;
|
|
85
119
|
const request = humanRequest ?? {
|
|
86
120
|
title: "Escalation requires human review",
|
|
87
121
|
summary: `All ${levels.length} automated levels have been exhausted.`,
|
|
88
122
|
};
|
|
89
|
-
|
|
123
|
+
const allEscalated = levels.every((level, idx) => {
|
|
124
|
+
const levelResult = ctx?.outputMaybe(level.output, {
|
|
125
|
+
nodeId: `${prefix}-level-${idx}`,
|
|
126
|
+
});
|
|
127
|
+
return didEscalate(level, levelResult);
|
|
128
|
+
});
|
|
129
|
+
const approvalEl = React.createElement(Approval, {
|
|
90
130
|
id: humanId,
|
|
91
131
|
output: escalationOutput,
|
|
92
132
|
request,
|
|
93
133
|
continueOnFail: true,
|
|
94
134
|
label: request.title,
|
|
135
|
+
});
|
|
136
|
+
levelElements.push(React.createElement(Branch, {
|
|
137
|
+
if: allEscalated,
|
|
138
|
+
then: approvalEl,
|
|
95
139
|
}));
|
|
96
140
|
}
|
|
97
141
|
return React.createElement(Sequence, {}, ...levelElements);
|
|
@@ -53,6 +53,7 @@ export function ScanFixVerify(props) {
|
|
|
53
53
|
const reportTask = React.createElement(Task, {
|
|
54
54
|
id: `${prefix}-report`,
|
|
55
55
|
output: props.reportOutput,
|
|
56
|
+
agent: props.verifier,
|
|
56
57
|
dependsOn: [`${prefix}-verify`],
|
|
57
58
|
children: "Produce a final summary report of all scan-fix-verify cycles, including what was found, what was fixed, and the final verification status.",
|
|
58
59
|
});
|
|
@@ -78,6 +78,7 @@ export function Supervisor(props) {
|
|
|
78
78
|
const finalTask = React.createElement(Task, {
|
|
79
79
|
id: `${prefix}-final`,
|
|
80
80
|
output: props.finalOutput,
|
|
81
|
+
agent: props.boss,
|
|
81
82
|
needs: { review: `${prefix}-review`, plan: `${prefix}-plan` },
|
|
82
83
|
label: "Supervisor summary",
|
|
83
84
|
children: "Summarize the overall results from all delegation cycles.",
|
|
@@ -31,6 +31,14 @@ export type TaskProps<Row, Output extends OutputTarget = OutputTarget, D extends
|
|
|
31
31
|
needs?: Record<string, string>;
|
|
32
32
|
/** Render-time typed dependencies. Keys resolve from task ids of the same name, or from matching `needs` entries. */
|
|
33
33
|
deps?: D;
|
|
34
|
+
/**
|
|
35
|
+
* Start this agent task from a copy of another task's final agent session context.
|
|
36
|
+
* The fork source becomes an implicit dependency: this task waits for it to complete,
|
|
37
|
+
* then copies its conversation snapshot into a fresh, independent session and submits
|
|
38
|
+
* its own prompt. The source is never mutated. Inside a `<Loop>`, resolves to the
|
|
39
|
+
* latest completed snapshot for that task id. Requires an agent task.
|
|
40
|
+
*/
|
|
41
|
+
fork?: string;
|
|
34
42
|
skipIf?: boolean;
|
|
35
43
|
needsApproval?: boolean;
|
|
36
44
|
/** When paired with `needsApproval`, do not block unrelated downstream flow while the approval is pending. */
|
package/src/index.d.ts
CHANGED
|
@@ -135,6 +135,14 @@ type TaskProps$2<Row, Output extends OutputTarget$1 = OutputTarget$1, D extends
|
|
|
135
135
|
needs?: Record<string, string>;
|
|
136
136
|
/** Render-time typed dependencies. Keys resolve from task ids of the same name, or from matching `needs` entries. */
|
|
137
137
|
deps?: D;
|
|
138
|
+
/**
|
|
139
|
+
* Start this agent task from a copy of another task's final agent session context.
|
|
140
|
+
* The fork source becomes an implicit dependency: this task waits for it to complete,
|
|
141
|
+
* then copies its conversation snapshot into a fresh, independent session and submits
|
|
142
|
+
* its own prompt. The source is never mutated. Inside a `<Loop>`, resolves to the
|
|
143
|
+
* latest completed snapshot for that task id. Requires an agent task.
|
|
144
|
+
*/
|
|
145
|
+
fork?: string;
|
|
138
146
|
skipIf?: boolean;
|
|
139
147
|
needsApproval?: boolean;
|
|
140
148
|
/** When paired with `needsApproval`, do not block unrelated downstream flow while the approval is pending. */
|