@krapht/ork 1.0.0-alpha.13 → 1.0.0-alpha.14
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/dist/{__build_info__-Da73_nBs.mjs → __build_info__-B82-dGJY.mjs} +2 -2
- package/dist/{attach-E6NNyA3T.mjs → attach-CdtGX9se.mjs} +5 -5
- package/dist/bin.mjs +194 -215
- package/dist/{builtin-files-n6o6oWpB.mjs → builtin-files-Bdq23IIi.mjs} +1 -1
- package/dist/{chips-CyeB_rhR.mjs → chips-BqcT58qr.mjs} +1 -1
- package/dist/{completion-SNNNxnv9.mjs → completion--ywde-56.mjs} +52 -11
- package/dist/{context-DHudLA5e.mjs → context-Y_I4Mofq.mjs} +17 -7
- package/dist/{find-CjsNYAAf.mjs → find-BVX-bL4u.mjs} +1 -1
- package/dist/{format-C60TFpRe.mjs → format-CvTE5CZQ.mjs} +12 -12
- package/dist/{help-groups-CyfPWlST.mjs → help-groups-CpR2UzSn.mjs} +16 -6
- package/dist/{in-a-run-BYjTXaST.mjs → in-a-run-cL5N9gpH.mjs} +1 -1
- package/dist/{limits-C5QKZNLT.mjs → limits-Dhe--wmX.mjs} +1 -1
- package/dist/{mcp-bcctR-wc.mjs → mcp-CsmboyXv.mjs} +6 -6
- package/dist/{once-DPQu5Q3H.mjs → once-CrwI-ZdN.mjs} +4 -4
- package/dist/{output-CnRzrbUM.mjs → output-B1kwo0YU.mjs} +17 -1
- package/dist/{pause-BPvlDIvz.mjs → pause-DssrYcda.mjs} +4 -4
- package/dist/{relay-C8W0t_zc.mjs → relay-DASRgsTQ.mjs} +2 -2
- package/dist/{run-CQIfq918.mjs → run-D8divGCs.mjs} +1 -1
- package/dist/send-CXv8bx0i.mjs +105 -0
- package/dist/{src-CGJGL1sZ.mjs → src-8Mul0695.mjs} +8 -10
- package/dist/{src-D93tHZ5B.mjs → src-C7z_6fhn.mjs} +92 -39
- package/dist/{src-CqRqsb2t.mjs → src-njdtejgZ.mjs} +98 -33
- package/dist/{src-BeR6f70A.mjs → src-tsuZRu0H.mjs} +2353 -918
- package/dist/{store-CM3cERGl.mjs → store-CleF7KeS.mjs} +21 -73
- package/dist/ui/assets/app-D_nAPnHM.css +1 -0
- package/dist/ui/assets/{app-DYRC0V4p.js → app-DqBAtXSZ.js} +19 -19
- package/dist/ui/assets/{index-CWwm8HTG.js → index-D-_uPI2v.js} +2 -2
- package/dist/ui/index.html +1 -1
- package/dist/{ui-D2Ydofq0.mjs → ui-CNO1M0qT.mjs} +4 -4
- package/dist/{usage-6WIowblz.mjs → usage-BVXi0iFY.mjs} +13 -141
- package/dist/{verdict-CIy8tAyX.mjs → verdict-CMvPCpWC.mjs} +2 -2
- package/dist/{watch-uOU_WeBI.mjs → watch-DVNIciHp.mjs} +10 -8
- package/dist/{workflows-7tQdMnrA.mjs → workflows-B30QkwTk.mjs} +3 -3
- package/dist/{worktrees-ZCII298F.mjs → worktrees-DD4_TpkH.mjs} +3 -5
- package/package.json +2 -2
- package/dist/clean-Bh4KZgzc.mjs +0 -91
- package/dist/decide-DuIHACvH.mjs +0 -50
- package/dist/list-BUb5Uc3H.mjs +0 -117
- package/dist/remove-BkFWGVYo.mjs +0 -201
- package/dist/report-CEN8VoUb.mjs +0 -140
- package/dist/send-CaZlGG5a.mjs +0 -163
- package/dist/ui/assets/app-QoHVi-mb.css +0 -1
|
@@ -495,6 +495,8 @@ const forEachFields = { forEach: z.union([z.strictObject({
|
|
|
495
495
|
maxItems: z.number().int().positive().describe("Most items allowed. A longer list asks whether to stop, run this many, or run all."),
|
|
496
496
|
...pacingFields
|
|
497
497
|
})]).optional().describe("Run this node once per item of a list, with no model turn to fan out or gather. Items are `<id>.0`, `<id>.1`, ….") };
|
|
498
|
+
/** The nodes that run one thing may name a lock (RFC-016). */
|
|
499
|
+
const lockFields = { lock: z.string().regex(/\S/u, "a lock needs a name").describe("The lock this node holds, e.g. `branch:{{inputs.branch}}`. Held from the first node that names it to the last, or the run's end; one run at a time holds it, the others wait. One name per node.").optional() };
|
|
498
500
|
// ../schema/src/settings.ts
|
|
499
501
|
/**
|
|
500
502
|
* @file The settings the workflow format shares between nodes, agents, defaults
|
|
@@ -562,9 +564,12 @@ const IsolationSchema = z.enum([
|
|
|
562
564
|
"readonly"
|
|
563
565
|
]);
|
|
564
566
|
const BudgetSchema = z.number().positive();
|
|
567
|
+
/** A cap in tokens: input, output and cache-write; cache reads don't count. */
|
|
568
|
+
const TokenBudgetSchema = z.number().int().positive();
|
|
565
569
|
/** Caps across runs (RFC-007), in Claude Code's estimated dollars. */
|
|
566
570
|
const QuotasSchema = z.strictObject({
|
|
567
571
|
dayUsd: BudgetSchema.optional().describe("Most to spend in a calendar day."),
|
|
572
|
+
dayTokens: TokenBudgetSchema.optional().describe("Most tokens (input, output, cache-write) to use in a calendar day."),
|
|
568
573
|
weekUsd: BudgetSchema.optional().describe("Most to spend in a calendar week, from Monday."),
|
|
569
574
|
monthUsd: BudgetSchema.optional().describe("Most to spend in a calendar month.")
|
|
570
575
|
});
|
|
@@ -651,6 +656,7 @@ const McpCallNodeSchema = z.strictObject({
|
|
|
651
656
|
timeout: TimeoutSchema.optional().describe("How long the call may take, e.g. 5m; then the node fails. With watch:, how long one look may take."),
|
|
652
657
|
watch: WatchSchema.optional().describe("Call the tool again and again until a condition holds, with no model turn."),
|
|
653
658
|
...notifyFields,
|
|
659
|
+
...lockFields,
|
|
654
660
|
...forEachFields
|
|
655
661
|
});
|
|
656
662
|
// ../schema/src/steps.ts
|
|
@@ -698,6 +704,7 @@ const toolFields = {
|
|
|
698
704
|
once: onceField,
|
|
699
705
|
...toolProgramFields,
|
|
700
706
|
...notifyFields,
|
|
707
|
+
...lockFields,
|
|
701
708
|
...forEachFields
|
|
702
709
|
};
|
|
703
710
|
/** What every tool node has: `tool.<id>`, its command, allow list and cwd. */
|
|
@@ -727,7 +734,6 @@ const TOOL_NODE_SCHEMAS = INTEGRATIONS.map((i) => toolNodeSchema(i));
|
|
|
727
734
|
* @file The node types of the workflow format (Claude, tool, control and gate
|
|
728
735
|
* nodes) as Zod schemas.
|
|
729
736
|
*/
|
|
730
|
-
const base = nodeBaseFields;
|
|
731
737
|
/** A Claude node's output field that is not a list: a rule or JSON Schema. */
|
|
732
738
|
const NotListSchema = z.unknown().refine((value) => typeof value !== "object" || value === null || !("list" in value), { message: LIST_RULE_HINT });
|
|
733
739
|
const NODE_EFFORT_HINT = "expected low, medium, high, xhigh or max, or {{item.<field>}}";
|
|
@@ -749,6 +755,7 @@ const agentSettings = {
|
|
|
749
755
|
context: z.array(z.string()).optional().describe("Node-layer context files."),
|
|
750
756
|
isolation: IsolationSchema.optional(),
|
|
751
757
|
budgetUsd: BudgetSchema.optional(),
|
|
758
|
+
budgetTokens: TokenBudgetSchema.optional(),
|
|
752
759
|
maxTurns: TurnsSchema.optional(),
|
|
753
760
|
timeout: TimeoutSchema.optional().describe("How long the node may work, e.g. 30m; time waiting on the user does not count."),
|
|
754
761
|
practices: PracticesSchema.optional(),
|
|
@@ -768,6 +775,7 @@ const AGENT_SETTING_KEYS = [
|
|
|
768
775
|
"context",
|
|
769
776
|
"isolation",
|
|
770
777
|
"budgetUsd",
|
|
778
|
+
"budgetTokens",
|
|
771
779
|
"maxTurns",
|
|
772
780
|
"timeout",
|
|
773
781
|
"practices",
|
|
@@ -776,7 +784,7 @@ const AGENT_SETTING_KEYS = [
|
|
|
776
784
|
"output"
|
|
777
785
|
];
|
|
778
786
|
const claudeFields = {
|
|
779
|
-
...
|
|
787
|
+
...nodeBaseFields,
|
|
780
788
|
agent: AgentNameSchema.optional().describe("The agent that fills this node; its settings apply where the node sets none, and the prompt is its task."),
|
|
781
789
|
prompt: z.string().optional().describe("Task brief. Supports {{inputs.*}} and {{nodes.<id>.output.*}} templates."),
|
|
782
790
|
instructions: z.string().optional().describe("Node-layer instructions appended to the system prompt."),
|
|
@@ -796,6 +804,7 @@ const ClaudeWorkerNodeSchema = z.strictObject({
|
|
|
796
804
|
...claudeFields,
|
|
797
805
|
mcp: McpNamesSchema,
|
|
798
806
|
...notifyFields,
|
|
807
|
+
...lockFields,
|
|
799
808
|
...forEachFields,
|
|
800
809
|
...stepFields
|
|
801
810
|
});
|
|
@@ -807,11 +816,11 @@ const ClaudeSubagentNodeSchema = z.strictObject({
|
|
|
807
816
|
});
|
|
808
817
|
const SplitNodeSchema = z.strictObject({
|
|
809
818
|
type: z.literal("control.split"),
|
|
810
|
-
...
|
|
819
|
+
...nodeBaseFields
|
|
811
820
|
});
|
|
812
821
|
const JoinNodeSchema = z.strictObject({
|
|
813
822
|
type: z.literal("control.join"),
|
|
814
|
-
...
|
|
823
|
+
...nodeBaseFields,
|
|
815
824
|
mode: z.enum([
|
|
816
825
|
"all",
|
|
817
826
|
"any",
|
|
@@ -821,7 +830,7 @@ const JoinNodeSchema = z.strictObject({
|
|
|
821
830
|
});
|
|
822
831
|
const RouterNodeSchema = z.strictObject({
|
|
823
832
|
type: z.literal("control.router"),
|
|
824
|
-
...
|
|
833
|
+
...nodeBaseFields,
|
|
825
834
|
mode: z.enum(["first", "all"]).optional().describe("Follow the first matching edge (default), or every match.")
|
|
826
835
|
});
|
|
827
836
|
const DecisionOptionSchema = z.union([z.string(), z.strictObject({
|
|
@@ -831,7 +840,7 @@ const DecisionOptionSchema = z.union([z.string(), z.strictObject({
|
|
|
831
840
|
})]);
|
|
832
841
|
const HumanGateNodeSchema = z.strictObject({
|
|
833
842
|
type: z.literal("human.gate"),
|
|
834
|
-
...
|
|
843
|
+
...nodeBaseFields,
|
|
835
844
|
...notifyFields,
|
|
836
845
|
question: z.string(),
|
|
837
846
|
document: z.string().optional(),
|
|
@@ -842,7 +851,7 @@ const HumanGateNodeSchema = z.strictObject({
|
|
|
842
851
|
});
|
|
843
852
|
const WorkflowCallNodeSchema = z.strictObject({
|
|
844
853
|
type: z.literal("workflow.call"),
|
|
845
|
-
...
|
|
854
|
+
...nodeBaseFields,
|
|
846
855
|
...notifyFields,
|
|
847
856
|
...forEachFields,
|
|
848
857
|
workflow: z.string().describe("The workflow's name, as in its project's workflows folder."),
|
|
@@ -871,12 +880,7 @@ const NodeSchema = z.discriminatedUnion("type", [
|
|
|
871
880
|
McpCallNodeSchema
|
|
872
881
|
]);
|
|
873
882
|
const isToolNode = (node) => node.type.startsWith(TOOL_PREFIX);
|
|
874
|
-
const
|
|
875
|
-
"agent.leader",
|
|
876
|
-
"agent.worker",
|
|
877
|
-
"agent.subagent"
|
|
878
|
-
];
|
|
879
|
-
const isClaudeNode = (node) => CLAUDE_NODE_TYPES.includes(node.type);
|
|
883
|
+
const isClaudeNode = (node) => node.type.startsWith("agent.");
|
|
880
884
|
// ../schema/src/agents.ts
|
|
881
885
|
/**
|
|
882
886
|
* @file Agents: who fills a Claude node. An agent is a portable `<name>.md`
|
|
@@ -1066,6 +1070,7 @@ const DefaultsSchema = z.strictObject({
|
|
|
1066
1070
|
effort: EffortSchema.optional(),
|
|
1067
1071
|
isolation: IsolationSchema.optional(),
|
|
1068
1072
|
budgetUsd: BudgetSchema.optional(),
|
|
1073
|
+
budgetTokens: TokenBudgetSchema.optional(),
|
|
1069
1074
|
maxTurns: TurnsSchema.optional(),
|
|
1070
1075
|
timeout: TimeoutSchema.optional().describe("For tool and Claude nodes that set none; a gate's own timeout is separate."),
|
|
1071
1076
|
practices: PracticesSchema.optional(),
|
|
@@ -1180,6 +1185,7 @@ const WorkflowSchema = z.strictObject({
|
|
|
1180
1185
|
agents: z.record(AgentNameSchema, AgentSchema).optional().describe("Agents defined in this workflow; they win over the project's and the user's of the same name."),
|
|
1181
1186
|
limits: z.strictObject({
|
|
1182
1187
|
budgetUsd: BudgetSchema.optional().describe("Hard cap for the whole run."),
|
|
1188
|
+
budgetTokens: TokenBudgetSchema.optional().describe("Hard cap for the whole run in tokens (input, output, cache-write)."),
|
|
1183
1189
|
concurrency: z.number().int().positive().optional().describe("Max sessions running at once."),
|
|
1184
1190
|
timeout: TimeoutSchema.optional().describe("How long the whole run may work, e.g. 2h; time waiting on the user does not count.")
|
|
1185
1191
|
}).optional(),
|
|
@@ -1648,7 +1654,7 @@ const sharedAnchors = (doc) => {
|
|
|
1648
1654
|
const refuseShared = (doc, node, path) => {
|
|
1649
1655
|
if (isAlias(node) || isNode(node) && "anchor" in node && typeof node.anchor === "string" && sharedAnchors(doc).has(node.anchor)) throw new EditRefused(`${shownPath$1(path)} is shared through a YAML anchor, so changing it here would change another node too. Replace the anchor in the file first.`);
|
|
1650
1656
|
};
|
|
1651
|
-
const keyOf = (pair) => isScalar(pair.key) ? pair.key.value : pair.key;
|
|
1657
|
+
const keyOf$1 = (pair) => isScalar(pair.key) ? pair.key.value : pair.key;
|
|
1652
1658
|
/** The value of a map's or list's child, as a node. */
|
|
1653
1659
|
const childNode = (parent, key) => isMap(parent) || isSeq(parent) ? parent.get(key, true) : void 0;
|
|
1654
1660
|
/** A key added to a map, before the first existing key that follows it. */
|
|
@@ -1657,7 +1663,7 @@ const insertPair = (doc, map, key, value, schema) => {
|
|
|
1657
1663
|
const order = keysOf(schema, map.toJS(doc));
|
|
1658
1664
|
const rank = order.indexOf(key);
|
|
1659
1665
|
const next = rank === -1 ? -1 : map.items.findIndex((pair) => {
|
|
1660
|
-
return order.indexOf(String(keyOf(pair))) > rank;
|
|
1666
|
+
return order.indexOf(String(keyOf$1(pair))) > rank;
|
|
1661
1667
|
});
|
|
1662
1668
|
const pair = doc.createPair(key, value);
|
|
1663
1669
|
if (next === -1) map.items.push(pair);
|
|
@@ -1693,9 +1699,9 @@ const updated = (doc, old, value, schema, path) => {
|
|
|
1693
1699
|
const updateMap = (doc, map, entries, schema, path) => {
|
|
1694
1700
|
const keys = new Set(entries.map(([key]) => key));
|
|
1695
1701
|
const whole = map.toJS(doc);
|
|
1696
|
-
map.items = map.items.filter((pair) => keys.has(String(keyOf(pair))));
|
|
1702
|
+
map.items = map.items.filter((pair) => keys.has(String(keyOf$1(pair))));
|
|
1697
1703
|
for (const [key, value] of entries) {
|
|
1698
|
-
const pair = map.items.find((p) => String(keyOf(p)) === key);
|
|
1704
|
+
const pair = map.items.find((p) => String(keyOf$1(p)) === key);
|
|
1699
1705
|
const child = childOf(schema, key, whole);
|
|
1700
1706
|
if (pair === void 0) insertPair(doc, map, key, value, schema);
|
|
1701
1707
|
else pair.value = updated(doc, pair.value, value, child, [...path, key]);
|
|
@@ -1729,7 +1735,7 @@ const emptyFor = (next) => typeof next === "number" ? new YAMLSeq() : new YAMLMa
|
|
|
1729
1735
|
/** Sets a child that may not exist yet in its map or list. */
|
|
1730
1736
|
const setChild = (doc, parent, key, value, schema, path) => {
|
|
1731
1737
|
if (isMap(parent) && typeof key === "string") {
|
|
1732
|
-
const pair = parent.items.find((p) => String(keyOf(p)) === key);
|
|
1738
|
+
const pair = parent.items.find((p) => String(keyOf$1(p)) === key);
|
|
1733
1739
|
if (pair === void 0) insertPair(doc, parent, key, value, schema);
|
|
1734
1740
|
else pair.value = value;
|
|
1735
1741
|
return;
|
|
@@ -3412,7 +3418,7 @@ const lstatOrUndefined = (target) => {
|
|
|
3412
3418
|
return;
|
|
3413
3419
|
}
|
|
3414
3420
|
};
|
|
3415
|
-
const stateOf$
|
|
3421
|
+
const stateOf$2 = (root, relative, origin) => {
|
|
3416
3422
|
const at = path.join(root, relative);
|
|
3417
3423
|
const stat = lstatOrUndefined(at);
|
|
3418
3424
|
if (stat === void 0) return { state: "missing" };
|
|
@@ -3442,7 +3448,7 @@ const toolLinks = (projectRoot, tools = linkableTools()) => tools.flatMap((id) =
|
|
|
3442
3448
|
kind,
|
|
3443
3449
|
path: folder,
|
|
3444
3450
|
origin,
|
|
3445
|
-
...stateOf$
|
|
3451
|
+
...stateOf$2(projectRoot, folder, origin)
|
|
3446
3452
|
}];
|
|
3447
3453
|
});
|
|
3448
3454
|
});
|
|
@@ -3942,8 +3948,8 @@ const socketRoom = (home = homedir(), platform = process.platform) => ({
|
|
|
3942
3948
|
max: SOCKET_PATH_MAX[platform]
|
|
3943
3949
|
});
|
|
3944
3950
|
/** For errors another handler already settles. */
|
|
3945
|
-
const ignore$
|
|
3946
|
-
const messageOf$
|
|
3951
|
+
const ignore$2 = () => {};
|
|
3952
|
+
const messageOf$3 = (line) => {
|
|
3947
3953
|
try {
|
|
3948
3954
|
const value = JSON.parse(line);
|
|
3949
3955
|
if (value === null || typeof value !== "object" || !("type" in value)) return;
|
|
@@ -3970,9 +3976,9 @@ const serveRun = async (options) => {
|
|
|
3970
3976
|
followers.delete(socket);
|
|
3971
3977
|
});
|
|
3972
3978
|
const lines = createInterface({ input: socket });
|
|
3973
|
-
lines.on("error", ignore$
|
|
3979
|
+
lines.on("error", ignore$2);
|
|
3974
3980
|
lines.on("line", (line) => {
|
|
3975
|
-
const message = messageOf$
|
|
3981
|
+
const message = messageOf$3(line);
|
|
3976
3982
|
if (message?.type === "wake") options.onWake();
|
|
3977
3983
|
if (message?.type === "follow") {
|
|
3978
3984
|
for (const stored of options.backlog(message.after)) socket.write(`${JSON.stringify(stored)}\n`);
|
|
@@ -4031,7 +4037,7 @@ const followSocket = (file, after, onEvent, signal) => {
|
|
|
4031
4037
|
})}\n`);
|
|
4032
4038
|
});
|
|
4033
4039
|
const lines = createInterface({ input: socket });
|
|
4034
|
-
lines.on("error", ignore$
|
|
4040
|
+
lines.on("error", ignore$2);
|
|
4035
4041
|
lines.on("line", (line) => {
|
|
4036
4042
|
try {
|
|
4037
4043
|
onEvent(JSON.parse(line));
|
|
@@ -4954,7 +4960,7 @@ const END_KINDS = {
|
|
|
4954
4960
|
no_report: info("No report", "agent", () => "ended without handing in a report", "Check that its brief says what to report; its session is in the Logs panel."),
|
|
4955
4961
|
invalid_report: info("Invalid report", "agent", (f) => `handed in a report that breaks its declared output${colon(f.reason)}`, "Check that its brief asks for every field its `output.schema` declares."),
|
|
4956
4962
|
turn_limit: info("Turn limit", "workflow", (f) => `reached its turn limit${paren(f.maxTurns === void 0 ? void 0 : String(f.maxTurns))}`, "Raise its `maxTurns`, or give it a smaller task."),
|
|
4957
|
-
budget_limit: info("Budget limit", "workflow", (f) => `reached its budget${paren(f.budgetUsd === void 0 ?
|
|
4963
|
+
budget_limit: info("Budget limit", "workflow", (f) => `reached its budget${paren([...f.budgetUsd === void 0 ? [] : [usd$1(f.budgetUsd)], ...f.budgetTokens === void 0 ? [] : [`${String(f.budgetTokens)} tokens`]].join(", ") || void 0)}`, "Raise `budgetUsd` or `budgetTokens` on the node, or the same in the workflow's `limits`, then `ork resume`."),
|
|
4958
4964
|
timed_out: info("Timed out", "workflow", (f) => `reached its timeout${paren(f.timeout)}`, "Raise its `timeout`, or give it a smaller task."),
|
|
4959
4965
|
context_too_long: info("Context too long", "workflow", () => "outgrew its model's context", "Give it a smaller task or less context, or a model with a larger context."),
|
|
4960
4966
|
plan_limit: info("Plan limit", "account", (f) => `ran into a usage limit${paren(f.window?.replaceAll("_", " "))}`, `Wait until the limit resets. ${RETRY}`, {
|
|
@@ -7160,7 +7166,8 @@ const TEMPLATE_FIELDS = [
|
|
|
7160
7166
|
"document",
|
|
7161
7167
|
"workflow",
|
|
7162
7168
|
"project",
|
|
7163
|
-
"ref"
|
|
7169
|
+
"ref",
|
|
7170
|
+
"lock"
|
|
7164
7171
|
];
|
|
7165
7172
|
/**
|
|
7166
7173
|
* The templates of a node that may read `{{item}}`, beside `inputs`,
|
|
@@ -7207,6 +7214,14 @@ const checkReferences = (workflow, edges, rules = /* @__PURE__ */ new Map()) =>
|
|
|
7207
7214
|
}))));
|
|
7208
7215
|
const fromMcp = Object.entries(workflow.nodes).flatMap(([id, node]) => node.type === "mcp.call" ? stringsIn(node.arguments ?? {}, ["arguments"]).flatMap(({ at, text }) => checkTemplate(id, at, text, context, itemOf(id))) : []);
|
|
7209
7216
|
const fromSteps = Object.entries(workflow.nodes).flatMap(([id, node]) => isToolNode(node) ? [] : programsOf(node).flatMap(({ at, program }) => program.command === void 0 ? [] : checkTemplate(id, [...at, "command"], program.command, context)));
|
|
7217
|
+
const fromLocks = Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
7218
|
+
const parsed = "lock" in node ? parseTemplate(node.lock ?? "") : void 0;
|
|
7219
|
+
return parsed?.ok === true ? parsed.parts.flatMap((part) => typeof part === "string" ? [] : collectPaths(part.expr).filter(([root]) => root === "nodes").map((segments) => error("lock.scope", `\`${describePath(segments)}\`: a lock's name reads only \`inputs\`, since it is taken before the node runs`, [
|
|
7220
|
+
"nodes",
|
|
7221
|
+
id,
|
|
7222
|
+
"lock"
|
|
7223
|
+
]))) : [];
|
|
7224
|
+
});
|
|
7210
7225
|
const fromOutputs = Object.entries(workflow.outputs ?? {}).flatMap(([name, value]) => {
|
|
7211
7226
|
const parsed = parseTemplate(value);
|
|
7212
7227
|
const path = ["outputs", name];
|
|
@@ -7220,6 +7235,7 @@ const checkReferences = (workflow, edges, rules = /* @__PURE__ */ new Map()) =>
|
|
|
7220
7235
|
...fromEnv,
|
|
7221
7236
|
...fromSteps,
|
|
7222
7237
|
...fromMcp,
|
|
7238
|
+
...fromLocks,
|
|
7223
7239
|
...checkToolEnv(workflow),
|
|
7224
7240
|
...fromOutputs,
|
|
7225
7241
|
...checkConditionRefs(workflow, edges, rules, context)
|
|
@@ -7566,780 +7582,940 @@ const slotClock = (base, machine) => {
|
|
|
7566
7582
|
}
|
|
7567
7583
|
};
|
|
7568
7584
|
};
|
|
7569
|
-
// ../core/src/
|
|
7585
|
+
// ../core/src/run/declared.ts
|
|
7570
7586
|
/**
|
|
7571
|
-
* @file
|
|
7572
|
-
*
|
|
7573
|
-
*
|
|
7574
|
-
*
|
|
7587
|
+
* @file Holding a node to what it declares of its output (RFC-010): a missing
|
|
7588
|
+
* field, the wrong type or a value outside the list is a problem to fail the
|
|
7589
|
+
* node on, so a condition never quietly matches nothing. A declared list
|
|
7590
|
+
* (RFC-013) is held to being a list, and each item to its shape. It reads the
|
|
7591
|
+
* format's own shape; a field written out as JSON Schema stays the driver's
|
|
7592
|
+
* to enforce. A list a resume hands back keeps the file's real values when it
|
|
7593
|
+
* only differs by masking.
|
|
7575
7594
|
*/
|
|
7576
|
-
const
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
} catch {
|
|
7582
|
-
return "unreadable";
|
|
7583
|
-
}
|
|
7595
|
+
const TYPE_NAMES = {
|
|
7596
|
+
string: "text",
|
|
7597
|
+
number: "a number",
|
|
7598
|
+
integer: "a whole number",
|
|
7599
|
+
boolean: "true or false"
|
|
7584
7600
|
};
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7601
|
+
const FITS = {
|
|
7602
|
+
string: (value) => typeof value === "string",
|
|
7603
|
+
number: (value) => typeof value === "number",
|
|
7604
|
+
integer: (value) => Number.isInteger(value),
|
|
7605
|
+
boolean: (value) => typeof value === "boolean"
|
|
7606
|
+
};
|
|
7607
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7608
|
+
const listed = (values) => {
|
|
7609
|
+
const names = values.map((value) => markValue(String(value)));
|
|
7610
|
+
const last = names.pop();
|
|
7611
|
+
return names.length === 0 ? String(last) : `${names.join(", ")} or ${String(last)}`;
|
|
7612
|
+
};
|
|
7613
|
+
const shown = (value) => {
|
|
7614
|
+
const text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
|
|
7615
|
+
return markValue(text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
|
7616
|
+
};
|
|
7617
|
+
/** What's wrong with one field, if it's a shape this can read. */
|
|
7618
|
+
const fieldProblem = (name, rule, value) => {
|
|
7619
|
+
const shape = listShape(rule);
|
|
7620
|
+
if (shape !== void 0) return listProblem(name, shape, value);
|
|
7621
|
+
const expected = Array.isArray(rule) ? listed(rule) : typeof rule === "string" ? TYPE_NAMES[rule] : void 0;
|
|
7622
|
+
if (expected === void 0) return null;
|
|
7623
|
+
if (value === void 0) return `${name} is missing, expected ${expected}`;
|
|
7624
|
+
return (Array.isArray(rule) ? rule.some((item) => item === value) : FITS[String(rule)]?.(value)) === true ? null : `${name} is ${shown(value)}, expected ${expected}`;
|
|
7625
|
+
};
|
|
7626
|
+
const fieldProblems = (declared, value, prefix) => Object.entries(declared).flatMap(([name, rule]) => {
|
|
7627
|
+
const problem = fieldProblem(`${prefix}${name}`, rule, value[name]);
|
|
7628
|
+
return problem === null ? [] : [problem];
|
|
7629
|
+
});
|
|
7630
|
+
/** What's wrong with one item; `name` says which, as `tasks[2]`. */
|
|
7631
|
+
const itemProblems = (name, shape, item) => {
|
|
7632
|
+
if (Object.keys(shape).length === 0) return [];
|
|
7633
|
+
return isRecord$2(item) ? fieldProblems(shape, item, `${name}.`) : [`${name} is ${shown(item)}, expected an object`];
|
|
7634
|
+
};
|
|
7635
|
+
/** Only the first bad item is named, so a long list can't flood the reason. */
|
|
7636
|
+
const listProblem = (name, shape, value) => {
|
|
7637
|
+
if (value === void 0) return `${name} is missing, expected a list`;
|
|
7638
|
+
if (!Array.isArray(value)) return `${name} is ${shown(value)}, expected a list`;
|
|
7639
|
+
const bad = value.map((item, index) => itemProblems(`${name}[${index}]`, shape, item)).find((problems) => problems.length > 0);
|
|
7640
|
+
return bad === void 0 ? null : bad.join("; ");
|
|
7593
7641
|
};
|
|
7594
7642
|
/**
|
|
7595
|
-
*
|
|
7596
|
-
*
|
|
7597
|
-
* call of one `node:` runs only that session, so its tool nodes do not count
|
|
7598
|
-
* unless `everyCall`, and nor does a call among `finished`. `unreadable` hears
|
|
7599
|
-
* of one that is there but could not be read.
|
|
7643
|
+
* What's wrong with `value` by this declaration, or `null`. With nothing
|
|
7644
|
+
* declared, nothing can be.
|
|
7600
7645
|
*/
|
|
7601
|
-
const
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
const
|
|
7605
|
-
|
|
7606
|
-
if (node.type !== "workflow.call") continue;
|
|
7607
|
-
if (hasTemplate(node.workflow) || hasTemplate(node.project ?? "")) continue;
|
|
7608
|
-
if (options.everyCall !== true && node.node !== void 0) continue;
|
|
7609
|
-
if (from === workflow && options.finished?.has(id) === true) continue;
|
|
7610
|
-
const target = node.project === void 0 ? place : registered.find((other) => other.id === node.project);
|
|
7611
|
-
if (target === void 0) continue;
|
|
7612
|
-
const file = path.join(placeFiles(target).workflows, `${path.basename(node.workflow)}.ork.yaml`);
|
|
7613
|
-
if (seen.has(file)) continue;
|
|
7614
|
-
seen.add(file);
|
|
7615
|
-
const state = fileState(file);
|
|
7616
|
-
if (state === "none") continue;
|
|
7617
|
-
const called = state === "file" ? readWorkflow(file) : null;
|
|
7618
|
-
if (called === null) {
|
|
7619
|
-
options.unreadable?.(node.workflow);
|
|
7620
|
-
continue;
|
|
7621
|
-
}
|
|
7622
|
-
found.push(called);
|
|
7623
|
-
walk(called, target);
|
|
7624
|
-
}
|
|
7625
|
-
};
|
|
7626
|
-
walk(workflow, own);
|
|
7627
|
-
return found;
|
|
7646
|
+
const declaredProblem = (declared, value) => {
|
|
7647
|
+
if (declared === void 0 || Object.keys(declared).length === 0) return null;
|
|
7648
|
+
if (!isRecord$2(value)) return `the result isn't a JSON object`;
|
|
7649
|
+
const problems = fieldProblems(declared, value, "");
|
|
7650
|
+
return problems.length === 0 ? null : problems.join("; ");
|
|
7628
7651
|
};
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7652
|
+
/**
|
|
7653
|
+
* The list a resume hands back. Events are stored masked, so a static list that
|
|
7654
|
+
* still masks to the record is the file's, with its real values.
|
|
7655
|
+
*/
|
|
7656
|
+
const unmasked = (forEach, recorded) => "items" in forEach && JSON.stringify(forEach.items, maskReplacer) === JSON.stringify(recorded) ? [...forEach.items] : recorded;
|
|
7657
|
+
// ../core/src/run/ended.ts
|
|
7658
|
+
/**
|
|
7659
|
+
* @file A node's result as it ended (specs/runtime/session-ends.md): one place
|
|
7660
|
+
* that sets `end`, makes a failure's `error` its message and marks a
|
|
7661
|
+
* transient kind, and that the engine's modules build ends through.
|
|
7662
|
+
*/
|
|
7663
|
+
/**
|
|
7664
|
+
* The result as it ended: a failure's `error` is the end's message (what the
|
|
7665
|
+
* run summary lists), and a transient kind marks it `transient`.
|
|
7666
|
+
*/
|
|
7667
|
+
const endedWith = (result, end) => ({
|
|
7668
|
+
...result,
|
|
7669
|
+
end,
|
|
7670
|
+
...result.ok ? {} : { error: end.message },
|
|
7671
|
+
...END_KINDS[end.kind].transient ? { transient: true } : {}
|
|
7648
7672
|
});
|
|
7649
|
-
|
|
7673
|
+
/** The reason a run's signal is aborted with when `timeout` passed. */
|
|
7674
|
+
const runTimedOut = (timeout) => timeout === void 0 ? { kind: "timed_out" } : {
|
|
7675
|
+
kind: "timed_out",
|
|
7676
|
+
timeout
|
|
7677
|
+
};
|
|
7678
|
+
const isTimedOut = (reason) => typeof reason === "object" && reason !== null && "kind" in reason && reason.kind === "timed_out";
|
|
7650
7679
|
/**
|
|
7651
|
-
*
|
|
7652
|
-
*
|
|
7653
|
-
* for a join). Pure: the scheduler owns time, sessions and loop counters.
|
|
7680
|
+
* The end of a node its run's signal stopped: `timed_out` when the run's
|
|
7681
|
+
* timeout aborted it, `interrupted` otherwise.
|
|
7654
7682
|
*/
|
|
7655
|
-
|
|
7656
|
-
const
|
|
7657
|
-
|
|
7658
|
-
const
|
|
7659
|
-
|
|
7660
|
-
if (key === void 0) {
|
|
7661
|
-
key = `${String(edge.source)}:${edge.from}->${edge.to}`;
|
|
7662
|
-
keys.set(edge, key);
|
|
7663
|
-
}
|
|
7664
|
-
return key;
|
|
7683
|
+
const stoppedEnd = (signal, detail) => {
|
|
7684
|
+
const reason = signal.reason;
|
|
7685
|
+
if (!isTimedOut(reason)) return endFor("interrupted", {}, detail);
|
|
7686
|
+
const facts = reason.timeout === void 0 ? {} : { timeout: reason.timeout };
|
|
7687
|
+
return endFor("timed_out", facts, detail);
|
|
7665
7688
|
};
|
|
7689
|
+
/** A failure that ended as `end`, its message the output's summary. */
|
|
7690
|
+
const failedWith = (end, output) => endedWith({
|
|
7691
|
+
ok: false,
|
|
7692
|
+
output: output ?? {
|
|
7693
|
+
status: "fail",
|
|
7694
|
+
summary: end.message
|
|
7695
|
+
}
|
|
7696
|
+
}, end);
|
|
7666
7697
|
/**
|
|
7667
|
-
*
|
|
7668
|
-
*
|
|
7669
|
-
*
|
|
7670
|
-
* matching edge.
|
|
7698
|
+
* The result with its end's detail scrubbed with the feedback redactor (the
|
|
7699
|
+
* run's `roots` and the home folder), as a session's is
|
|
7700
|
+
* (specs/runtime/session-ends.md), whichever node type set it.
|
|
7671
7701
|
*/
|
|
7672
|
-
const
|
|
7673
|
-
const
|
|
7674
|
-
if (
|
|
7675
|
-
const
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
return compiled.outgoing.map((edge) => {
|
|
7679
|
-
const taken = (edge.condition === void 0 ? result.ok : truthy(evaluate(edge.condition, {
|
|
7680
|
-
...scope,
|
|
7681
|
-
output: {
|
|
7682
|
-
ok: result.ok,
|
|
7683
|
-
...result.output
|
|
7684
|
-
}
|
|
7685
|
-
}))) && !(firstOnly && matched);
|
|
7686
|
-
if (taken) matched = true;
|
|
7687
|
-
return {
|
|
7688
|
-
edge,
|
|
7689
|
-
taken
|
|
7690
|
-
};
|
|
7702
|
+
const scrubbedEnd = (result, roots) => {
|
|
7703
|
+
const { end } = result;
|
|
7704
|
+
if (end?.detail === void 0) return result;
|
|
7705
|
+
const detail = redact(end.detail, {
|
|
7706
|
+
roots,
|
|
7707
|
+
home: homedir()
|
|
7691
7708
|
});
|
|
7692
|
-
};
|
|
7693
|
-
const joinOutput = (edges, resolved) => {
|
|
7694
|
-
const ran = edges.filter((e) => resolved.get(edgeKey(e))?.taken === true);
|
|
7695
|
-
const results = {};
|
|
7696
|
-
for (const edge of ran) results[edge.from] = resolved.get(edgeKey(edge))?.output ?? {};
|
|
7697
7709
|
return {
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7710
|
+
...result,
|
|
7711
|
+
end: {
|
|
7712
|
+
...end,
|
|
7713
|
+
detail
|
|
7714
|
+
}
|
|
7701
7715
|
};
|
|
7702
7716
|
};
|
|
7703
|
-
|
|
7704
|
-
const STUCK_REASON = "ork engine error:";
|
|
7717
|
+
// ../core/src/run/slots.ts
|
|
7705
7718
|
/**
|
|
7706
|
-
*
|
|
7707
|
-
*
|
|
7708
|
-
*
|
|
7719
|
+
* @file Session slots: at most `limits.concurrency` Claude sessions run at
|
|
7720
|
+
* once. Queued activations take a free slot when the scheduler pumps;
|
|
7721
|
+
* delegated children and leaders returning from `delegate` wait for one and
|
|
7722
|
+
* are served first, so a leader waiting on its children never holds a slot
|
|
7723
|
+
* they need.
|
|
7709
7724
|
*/
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
const
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
if (
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
* a later round, a branch the loop doesn't go back to never runs again.
|
|
7740
|
-
*/
|
|
7741
|
-
const createInbox = (graph) => {
|
|
7742
|
-
const resolved = /* @__PURE__ */ new Map();
|
|
7743
|
-
/** Targets that ran, or joins that fired, in their current round. */
|
|
7744
|
-
const fired = /* @__PURE__ */ new Set();
|
|
7745
|
-
let order;
|
|
7746
|
-
const forwards = /* @__PURE__ */ new Map();
|
|
7747
|
-
const forwardIn = (id) => {
|
|
7748
|
-
let edges = forwards.get(id);
|
|
7749
|
-
if (edges === void 0) {
|
|
7750
|
-
edges = (graph.nodes.get(id)?.incoming ?? []).filter((e) => !isLoopEdge(e));
|
|
7751
|
-
forwards.set(id, edges);
|
|
7752
|
-
}
|
|
7753
|
-
return edges;
|
|
7754
|
-
};
|
|
7755
|
-
const reset = (id) => {
|
|
7756
|
-
resolved.delete(id);
|
|
7757
|
-
fired.delete(id);
|
|
7758
|
-
};
|
|
7759
|
-
const arriveAtJoin = (id, seen) => {
|
|
7760
|
-
const node = graph.nodes.get(id)?.node;
|
|
7761
|
-
if (node?.type !== "control.join") return { kind: "wait" };
|
|
7762
|
-
const inputs = forwardIn(id);
|
|
7763
|
-
const ran = inputs.filter((e) => seen.get(edgeKey(e))?.taken === true);
|
|
7764
|
-
const complete = inputs.every((e) => seen.has(edgeKey(e)));
|
|
7765
|
-
const mode = node.mode ?? "all";
|
|
7766
|
-
const needed = mode === "any" ? 1 : node.n ?? inputs.length;
|
|
7767
|
-
if (mode === "all") {
|
|
7768
|
-
if (!complete) return { kind: "wait" };
|
|
7769
|
-
reset(id);
|
|
7770
|
-
return ran.length > 0 ? {
|
|
7771
|
-
kind: "activate",
|
|
7772
|
-
input: joinOutput(inputs, seen)
|
|
7773
|
-
} : { kind: "skip" };
|
|
7774
|
-
}
|
|
7775
|
-
if (!fired.has(id) && ran.length >= needed) {
|
|
7776
|
-
const input = joinOutput(inputs, seen);
|
|
7777
|
-
if (complete) reset(id);
|
|
7778
|
-
else fired.add(id);
|
|
7779
|
-
return {
|
|
7780
|
-
kind: "activate",
|
|
7781
|
-
input
|
|
7782
|
-
};
|
|
7725
|
+
/** Only a Claude session takes a slot; tools and gates are not limited. */
|
|
7726
|
+
const needsSlot = (node) => isClaudeNode(node);
|
|
7727
|
+
/** A call spends through its run, so it needs budget as a session does. */
|
|
7728
|
+
const spendsBudget = (node) => needsSlot(node) || node.type === "workflow.call";
|
|
7729
|
+
const createSlots = (limit, onFree) => {
|
|
7730
|
+
let used = 0;
|
|
7731
|
+
const waiting = [];
|
|
7732
|
+
return {
|
|
7733
|
+
limit,
|
|
7734
|
+
tryTake: () => {
|
|
7735
|
+
if (used >= limit || waiting.length > 0) return false;
|
|
7736
|
+
used += 1;
|
|
7737
|
+
return true;
|
|
7738
|
+
},
|
|
7739
|
+
take: () => {
|
|
7740
|
+
if (used < limit && waiting.length === 0) {
|
|
7741
|
+
used += 1;
|
|
7742
|
+
return Promise.resolve();
|
|
7743
|
+
}
|
|
7744
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
7745
|
+
waiting.push(resolve);
|
|
7746
|
+
return promise;
|
|
7747
|
+
},
|
|
7748
|
+
release: () => {
|
|
7749
|
+
const next = waiting.shift();
|
|
7750
|
+
if (next === void 0) {
|
|
7751
|
+
used -= 1;
|
|
7752
|
+
onFree();
|
|
7753
|
+
} else next();
|
|
7783
7754
|
}
|
|
7784
|
-
if (!complete) return { kind: "wait" };
|
|
7785
|
-
const wasFired = fired.has(id);
|
|
7786
|
-
reset(id);
|
|
7787
|
-
return wasFired ? { kind: "wait" } : { kind: "skip" };
|
|
7788
7755
|
};
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
if (graph.nodes.get(id)?.node.type === "control.join") return arriveAtJoin(id, seen);
|
|
7799
|
-
const complete = forwardIn(id).every((e) => seen.has(edgeKey(e)));
|
|
7800
|
-
const ran = taken || fired.has(id);
|
|
7801
|
-
if (complete) reset(id);
|
|
7802
|
-
else if (taken) fired.add(id);
|
|
7803
|
-
if (taken) return {
|
|
7804
|
-
kind: "activate",
|
|
7805
|
-
input: output
|
|
7806
|
-
};
|
|
7807
|
-
return complete && !ran ? { kind: "skip" } : { kind: "wait" };
|
|
7756
|
+
};
|
|
7757
|
+
// ../core/src/run/fan-out.ts
|
|
7758
|
+
/** The node's `forEach`, whatever its type; `undefined` when it has none. */
|
|
7759
|
+
const forEachOf = (node) => node !== void 0 && "forEach" in node ? node.forEach : void 0;
|
|
7760
|
+
/** The list a node runs over, or why there isn't one. */
|
|
7761
|
+
const listOf = (forEach, scope) => {
|
|
7762
|
+
if ("items" in forEach) return {
|
|
7763
|
+
ok: true,
|
|
7764
|
+
list: forEach.items
|
|
7808
7765
|
};
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
* so it doesn't count. Its edges count as not taken; the caller delivers
|
|
7816
|
-
* them, and asks again, as that can start something.
|
|
7817
|
-
*/
|
|
7818
|
-
const stranded = (active) => {
|
|
7819
|
-
const candidates = [...resolved].map(([id, seen]) => ({
|
|
7820
|
-
id,
|
|
7821
|
-
missing: missingOf(id, seen)
|
|
7822
|
-
})).filter(({ missing }) => !missing.some((e) => active(e.from)));
|
|
7823
|
-
if (candidates.length === 0) return [];
|
|
7824
|
-
/** Nodes known to be unable to run, in this call. */
|
|
7825
|
-
const dead = /* @__PURE__ */ new Set();
|
|
7826
|
-
const canRun = (from) => {
|
|
7827
|
-
const visited = /* @__PURE__ */ new Set([from]);
|
|
7828
|
-
const stack = [from];
|
|
7829
|
-
for (let id = stack.pop(); id !== void 0; id = stack.pop()) {
|
|
7830
|
-
if (active(id)) return true;
|
|
7831
|
-
for (const edge of forwardIn(id)) {
|
|
7832
|
-
if (visited.has(edge.from) || dead.has(edge.from)) continue;
|
|
7833
|
-
visited.add(edge.from);
|
|
7834
|
-
stack.push(edge.from);
|
|
7835
|
-
}
|
|
7836
|
-
}
|
|
7837
|
-
for (const id of visited) dead.add(id);
|
|
7838
|
-
return false;
|
|
7839
|
-
};
|
|
7840
|
-
order ??= orderOf(graph);
|
|
7841
|
-
const known = order;
|
|
7842
|
-
const place = (id) => known.get(id) ?? Infinity;
|
|
7843
|
-
return candidates.filter(({ missing }) => !missing.some((e) => canRun(e.from))).toSorted((a, b) => place(a.id) - place(b.id))[0]?.missing ?? [];
|
|
7766
|
+
const read = templateValue(forEach.from, scope);
|
|
7767
|
+
if (!read.ok) return read;
|
|
7768
|
+
const { value } = read;
|
|
7769
|
+
if (Array.isArray(value)) return {
|
|
7770
|
+
ok: true,
|
|
7771
|
+
list: value
|
|
7844
7772
|
};
|
|
7845
|
-
|
|
7846
|
-
const waiting = () => [...resolved].map(([id, seen]) => ({
|
|
7847
|
-
id,
|
|
7848
|
-
waitingOn: [...new Set(missingOf(id, seen).map((e) => e.from))]
|
|
7849
|
-
}));
|
|
7773
|
+
const name = String(templatePath(forEach.from)?.at(-1) ?? forEach.from);
|
|
7850
7774
|
return {
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
waiting
|
|
7775
|
+
ok: false,
|
|
7776
|
+
error: declaredProblem({ [name]: { list: {} } }, { [name]: value }) ?? `${name} is not a list`
|
|
7854
7777
|
};
|
|
7855
7778
|
};
|
|
7856
|
-
|
|
7857
|
-
const
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
};
|
|
7862
|
-
/** What a list over its `maxItems` can be answered with (RFC-013). */
|
|
7863
|
-
const EACH_CHOICES = {
|
|
7864
|
-
stop: "stop",
|
|
7865
|
-
first: "first",
|
|
7866
|
-
all: "all"
|
|
7779
|
+
/** The output a `from` list comes from, whose freshness resume asks about. */
|
|
7780
|
+
const sourceOf = (forEach, scope) => {
|
|
7781
|
+
if (!("from" in forEach)) return void 0;
|
|
7782
|
+
const [root, id] = templatePath(forEach.from) ?? [];
|
|
7783
|
+
return root === "nodes" ? scope.nodes[String(id)]?.output : void 0;
|
|
7867
7784
|
};
|
|
7868
|
-
|
|
7869
|
-
const eachQuestion = (node, count, maxItems, round = 1) => ({
|
|
7870
|
-
question: `The list of ${node}${round > 1 ? ` (round ${String(round)})` : ""} has ${String(count)} items, more than its maxItems of ${String(maxItems)}. What now?`,
|
|
7871
|
-
options: [
|
|
7872
|
-
{
|
|
7873
|
-
id: EACH_CHOICES.stop,
|
|
7874
|
-
description: "Stop the run"
|
|
7875
|
-
},
|
|
7876
|
-
{
|
|
7877
|
-
id: EACH_CHOICES.first,
|
|
7878
|
-
description: `Run the first ${String(maxItems)}`
|
|
7879
|
-
},
|
|
7880
|
-
{
|
|
7881
|
-
id: EACH_CHOICES.all,
|
|
7882
|
-
description: `Run all ${String(count)}`
|
|
7883
|
-
}
|
|
7884
|
-
],
|
|
7885
|
-
recommended: EACH_CHOICES.stop,
|
|
7886
|
-
context: `${node} runs at most ${String(maxItems)} items unless someone says otherwise.`
|
|
7887
|
-
});
|
|
7888
|
-
/** The question about a loop out of rounds; the recommended choice stops. */
|
|
7889
|
-
const loopQuestion = (edge) => ({
|
|
7890
|
-
question: `The loop ${edge.from} -> ${edge.to} used all ${String(edge.maxRounds ?? 0)} of its rounds. What now?`,
|
|
7891
|
-
options: [
|
|
7892
|
-
{
|
|
7893
|
-
id: LOOP_CHOICES.stop,
|
|
7894
|
-
description: "Stop the run"
|
|
7895
|
-
},
|
|
7896
|
-
{
|
|
7897
|
-
id: LOOP_CHOICES.again,
|
|
7898
|
-
description: "Allow one more round"
|
|
7899
|
-
},
|
|
7900
|
-
{
|
|
7901
|
-
id: LOOP_CHOICES.onward,
|
|
7902
|
-
description: "Go on along the other edges"
|
|
7903
|
-
}
|
|
7904
|
-
],
|
|
7905
|
-
recommended: LOOP_CHOICES.stop,
|
|
7906
|
-
context: `${edge.from} still wants another round${edge.when === void 0 ? "" : ` (${edge.when.trim()})`}.`
|
|
7907
|
-
});
|
|
7785
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7908
7786
|
/**
|
|
7909
|
-
*
|
|
7910
|
-
*
|
|
7911
|
-
*
|
|
7787
|
+
* What `results` keeps of an item: its declared fields (a program's are under
|
|
7788
|
+
* `json`), its summary, Ork's own branch and worktree, and how a program
|
|
7789
|
+
* failed. The full output stays in the item's own `node.finished`.
|
|
7912
7790
|
*/
|
|
7913
|
-
const
|
|
7914
|
-
const
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
const
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7791
|
+
const entryOf = (declared, { ok, output, error }) => {
|
|
7792
|
+
const json = isRecord$1(output["json"]) ? output["json"] : {};
|
|
7793
|
+
const fields = Object.keys(declared ?? {}).map((field) => [field, Object.hasOwn(json, field) ? json[field] : output[field]]);
|
|
7794
|
+
const own = [
|
|
7795
|
+
"summary",
|
|
7796
|
+
"branch",
|
|
7797
|
+
"worktree",
|
|
7798
|
+
"merge"
|
|
7799
|
+
].map((field) => [field, output[field]]);
|
|
7800
|
+
const failed = !ok && typeof output["exitCode"] === "number" ? [["exitCode", output["exitCode"]], ["error", error]] : [];
|
|
7801
|
+
return Object.fromEntries([
|
|
7802
|
+
...fields,
|
|
7803
|
+
...own,
|
|
7804
|
+
...failed
|
|
7805
|
+
].filter(([, value]) => value !== void 0));
|
|
7806
|
+
};
|
|
7807
|
+
/** Whether an item passed but its work could not reach its owner. */
|
|
7808
|
+
const mergeFailed = ({ output }) => isRecord$1(output["merge"]) && output["merge"]["ok"] === false;
|
|
7809
|
+
const statusOf$2 = (result) => {
|
|
7810
|
+
if (mergeFailed(result)) return "fail";
|
|
7811
|
+
const { ok, output } = result;
|
|
7812
|
+
const status = output["status"];
|
|
7813
|
+
if (typeof status === "string") return status;
|
|
7814
|
+
return ok ? "pass" : "fail";
|
|
7815
|
+
};
|
|
7816
|
+
/** Why a fan-out did not succeed, naming the first item that failed. */
|
|
7817
|
+
const whyNot = (count, failures, skipped) => {
|
|
7818
|
+
const parts = [`${String(failures.length)} of ${String(count)} items failed`];
|
|
7819
|
+
const [first] = failures;
|
|
7820
|
+
if (first !== void 0) parts[0] = `${parts[0] ?? ""}; first, ${first}`;
|
|
7821
|
+
if (skipped > 0) parts.push(`${String(skipped)} not started`);
|
|
7822
|
+
return parts.join("; ");
|
|
7823
|
+
};
|
|
7824
|
+
/**
|
|
7825
|
+
* Waits until the item may start: not while it is held, and without keeping a
|
|
7826
|
+
* session slot through a pause, which would stop other nodes.
|
|
7827
|
+
*/
|
|
7828
|
+
const awaitTurn = async (hold, slots, signal) => {
|
|
7829
|
+
for (;;) {
|
|
7830
|
+
await hold.wait("start", signal);
|
|
7831
|
+
if (slots === null) return;
|
|
7832
|
+
await slots.take();
|
|
7833
|
+
if (!hold.held() || signal.aborted) return;
|
|
7834
|
+
slots.release();
|
|
7835
|
+
}
|
|
7836
|
+
};
|
|
7837
|
+
const createFanOut = (deps) => {
|
|
7838
|
+
const { holds, slots, stops, nodeOf, perform, options } = deps;
|
|
7839
|
+
return async (activation, forEach, round) => {
|
|
7840
|
+
const { id, input } = activation;
|
|
7841
|
+
const scope = deps.scope();
|
|
7842
|
+
const recorded = options.replay?.listed?.(id, round, sourceOf(forEach, scope)) ?? null;
|
|
7843
|
+
const listed = recorded === null ? listOf(forEach, scope) : {
|
|
7844
|
+
ok: true,
|
|
7845
|
+
list: unmasked(forEach, recorded)
|
|
7846
|
+
};
|
|
7847
|
+
if (!listed.ok) return failedWith(endFor("invalid_node", { reason: listed.error }));
|
|
7848
|
+
let { list } = listed;
|
|
7849
|
+
const recordList = () => {
|
|
7930
7850
|
options.onEvent({
|
|
7931
|
-
type: "
|
|
7932
|
-
|
|
7933
|
-
|
|
7851
|
+
type: "each.listed",
|
|
7852
|
+
node: id,
|
|
7853
|
+
round,
|
|
7854
|
+
count: list.length,
|
|
7855
|
+
list,
|
|
7856
|
+
...recorded === null && options.replay !== void 0 ? { anew: true } : {}
|
|
7934
7857
|
});
|
|
7935
|
-
|
|
7858
|
+
};
|
|
7859
|
+
recordList();
|
|
7860
|
+
const signal = stops.signalFor(id);
|
|
7861
|
+
if ("maxItems" in forEach && list.length > forEach.maxItems) {
|
|
7862
|
+
options.onEvent({
|
|
7863
|
+
type: "each.exceeded",
|
|
7864
|
+
node: id,
|
|
7865
|
+
round,
|
|
7866
|
+
count: list.length,
|
|
7867
|
+
maxItems: forEach.maxItems
|
|
7868
|
+
});
|
|
7869
|
+
const chosen = await deps.loops.overList(id, list, forEach.maxItems, round, signal);
|
|
7870
|
+
if (chosen === null) {
|
|
7871
|
+
const reason = `the list of ${id} has ${String(list.length)} items, over maxItems ${String(forEach.maxItems)}`;
|
|
7872
|
+
return stops.ended(id, {
|
|
7873
|
+
...failedWith(endFor("invalid_node", { reason }), {
|
|
7874
|
+
status: "fail",
|
|
7875
|
+
summary: reason
|
|
7876
|
+
}),
|
|
7877
|
+
limited: reason,
|
|
7878
|
+
stopsAs: "failed"
|
|
7879
|
+
});
|
|
7880
|
+
}
|
|
7881
|
+
const narrowed = chosen.length !== list.length;
|
|
7882
|
+
list = chosen;
|
|
7883
|
+
if (narrowed) recordList();
|
|
7936
7884
|
}
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
const
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7885
|
+
const node = nodeOf(activation);
|
|
7886
|
+
const session = node !== void 0 && needsSlot(node);
|
|
7887
|
+
const spends = node !== void 0 && spendsBudget(node);
|
|
7888
|
+
const declared = node === void 0 ? void 0 : declaredOutputOf(node);
|
|
7889
|
+
const results = list.map(() => ({}));
|
|
7890
|
+
const statuses = list.map(() => "skipped");
|
|
7891
|
+
const failures = [];
|
|
7892
|
+
/** How the first item that failed ended: the fanned node's own end. */
|
|
7893
|
+
let firstEnd;
|
|
7894
|
+
const mayStart = () => !signal.aborted && !deps.isStopped() && (forEach.onFailure !== "stop" || failures.length === 0);
|
|
7895
|
+
const runItem = async (index) => {
|
|
7896
|
+
const itemId = `${id}.${String(index)}`;
|
|
7897
|
+
const hold = holds.forNode(itemId, id);
|
|
7898
|
+
await awaitTurn(hold, session ? slots : null, signal);
|
|
7899
|
+
try {
|
|
7900
|
+
if (!mayStart() || spends && deps.budgetSpent()) return;
|
|
7901
|
+
const result = await perform({
|
|
7902
|
+
id: itemId,
|
|
7903
|
+
input,
|
|
7904
|
+
template: id,
|
|
7905
|
+
item: {
|
|
7906
|
+
of: id,
|
|
7907
|
+
index,
|
|
7908
|
+
value: list[index]
|
|
7909
|
+
},
|
|
7910
|
+
round
|
|
7911
|
+
});
|
|
7912
|
+
results[index] = entryOf(declared, result);
|
|
7913
|
+
statuses[index] = statusOf$2(result);
|
|
7914
|
+
if (!result.ok) {
|
|
7915
|
+
firstEnd ??= endOf(result);
|
|
7916
|
+
failures.push(`${itemId}: ${result.error ?? "failed"}`);
|
|
7917
|
+
} else if (mergeFailed(result)) failures.push(`${itemId}: its work could not be merged`);
|
|
7918
|
+
} finally {
|
|
7919
|
+
if (session) slots.release();
|
|
7920
|
+
}
|
|
7921
|
+
};
|
|
7922
|
+
let next = 0;
|
|
7923
|
+
const worker = async () => {
|
|
7924
|
+
while (next < list.length && mayStart()) {
|
|
7925
|
+
const index = next;
|
|
7926
|
+
next += 1;
|
|
7927
|
+
await runItem(index);
|
|
7928
|
+
}
|
|
7929
|
+
};
|
|
7930
|
+
const lanes = Math.min(forEach.maxParallel ?? slots.limit, list.length);
|
|
7931
|
+
await Promise.all(Array.from({ length: lanes }, () => worker()));
|
|
7932
|
+
const skipped = statuses.filter((status) => status === "skipped").length;
|
|
7933
|
+
const ok = failures.length === 0 && skipped === 0;
|
|
7934
|
+
const gathered = {
|
|
7935
|
+
ok,
|
|
7936
|
+
output: {
|
|
7937
|
+
items: list,
|
|
7938
|
+
results,
|
|
7939
|
+
statuses,
|
|
7940
|
+
count: list.length
|
|
7941
|
+
},
|
|
7942
|
+
...ok ? {} : { error: whyNot(list.length, failures, skipped) },
|
|
7943
|
+
...ok || firstEnd === void 0 ? {} : { end: firstEnd },
|
|
7944
|
+
...!ok && deps.isStopped() ? { limited: deps.reasons[0] ?? "the run stopped" } : {}
|
|
7945
|
+
};
|
|
7946
|
+
return {
|
|
7947
|
+
...stops.ended(id, gathered),
|
|
7948
|
+
output: gathered.output
|
|
7949
|
+
};
|
|
7950
|
+
};
|
|
7951
|
+
};
|
|
7952
|
+
// ../core/src/run/locks.ts
|
|
7953
|
+
/**
|
|
7954
|
+
* @file A run's locks (RFC-016): a node that names a lock takes it from the
|
|
7955
|
+
* machine's ledger before it runs, or joins the run's hold when the run has
|
|
7956
|
+
* it already. The hold spans nodes: it goes when a node naming it ends and no
|
|
7957
|
+
* node the graph can still reach from there names it, and on every end of the
|
|
7958
|
+
* run. A fanned node's items each take and release it (unless the run already
|
|
7959
|
+
* holds it). A wait is never silent: `lock.waiting` names the holders, and
|
|
7960
|
+
* the node's, its owners' and the run's clocks stand still meanwhile.
|
|
7961
|
+
*/
|
|
7962
|
+
/** A wait for a lock, as `ork ps`, `ork logs`, `ork tree` and the UI say it. */
|
|
7963
|
+
const lockWaitLine = (lock, holders) => holders.length === 0 ? `waiting for ${lock}` : `waiting for ${lock}, held by ${holders.join(", ")}`;
|
|
7964
|
+
/** The node an activation is of: an item's or a worker's declared node. */
|
|
7965
|
+
const declaredOf = (activation) => activation.item?.of ?? activation.template ?? activation.id;
|
|
7966
|
+
const createRunLocks = (deps) => {
|
|
7967
|
+
const { graph, ledgers, onEvent, runId } = deps;
|
|
7968
|
+
/** The run's holds across nodes, by lock name. */
|
|
7969
|
+
const spans = /* @__PURE__ */ new Map();
|
|
7970
|
+
/** Each activation running under a lock; an item's own release with it. */
|
|
7971
|
+
const entered = /* @__PURE__ */ new Map();
|
|
7972
|
+
let ended = false;
|
|
7973
|
+
const nameOf = (id, item) => {
|
|
7974
|
+
const node = graph.nodes.get(id)?.node;
|
|
7975
|
+
const lock = node !== void 0 && "lock" in node ? node.lock : void 0;
|
|
7976
|
+
if (lock === void 0) return void 0;
|
|
7977
|
+
const parsed = parseTemplate(lock);
|
|
7978
|
+
return parsed.ok ? renderTemplate(parsed.parts, deps.scope(item)) : lock;
|
|
7979
|
+
};
|
|
7980
|
+
/** Whether a node the graph can reach from `from` names `lock` itself. */
|
|
7981
|
+
const laterNamed = (from, lock) => {
|
|
7982
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7983
|
+
const next = [from];
|
|
7984
|
+
for (let id = next.pop(); id !== void 0; id = next.pop()) for (const { to } of graph.nodes.get(id)?.outgoing ?? []) {
|
|
7985
|
+
if (seen.has(to)) continue;
|
|
7986
|
+
seen.add(to);
|
|
7987
|
+
if (!(forEachOf(graph.nodes.get(to)?.node) !== void 0) && nameOf(to) === lock) return true;
|
|
7988
|
+
next.push(to);
|
|
7947
7989
|
}
|
|
7948
|
-
if (choice !== LOOP_CHOICES.onward) options.fail(`the loop ${edge.from} -> ${edge.to} ran out of rounds`);
|
|
7949
7990
|
return false;
|
|
7950
7991
|
};
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7992
|
+
const take = async (id, lock, ledger, signal) => {
|
|
7993
|
+
const slots = await ledger.of(lock);
|
|
7994
|
+
const release = await deps.waitFor(id, () => slots.take({
|
|
7995
|
+
run: runId,
|
|
7996
|
+
node: id,
|
|
7997
|
+
pid: process.pid
|
|
7998
|
+
}, {
|
|
7999
|
+
signal,
|
|
8000
|
+
onWait: (holders) => {
|
|
8001
|
+
onEvent({
|
|
8002
|
+
type: "lock.waiting",
|
|
8003
|
+
id,
|
|
8004
|
+
lock,
|
|
8005
|
+
holders: holders.map((held) => `${held.run}/${held.node}`)
|
|
8006
|
+
});
|
|
8007
|
+
}
|
|
7963
8008
|
}));
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
}
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
8009
|
+
if (release !== null && ended) {
|
|
8010
|
+
release();
|
|
8011
|
+
return null;
|
|
8012
|
+
}
|
|
8013
|
+
return release;
|
|
8014
|
+
};
|
|
8015
|
+
/** Joins the run's hold of `lock`, taking it first when nobody has. */
|
|
8016
|
+
const join = async (id, lock, ledger, signal) => {
|
|
8017
|
+
for (;;) {
|
|
8018
|
+
let span = spans.get(lock);
|
|
8019
|
+
if (span === void 0) {
|
|
8020
|
+
span = {
|
|
8021
|
+
taking: take(id, lock, ledger, signal),
|
|
8022
|
+
users: /* @__PURE__ */ new Set(),
|
|
8023
|
+
last: id
|
|
8024
|
+
};
|
|
8025
|
+
spans.set(lock, span);
|
|
8026
|
+
}
|
|
8027
|
+
span.users.add(id);
|
|
8028
|
+
span.last = id;
|
|
8029
|
+
let release;
|
|
8030
|
+
try {
|
|
8031
|
+
release = await span.taking;
|
|
8032
|
+
} catch (error) {
|
|
8033
|
+
if (spans.get(lock) === span) spans.delete(lock);
|
|
8034
|
+
throw error;
|
|
8035
|
+
}
|
|
8036
|
+
if (release !== null) {
|
|
8037
|
+
span.release = release;
|
|
8038
|
+
return true;
|
|
8039
|
+
}
|
|
8040
|
+
span.users.delete(id);
|
|
8041
|
+
if (spans.get(lock) === span) spans.delete(lock);
|
|
8042
|
+
if (signal.aborted || ended) return false;
|
|
8043
|
+
}
|
|
8044
|
+
};
|
|
8045
|
+
const released = (id, lock, end) => {
|
|
8046
|
+
onEvent({
|
|
8047
|
+
type: "lock.released",
|
|
8048
|
+
id,
|
|
8049
|
+
lock,
|
|
8050
|
+
end
|
|
8051
|
+
});
|
|
7972
8052
|
};
|
|
7973
8053
|
return {
|
|
7974
|
-
|
|
7975
|
-
|
|
8054
|
+
enter: async (activation, signal) => {
|
|
8055
|
+
const { id, item } = activation;
|
|
8056
|
+
const lock = nameOf(declaredOf(activation), item);
|
|
8057
|
+
if (lock === void 0 || ledgers === void 0) return true;
|
|
8058
|
+
if (item === void 0 || spans.has(lock)) {
|
|
8059
|
+
if (!await join(id, lock, ledgers, signal)) return false;
|
|
8060
|
+
entered.set(id, { lock });
|
|
8061
|
+
} else {
|
|
8062
|
+
const release = await take(id, lock, ledgers, signal);
|
|
8063
|
+
if (release === null) return false;
|
|
8064
|
+
entered.set(id, {
|
|
8065
|
+
lock,
|
|
8066
|
+
release
|
|
8067
|
+
});
|
|
8068
|
+
}
|
|
8069
|
+
onEvent({
|
|
8070
|
+
type: "lock.taken",
|
|
8071
|
+
id,
|
|
8072
|
+
lock
|
|
8073
|
+
});
|
|
8074
|
+
return true;
|
|
8075
|
+
},
|
|
8076
|
+
leave: (activation) => {
|
|
8077
|
+
const { id } = activation;
|
|
8078
|
+
const held = entered.get(id);
|
|
8079
|
+
if (held === void 0) return;
|
|
8080
|
+
entered.delete(id);
|
|
8081
|
+
const { lock, release } = held;
|
|
8082
|
+
if (release !== void 0) {
|
|
8083
|
+
release();
|
|
8084
|
+
released(id, lock, "span");
|
|
8085
|
+
return;
|
|
8086
|
+
}
|
|
8087
|
+
const span = spans.get(lock);
|
|
8088
|
+
if (span === void 0) return;
|
|
8089
|
+
span.users.delete(id);
|
|
8090
|
+
if (span.users.size > 0 || laterNamed(declaredOf(activation), lock)) return;
|
|
8091
|
+
spans.delete(lock);
|
|
8092
|
+
span.release?.();
|
|
8093
|
+
released(id, lock, "span");
|
|
8094
|
+
},
|
|
8095
|
+
end: () => {
|
|
8096
|
+
ended = true;
|
|
8097
|
+
for (const [id, { lock, release }] of entered) {
|
|
8098
|
+
if (release === void 0) continue;
|
|
8099
|
+
release();
|
|
8100
|
+
released(id, lock, "run");
|
|
8101
|
+
}
|
|
8102
|
+
entered.clear();
|
|
8103
|
+
for (const [lock, span] of spans) {
|
|
8104
|
+
if (span.release === void 0) continue;
|
|
8105
|
+
span.release();
|
|
8106
|
+
released(span.last, lock, "run");
|
|
8107
|
+
}
|
|
8108
|
+
spans.clear();
|
|
8109
|
+
}
|
|
7976
8110
|
};
|
|
7977
8111
|
};
|
|
7978
|
-
// ../core/src/
|
|
7979
|
-
/**
|
|
7980
|
-
* @file A node's result as it ended (specs/runtime/session-ends.md): one place
|
|
7981
|
-
* that sets `end`, makes a failure's `error` its message and marks a
|
|
7982
|
-
* transient kind, and that the engine's modules build ends through.
|
|
7983
|
-
*/
|
|
8112
|
+
// ../core/src/project/calls.ts
|
|
7984
8113
|
/**
|
|
7985
|
-
*
|
|
7986
|
-
*
|
|
8114
|
+
* @file `ork validate`'s check of `workflow.call` nodes against what they call
|
|
8115
|
+
* (RFC-004): the project, the workflow, the node and the required inputs. A
|
|
8116
|
+
* project not registered here is only a warning, since it may be on another
|
|
8117
|
+
* machine; a run refuses it. A name with a template is left to the run.
|
|
7987
8118
|
*/
|
|
7988
|
-
const
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
}
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
kind: "timed_out",
|
|
7997
|
-
timeout
|
|
7998
|
-
};
|
|
7999
|
-
const isTimedOut = (reason) => typeof reason === "object" && reason !== null && "kind" in reason && reason.kind === "timed_out";
|
|
8000
|
-
/**
|
|
8001
|
-
* The end of a node its run's signal stopped: `timed_out` when the run's
|
|
8002
|
-
* timeout aborted it, `interrupted` otherwise.
|
|
8003
|
-
*/
|
|
8004
|
-
const stoppedEnd = (signal, detail) => {
|
|
8005
|
-
const reason = signal.reason;
|
|
8006
|
-
if (!isTimedOut(reason)) return endFor("interrupted", {}, detail);
|
|
8007
|
-
const facts = reason.timeout === void 0 ? {} : { timeout: reason.timeout };
|
|
8008
|
-
return endFor("timed_out", facts, detail);
|
|
8119
|
+
const hasTemplate = (text) => text.includes("{{");
|
|
8120
|
+
/** A workflow's file: a folder of that name is no workflow. */
|
|
8121
|
+
const fileState = (file) => {
|
|
8122
|
+
try {
|
|
8123
|
+
return statSync(file, { throwIfNoEntry: false })?.isFile() === true ? "file" : "none";
|
|
8124
|
+
} catch {
|
|
8125
|
+
return "unreadable";
|
|
8126
|
+
}
|
|
8009
8127
|
};
|
|
8010
|
-
/** A
|
|
8011
|
-
const
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8128
|
+
/** A workflow from its file, or `null` when it cannot be read or is not valid. */
|
|
8129
|
+
const readWorkflow = (file) => {
|
|
8130
|
+
try {
|
|
8131
|
+
const parsed = parseWorkflow(readFileSync(file, "utf8"));
|
|
8132
|
+
return parsed.ok ? parsed.workflow : null;
|
|
8133
|
+
} catch {
|
|
8134
|
+
return null;
|
|
8016
8135
|
}
|
|
8017
|
-
}
|
|
8136
|
+
};
|
|
8018
8137
|
/**
|
|
8019
|
-
* The
|
|
8020
|
-
*
|
|
8021
|
-
*
|
|
8138
|
+
* The workflows a workflow calls by a literal name, and those they call. One
|
|
8139
|
+
* that cannot be read is left out: `ork validate` and the call report it. A
|
|
8140
|
+
* call of one `node:` runs only that session, so its tool nodes do not count
|
|
8141
|
+
* unless `everyCall`, and nor does a call among `finished`. `unreadable` hears
|
|
8142
|
+
* of one that is there but could not be read.
|
|
8022
8143
|
*/
|
|
8023
|
-
const
|
|
8024
|
-
const
|
|
8025
|
-
|
|
8026
|
-
const
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8144
|
+
const calledWorkflows = (workflow, own, registered, options = {}) => {
|
|
8145
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8146
|
+
const found = [];
|
|
8147
|
+
const walk = (from, place) => {
|
|
8148
|
+
for (const [id, node] of Object.entries(from.nodes)) {
|
|
8149
|
+
if (node.type !== "workflow.call") continue;
|
|
8150
|
+
if (hasTemplate(node.workflow) || hasTemplate(node.project ?? "")) continue;
|
|
8151
|
+
if (options.everyCall !== true && node.node !== void 0) continue;
|
|
8152
|
+
if (from === workflow && options.finished?.has(id) === true) continue;
|
|
8153
|
+
const target = node.project === void 0 ? place : registered.find((other) => other.id === node.project);
|
|
8154
|
+
if (target === void 0) continue;
|
|
8155
|
+
const file = path.join(placeFiles(target).workflows, `${path.basename(node.workflow)}.ork.yaml`);
|
|
8156
|
+
if (seen.has(file)) continue;
|
|
8157
|
+
seen.add(file);
|
|
8158
|
+
const state = fileState(file);
|
|
8159
|
+
if (state === "none") continue;
|
|
8160
|
+
const called = state === "file" ? readWorkflow(file) : null;
|
|
8161
|
+
if (called === null) {
|
|
8162
|
+
options.unreadable?.(node.workflow);
|
|
8163
|
+
continue;
|
|
8164
|
+
}
|
|
8165
|
+
found.push(called);
|
|
8166
|
+
walk(called, target);
|
|
8035
8167
|
}
|
|
8036
8168
|
};
|
|
8169
|
+
walk(workflow, own);
|
|
8170
|
+
return found;
|
|
8037
8171
|
};
|
|
8038
|
-
|
|
8172
|
+
const checkCallTargets = (workflow, own, registered) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
8173
|
+
if (node.type !== "workflow.call") return [];
|
|
8174
|
+
const at = ["nodes", id];
|
|
8175
|
+
if (hasTemplate(node.workflow) || hasTemplate(node.project ?? "")) return [];
|
|
8176
|
+
if (/[/\\]/u.test(node.workflow) || node.workflow === "..") return [error("call.workflow", `call \`${id}\`: \`${node.workflow}\` is not a workflow's name`, [...at, "workflow"])];
|
|
8177
|
+
const target = node.project === void 0 ? own : registered.find((place) => place.id === node.project);
|
|
8178
|
+
if (target === void 0) return [warning("call.project", `call \`${id}\`: project \`${String(node.project)}\` is not registered here, so a run will refuse it; register it with \`ork register\``, [...at, "project"])];
|
|
8179
|
+
const file = path.join(placeFiles(target).workflows, `${node.workflow}.ork.yaml`);
|
|
8180
|
+
const state = fileState(file);
|
|
8181
|
+
if (state === "none") return [error("call.workflow", `call \`${id}\`: no workflow \`${node.workflow}\` in ${target.root}`, [...at, "workflow"])];
|
|
8182
|
+
const called = state === "file" ? readWorkflow(file) : null;
|
|
8183
|
+
if (called === null) return [error("call.workflow", `call \`${id}\`: the workflow \`${node.workflow}\` cannot be read or is not valid; run \`ork validate\` on it`, [...at, "workflow"])];
|
|
8184
|
+
const found = [];
|
|
8185
|
+
if (node.node !== void 0) {
|
|
8186
|
+
const type = called.nodes[node.node]?.type;
|
|
8187
|
+
if (type !== "agent.leader" && type !== "agent.worker") found.push(error("call.node", `call \`${id}\`: \`${node.node}\` is not a leader or worker of \`${node.workflow}\``, [...at, "node"]));
|
|
8188
|
+
}
|
|
8189
|
+
for (const [name, input] of Object.entries(called.inputs ?? {})) if (input.required === true && input.default === void 0 && node.inputs?.[name] === void 0) found.push(error("call.input", `call \`${id}\`: \`${node.workflow}\` requires the input \`${name}\``, [...at, "inputs"]));
|
|
8190
|
+
return found;
|
|
8191
|
+
});
|
|
8192
|
+
// ../core/src/run/routing.ts
|
|
8039
8193
|
/**
|
|
8040
|
-
* @file
|
|
8041
|
-
*
|
|
8042
|
-
*
|
|
8043
|
-
* (RFC-013) is held to being a list, and each item to its shape. It reads the
|
|
8044
|
-
* format's own shape; a field written out as JSON Schema stays the driver's
|
|
8045
|
-
* to enforce. A list a resume hands back keeps the file's real values when it
|
|
8046
|
-
* only differs by masking.
|
|
8194
|
+
* @file Where a finished node's output goes: which outgoing edges are taken,
|
|
8195
|
+
* and what each arrival does at its target (activate it, skip it, or wait,
|
|
8196
|
+
* for a join). Pure: the scheduler owns time, sessions and loop counters.
|
|
8047
8197
|
*/
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
boolean: (value) => typeof value === "boolean"
|
|
8059
|
-
};
|
|
8060
|
-
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8061
|
-
const listed = (values) => {
|
|
8062
|
-
const names = values.map((value) => markValue(String(value)));
|
|
8063
|
-
const last = names.pop();
|
|
8064
|
-
return names.length === 0 ? String(last) : `${names.join(", ")} or ${String(last)}`;
|
|
8065
|
-
};
|
|
8066
|
-
const shown = (value) => {
|
|
8067
|
-
const text = typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
|
|
8068
|
-
return markValue(text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
|
8069
|
-
};
|
|
8070
|
-
/** What's wrong with one field, if it's a shape this can read. */
|
|
8071
|
-
const fieldProblem = (name, rule, value) => {
|
|
8072
|
-
const shape = listShape(rule);
|
|
8073
|
-
if (shape !== void 0) return listProblem(name, shape, value);
|
|
8074
|
-
const expected = Array.isArray(rule) ? listed(rule) : typeof rule === "string" ? TYPE_NAMES[rule] : void 0;
|
|
8075
|
-
if (expected === void 0) return null;
|
|
8076
|
-
if (value === void 0) return `${name} is missing, expected ${expected}`;
|
|
8077
|
-
return (Array.isArray(rule) ? rule.some((item) => item === value) : FITS[String(rule)]?.(value)) === true ? null : `${name} is ${shown(value)}, expected ${expected}`;
|
|
8198
|
+
/** A `maxRounds` edge. Removing them leaves a DAG (the compiler checks it). */
|
|
8199
|
+
const isLoopEdge = (edge) => edge.maxRounds !== void 0;
|
|
8200
|
+
const keys = /* @__PURE__ */ new WeakMap();
|
|
8201
|
+
const edgeKey = (edge) => {
|
|
8202
|
+
let key = keys.get(edge);
|
|
8203
|
+
if (key === void 0) {
|
|
8204
|
+
key = `${String(edge.source)}:${edge.from}->${edge.to}`;
|
|
8205
|
+
keys.set(edge, key);
|
|
8206
|
+
}
|
|
8207
|
+
return key;
|
|
8078
8208
|
};
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8209
|
+
/**
|
|
8210
|
+
* Decides every outgoing edge of a finished node. An edge with `when:` is taken
|
|
8211
|
+
* when its condition holds for the node's output; one without is taken when the
|
|
8212
|
+
* node succeeded. A `control.router` in `first` mode takes only its first
|
|
8213
|
+
* matching edge.
|
|
8214
|
+
*/
|
|
8215
|
+
const decideEdges = (graph, id, result, scope) => {
|
|
8216
|
+
const compiled = graph.nodes.get(id);
|
|
8217
|
+
if (compiled === void 0) return [];
|
|
8218
|
+
const { node } = compiled;
|
|
8219
|
+
const firstOnly = node.type === "control.router" && (node.mode ?? "first") === "first";
|
|
8220
|
+
let matched = false;
|
|
8221
|
+
return compiled.outgoing.map((edge) => {
|
|
8222
|
+
const taken = (edge.condition === void 0 ? result.ok : truthy(evaluate(edge.condition, {
|
|
8223
|
+
...scope,
|
|
8224
|
+
output: {
|
|
8225
|
+
ok: result.ok,
|
|
8226
|
+
...result.output
|
|
8227
|
+
}
|
|
8228
|
+
}))) && !(firstOnly && matched);
|
|
8229
|
+
if (taken) matched = true;
|
|
8230
|
+
return {
|
|
8231
|
+
edge,
|
|
8232
|
+
taken
|
|
8233
|
+
};
|
|
8234
|
+
});
|
|
8087
8235
|
};
|
|
8088
|
-
|
|
8089
|
-
const
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8236
|
+
const joinOutput = (edges, resolved) => {
|
|
8237
|
+
const ran = edges.filter((e) => resolved.get(edgeKey(e))?.taken === true);
|
|
8238
|
+
const results = {};
|
|
8239
|
+
for (const edge of ran) results[edge.from] = resolved.get(edgeKey(edge))?.output ?? {};
|
|
8240
|
+
return {
|
|
8241
|
+
statuses: ran.map((e) => results[e.from]?.["status"]),
|
|
8242
|
+
results,
|
|
8243
|
+
count: ran.length
|
|
8244
|
+
};
|
|
8094
8245
|
};
|
|
8246
|
+
/** How a reason for a run that was left waiting starts: a bug in Ork. */
|
|
8247
|
+
const STUCK_REASON = "ork engine error:";
|
|
8095
8248
|
/**
|
|
8096
|
-
*
|
|
8097
|
-
*
|
|
8249
|
+
* Why a run that was left waiting fails: a node neither ran nor was skipped
|
|
8250
|
+
* while an edge could still reach it. The scheduler resolves every edge whose
|
|
8251
|
+
* source can't run any more, so only a bug in Ork leaves one.
|
|
8098
8252
|
*/
|
|
8099
|
-
const
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
const
|
|
8103
|
-
|
|
8253
|
+
const stuckReason = (waiting) => `${STUCK_REASON} ${waiting.map(({ id, waitingOn }) => `${id} was still waiting on ${waitingOn.join(", ")}`).join("; ")} with nothing left to run; this is a bug in Ork, not in the workflow`;
|
|
8254
|
+
/** Each node's place along the non-loop edges, sources first. */
|
|
8255
|
+
const orderOf = (graph) => {
|
|
8256
|
+
const order = /* @__PURE__ */ new Map();
|
|
8257
|
+
const left = /* @__PURE__ */ new Map();
|
|
8258
|
+
for (const [id, compiled] of graph.nodes) left.set(id, compiled.incoming.filter((e) => !isLoopEdge(e)).length);
|
|
8259
|
+
const ready = [...left].filter(([, count]) => count === 0).map(([id]) => id);
|
|
8260
|
+
for (let id = ready.shift(); id !== void 0; id = ready.shift()) {
|
|
8261
|
+
order.set(id, order.size);
|
|
8262
|
+
for (const edge of graph.nodes.get(id)?.outgoing ?? []) {
|
|
8263
|
+
if (isLoopEdge(edge)) continue;
|
|
8264
|
+
const count = (left.get(edge.to) ?? 0) - 1;
|
|
8265
|
+
left.set(edge.to, count);
|
|
8266
|
+
if (count === 0) ready.push(edge.to);
|
|
8267
|
+
}
|
|
8268
|
+
}
|
|
8269
|
+
return order;
|
|
8104
8270
|
};
|
|
8105
8271
|
/**
|
|
8106
|
-
*
|
|
8107
|
-
*
|
|
8272
|
+
* Tracks, per target, which incoming non-loop edges have resolved in its
|
|
8273
|
+
* current round: since every one last resolved.
|
|
8274
|
+
*
|
|
8275
|
+
* - A node runs each time an incoming edge is taken, and is skipped once every
|
|
8276
|
+
* incoming non-loop edge has resolved as not taken (dead-path elimination),
|
|
8277
|
+
* so nothing downstream waits on a branch that will never run.
|
|
8278
|
+
* - A `control.join` waits: `all` until every input resolved, `any` for the first
|
|
8279
|
+
* that ran, `n` for `n` that ran. A join none of whose required inputs ran is
|
|
8280
|
+
* skipped.
|
|
8281
|
+
* - An edge whose source can't run any more counts as not taken (`stranded`): in
|
|
8282
|
+
* a later round, a branch the loop doesn't go back to never runs again.
|
|
8108
8283
|
*/
|
|
8109
|
-
const
|
|
8110
|
-
|
|
8111
|
-
/**
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
/** A call spends through its run, so it needs budget as a session does. */
|
|
8121
|
-
const spendsBudget = (node) => needsSlot(node) || node.type === "workflow.call";
|
|
8122
|
-
const createSlots = (limit, onFree) => {
|
|
8123
|
-
let used = 0;
|
|
8124
|
-
const waiting = [];
|
|
8125
|
-
return {
|
|
8126
|
-
limit,
|
|
8127
|
-
tryTake: () => {
|
|
8128
|
-
if (used >= limit || waiting.length > 0) return false;
|
|
8129
|
-
used += 1;
|
|
8130
|
-
return true;
|
|
8131
|
-
},
|
|
8132
|
-
take: () => {
|
|
8133
|
-
if (used < limit && waiting.length === 0) {
|
|
8134
|
-
used += 1;
|
|
8135
|
-
return Promise.resolve();
|
|
8136
|
-
}
|
|
8137
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
8138
|
-
waiting.push(resolve);
|
|
8139
|
-
return promise;
|
|
8140
|
-
},
|
|
8141
|
-
release: () => {
|
|
8142
|
-
const next = waiting.shift();
|
|
8143
|
-
if (next === void 0) {
|
|
8144
|
-
used -= 1;
|
|
8145
|
-
onFree();
|
|
8146
|
-
} else next();
|
|
8284
|
+
const createInbox = (graph) => {
|
|
8285
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
8286
|
+
/** Targets that ran, or joins that fired, in their current round. */
|
|
8287
|
+
const fired = /* @__PURE__ */ new Set();
|
|
8288
|
+
let order;
|
|
8289
|
+
const forwards = /* @__PURE__ */ new Map();
|
|
8290
|
+
const forwardIn = (id) => {
|
|
8291
|
+
let edges = forwards.get(id);
|
|
8292
|
+
if (edges === void 0) {
|
|
8293
|
+
edges = (graph.nodes.get(id)?.incoming ?? []).filter((e) => !isLoopEdge(e));
|
|
8294
|
+
forwards.set(id, edges);
|
|
8147
8295
|
}
|
|
8296
|
+
return edges;
|
|
8148
8297
|
};
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
const forEachOf = (node) => node !== void 0 && "forEach" in node ? node.forEach : void 0;
|
|
8153
|
-
/** The list a node runs over, or why there isn't one. */
|
|
8154
|
-
const listOf = (forEach, scope) => {
|
|
8155
|
-
if ("items" in forEach) return {
|
|
8156
|
-
ok: true,
|
|
8157
|
-
list: forEach.items
|
|
8298
|
+
const reset = (id) => {
|
|
8299
|
+
resolved.delete(id);
|
|
8300
|
+
fired.delete(id);
|
|
8158
8301
|
};
|
|
8159
|
-
const
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8302
|
+
const arriveAtJoin = (id, seen) => {
|
|
8303
|
+
const node = graph.nodes.get(id)?.node;
|
|
8304
|
+
if (node?.type !== "control.join") return { kind: "wait" };
|
|
8305
|
+
const inputs = forwardIn(id);
|
|
8306
|
+
const ran = inputs.filter((e) => seen.get(edgeKey(e))?.taken === true);
|
|
8307
|
+
const complete = inputs.every((e) => seen.has(edgeKey(e)));
|
|
8308
|
+
const mode = node.mode ?? "all";
|
|
8309
|
+
const needed = mode === "any" ? 1 : node.n ?? inputs.length;
|
|
8310
|
+
if (mode === "all") {
|
|
8311
|
+
if (!complete) return { kind: "wait" };
|
|
8312
|
+
reset(id);
|
|
8313
|
+
return ran.length > 0 ? {
|
|
8314
|
+
kind: "activate",
|
|
8315
|
+
input: joinOutput(inputs, seen)
|
|
8316
|
+
} : { kind: "skip" };
|
|
8317
|
+
}
|
|
8318
|
+
if (!fired.has(id) && ran.length >= needed) {
|
|
8319
|
+
const input = joinOutput(inputs, seen);
|
|
8320
|
+
if (complete) reset(id);
|
|
8321
|
+
else fired.add(id);
|
|
8322
|
+
return {
|
|
8323
|
+
kind: "activate",
|
|
8324
|
+
input
|
|
8325
|
+
};
|
|
8326
|
+
}
|
|
8327
|
+
if (!complete) return { kind: "wait" };
|
|
8328
|
+
const wasFired = fired.has(id);
|
|
8329
|
+
reset(id);
|
|
8330
|
+
return wasFired ? { kind: "wait" } : { kind: "skip" };
|
|
8165
8331
|
};
|
|
8166
|
-
|
|
8332
|
+
/** Records that `edge` resolved, and says what happens at its target. */
|
|
8333
|
+
const arrive = (edge, taken, output) => {
|
|
8334
|
+
const id = edge.to;
|
|
8335
|
+
const seen = resolved.get(id) ?? /* @__PURE__ */ new Map();
|
|
8336
|
+
resolved.set(id, seen);
|
|
8337
|
+
seen.set(edgeKey(edge), {
|
|
8338
|
+
taken,
|
|
8339
|
+
output
|
|
8340
|
+
});
|
|
8341
|
+
if (graph.nodes.get(id)?.node.type === "control.join") return arriveAtJoin(id, seen);
|
|
8342
|
+
const complete = forwardIn(id).every((e) => seen.has(edgeKey(e)));
|
|
8343
|
+
const ran = taken || fired.has(id);
|
|
8344
|
+
if (complete) reset(id);
|
|
8345
|
+
else if (taken) fired.add(id);
|
|
8346
|
+
if (taken) return {
|
|
8347
|
+
kind: "activate",
|
|
8348
|
+
input: output
|
|
8349
|
+
};
|
|
8350
|
+
return complete && !ran ? { kind: "skip" } : { kind: "wait" };
|
|
8351
|
+
};
|
|
8352
|
+
/** The edges a target still waits on. */
|
|
8353
|
+
const missingOf = (id, seen) => forwardIn(id).filter((e) => !seen.has(edgeKey(e)));
|
|
8354
|
+
/**
|
|
8355
|
+
* The first target, in graph order, whose every edge still to resolve comes
|
|
8356
|
+
* from a node that can't run any more: neither `active` (queued or running)
|
|
8357
|
+
* nor after one along the non-loop edges. A loop's edge starts a new round,
|
|
8358
|
+
* so it doesn't count. Its edges count as not taken; the caller delivers
|
|
8359
|
+
* them, and asks again, as that can start something.
|
|
8360
|
+
*/
|
|
8361
|
+
const stranded = (active) => {
|
|
8362
|
+
const candidates = [...resolved].map(([id, seen]) => ({
|
|
8363
|
+
id,
|
|
8364
|
+
missing: missingOf(id, seen)
|
|
8365
|
+
})).filter(({ missing }) => !missing.some((e) => active(e.from)));
|
|
8366
|
+
if (candidates.length === 0) return [];
|
|
8367
|
+
/** Nodes known to be unable to run, in this call. */
|
|
8368
|
+
const dead = /* @__PURE__ */ new Set();
|
|
8369
|
+
const canRun = (from) => {
|
|
8370
|
+
const visited = /* @__PURE__ */ new Set([from]);
|
|
8371
|
+
const stack = [from];
|
|
8372
|
+
for (let id = stack.pop(); id !== void 0; id = stack.pop()) {
|
|
8373
|
+
if (active(id)) return true;
|
|
8374
|
+
for (const edge of forwardIn(id)) {
|
|
8375
|
+
if (visited.has(edge.from) || dead.has(edge.from)) continue;
|
|
8376
|
+
visited.add(edge.from);
|
|
8377
|
+
stack.push(edge.from);
|
|
8378
|
+
}
|
|
8379
|
+
}
|
|
8380
|
+
for (const id of visited) dead.add(id);
|
|
8381
|
+
return false;
|
|
8382
|
+
};
|
|
8383
|
+
order ??= orderOf(graph);
|
|
8384
|
+
const known = order;
|
|
8385
|
+
const place = (id) => known.get(id) ?? Infinity;
|
|
8386
|
+
return candidates.filter(({ missing }) => !missing.some((e) => canRun(e.from))).toSorted((a, b) => place(a.id) - place(b.id))[0]?.missing ?? [];
|
|
8387
|
+
};
|
|
8388
|
+
/** Targets an edge reached that still wait on others. */
|
|
8389
|
+
const waiting = () => [...resolved].map(([id, seen]) => ({
|
|
8390
|
+
id,
|
|
8391
|
+
waitingOn: [...new Set(missingOf(id, seen).map((e) => e.from))]
|
|
8392
|
+
}));
|
|
8167
8393
|
return {
|
|
8168
|
-
|
|
8169
|
-
|
|
8394
|
+
arrive,
|
|
8395
|
+
stranded,
|
|
8396
|
+
waiting
|
|
8170
8397
|
};
|
|
8171
8398
|
};
|
|
8172
|
-
|
|
8173
|
-
const
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
};
|
|
8178
|
-
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8179
|
-
/**
|
|
8180
|
-
* What `results` keeps of an item: its declared fields (a program's are under
|
|
8181
|
-
* `json`), its summary, Ork's own branch and worktree, and how a program
|
|
8182
|
-
* failed. The full output stays in the item's own `node.finished`.
|
|
8183
|
-
*/
|
|
8184
|
-
const entryOf = (declared, { ok, output, error }) => {
|
|
8185
|
-
const json = isRecord$1(output["json"]) ? output["json"] : {};
|
|
8186
|
-
const fields = Object.keys(declared ?? {}).map((field) => [field, Object.hasOwn(json, field) ? json[field] : output[field]]);
|
|
8187
|
-
const own = [
|
|
8188
|
-
"summary",
|
|
8189
|
-
"branch",
|
|
8190
|
-
"worktree",
|
|
8191
|
-
"merge"
|
|
8192
|
-
].map((field) => [field, output[field]]);
|
|
8193
|
-
const failed = !ok && typeof output["exitCode"] === "number" ? [["exitCode", output["exitCode"]], ["error", error]] : [];
|
|
8194
|
-
return Object.fromEntries([
|
|
8195
|
-
...fields,
|
|
8196
|
-
...own,
|
|
8197
|
-
...failed
|
|
8198
|
-
].filter(([, value]) => value !== void 0));
|
|
8199
|
-
};
|
|
8200
|
-
/** Whether an item passed but its work could not reach its owner. */
|
|
8201
|
-
const mergeFailed = ({ output }) => isRecord$1(output["merge"]) && output["merge"]["ok"] === false;
|
|
8202
|
-
const statusOf$2 = (result) => {
|
|
8203
|
-
if (mergeFailed(result)) return "fail";
|
|
8204
|
-
const { ok, output } = result;
|
|
8205
|
-
const status = output["status"];
|
|
8206
|
-
if (typeof status === "string") return status;
|
|
8207
|
-
return ok ? "pass" : "fail";
|
|
8399
|
+
// ../core/src/run/escalation.ts
|
|
8400
|
+
const LOOP_CHOICES = {
|
|
8401
|
+
stop: "stop",
|
|
8402
|
+
again: "again",
|
|
8403
|
+
onward: "onward"
|
|
8208
8404
|
};
|
|
8209
|
-
/**
|
|
8210
|
-
const
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
if (skipped > 0) parts.push(`${String(skipped)} not started`);
|
|
8215
|
-
return parts.join("; ");
|
|
8405
|
+
/** What a list over its `maxItems` can be answered with (RFC-013). */
|
|
8406
|
+
const EACH_CHOICES = {
|
|
8407
|
+
stop: "stop",
|
|
8408
|
+
first: "first",
|
|
8409
|
+
all: "all"
|
|
8216
8410
|
};
|
|
8411
|
+
/** The question about a list over its cap; the recommended choice stops. */
|
|
8412
|
+
const eachQuestion = (node, count, maxItems, round = 1) => ({
|
|
8413
|
+
question: `The list of ${node}${round > 1 ? ` (round ${String(round)})` : ""} has ${String(count)} items, more than its maxItems of ${String(maxItems)}. What now?`,
|
|
8414
|
+
options: [
|
|
8415
|
+
{
|
|
8416
|
+
id: EACH_CHOICES.stop,
|
|
8417
|
+
description: "Stop the run"
|
|
8418
|
+
},
|
|
8419
|
+
{
|
|
8420
|
+
id: EACH_CHOICES.first,
|
|
8421
|
+
description: `Run the first ${String(maxItems)}`
|
|
8422
|
+
},
|
|
8423
|
+
{
|
|
8424
|
+
id: EACH_CHOICES.all,
|
|
8425
|
+
description: `Run all ${String(count)}`
|
|
8426
|
+
}
|
|
8427
|
+
],
|
|
8428
|
+
recommended: EACH_CHOICES.stop,
|
|
8429
|
+
context: `${node} runs at most ${String(maxItems)} items unless someone says otherwise.`
|
|
8430
|
+
});
|
|
8431
|
+
/** The question about a loop out of rounds; the recommended choice stops. */
|
|
8432
|
+
const loopQuestion = (edge) => ({
|
|
8433
|
+
question: `The loop ${edge.from} -> ${edge.to} used all ${String(edge.maxRounds ?? 0)} of its rounds. What now?`,
|
|
8434
|
+
options: [
|
|
8435
|
+
{
|
|
8436
|
+
id: LOOP_CHOICES.stop,
|
|
8437
|
+
description: "Stop the run"
|
|
8438
|
+
},
|
|
8439
|
+
{
|
|
8440
|
+
id: LOOP_CHOICES.again,
|
|
8441
|
+
description: "Allow one more round"
|
|
8442
|
+
},
|
|
8443
|
+
{
|
|
8444
|
+
id: LOOP_CHOICES.onward,
|
|
8445
|
+
description: "Go on along the other edges"
|
|
8446
|
+
}
|
|
8447
|
+
],
|
|
8448
|
+
recommended: LOOP_CHOICES.stop,
|
|
8449
|
+
context: `${edge.from} still wants another round${edge.when === void 0 ? "" : ` (${edge.when.trim()})`}.`
|
|
8450
|
+
});
|
|
8217
8451
|
/**
|
|
8218
|
-
*
|
|
8219
|
-
*
|
|
8452
|
+
* Asks the owner of `edge.from` while its session is live (the node runs in the
|
|
8453
|
+
* flow, so its owner is not waiting on it), and the human otherwise or when the
|
|
8454
|
+
* owner's session ends first.
|
|
8220
8455
|
*/
|
|
8221
|
-
const
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
slots.release();
|
|
8228
|
-
}
|
|
8456
|
+
const askAboutLoop = async (human, edge, bus) => {
|
|
8457
|
+
const question = loopQuestion(edge);
|
|
8458
|
+
return await bus?.askOwner(edge.from, question) ?? await bus?.askOwner(edge.to, question) ?? human.ask({
|
|
8459
|
+
nodeId: edge.from,
|
|
8460
|
+
...question
|
|
8461
|
+
});
|
|
8229
8462
|
};
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
const
|
|
8236
|
-
const
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
const
|
|
8254
|
-
if (
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
|
|
8275
|
-
|
|
8276
|
-
|
|
8277
|
-
}
|
|
8278
|
-
const
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
let firstEnd;
|
|
8287
|
-
const mayStart = () => !signal.aborted && !deps.isStopped() && (forEach.onFailure !== "stop" || failures.length === 0);
|
|
8288
|
-
const runItem = async (index) => {
|
|
8289
|
-
const itemId = `${id}.${String(index)}`;
|
|
8290
|
-
const hold = holds.forNode(itemId, id);
|
|
8291
|
-
await awaitTurn(hold, session ? slots : null, signal);
|
|
8292
|
-
try {
|
|
8293
|
-
if (!mayStart() || spends && deps.budgetSpent()) return;
|
|
8294
|
-
const result = await perform({
|
|
8295
|
-
id: itemId,
|
|
8296
|
-
input,
|
|
8297
|
-
template: id,
|
|
8298
|
-
item: {
|
|
8299
|
-
of: id,
|
|
8300
|
-
index,
|
|
8301
|
-
value: list[index]
|
|
8302
|
-
},
|
|
8303
|
-
round
|
|
8304
|
-
});
|
|
8305
|
-
results[index] = entryOf(declared, result);
|
|
8306
|
-
statuses[index] = statusOf$2(result);
|
|
8307
|
-
if (!result.ok) {
|
|
8308
|
-
firstEnd ??= endOf(result);
|
|
8309
|
-
failures.push(`${itemId}: ${result.error ?? "failed"}`);
|
|
8310
|
-
} else if (mergeFailed(result)) failures.push(`${itemId}: its work could not be merged`);
|
|
8311
|
-
} finally {
|
|
8312
|
-
if (session) slots.release();
|
|
8313
|
-
}
|
|
8314
|
-
};
|
|
8315
|
-
let next = 0;
|
|
8316
|
-
const worker = async () => {
|
|
8317
|
-
while (next < list.length && mayStart()) {
|
|
8318
|
-
const index = next;
|
|
8319
|
-
next += 1;
|
|
8320
|
-
await runItem(index);
|
|
8321
|
-
}
|
|
8322
|
-
};
|
|
8323
|
-
const lanes = Math.min(forEach.maxParallel ?? slots.limit, list.length);
|
|
8324
|
-
await Promise.all(Array.from({ length: lanes }, () => worker()));
|
|
8325
|
-
const skipped = statuses.filter((status) => status === "skipped").length;
|
|
8326
|
-
const ok = failures.length === 0 && skipped === 0;
|
|
8327
|
-
const gathered = {
|
|
8328
|
-
ok,
|
|
8329
|
-
output: {
|
|
8330
|
-
items: list,
|
|
8331
|
-
results,
|
|
8332
|
-
statuses,
|
|
8333
|
-
count: list.length
|
|
8334
|
-
},
|
|
8335
|
-
...ok ? {} : { error: whyNot(list.length, failures, skipped) },
|
|
8336
|
-
...ok || firstEnd === void 0 ? {} : { end: firstEnd },
|
|
8337
|
-
...!ok && deps.isStopped() ? { limited: deps.reasons[0] ?? "the run stopped" } : {}
|
|
8338
|
-
};
|
|
8339
|
-
return {
|
|
8340
|
-
...stops.ended(id, gathered),
|
|
8341
|
-
output: gathered.output
|
|
8342
|
-
};
|
|
8463
|
+
/** Counts each loop edge's rounds; `take` is false once one is out of them. */
|
|
8464
|
+
const createLoops = (options) => {
|
|
8465
|
+
const taken = /* @__PURE__ */ new Map();
|
|
8466
|
+
const extra = /* @__PURE__ */ new Map();
|
|
8467
|
+
const take = async (edge, output) => {
|
|
8468
|
+
const key = edgeKey(edge);
|
|
8469
|
+
const count = taken.get(key) ?? 0;
|
|
8470
|
+
if (count < (edge.maxRounds ?? 0) + (extra.get(key) ?? 0)) {
|
|
8471
|
+
taken.set(key, count + 1);
|
|
8472
|
+
options.activate(edge.to, output);
|
|
8473
|
+
options.onEvent({
|
|
8474
|
+
type: "edge.taken",
|
|
8475
|
+
from: edge.from,
|
|
8476
|
+
to: edge.to
|
|
8477
|
+
});
|
|
8478
|
+
return true;
|
|
8479
|
+
}
|
|
8480
|
+
options.onEvent({
|
|
8481
|
+
type: "loop.exhausted",
|
|
8482
|
+
from: edge.from,
|
|
8483
|
+
to: edge.to,
|
|
8484
|
+
maxRounds: edge.maxRounds ?? 0
|
|
8485
|
+
});
|
|
8486
|
+
const choice = await askAboutLoop(options.human, edge, options.bus);
|
|
8487
|
+
if (choice === LOOP_CHOICES.again) {
|
|
8488
|
+
extra.set(key, (extra.get(key) ?? 0) + 1);
|
|
8489
|
+
return take(edge, output);
|
|
8490
|
+
}
|
|
8491
|
+
if (choice !== LOOP_CHOICES.onward) options.fail(`the loop ${edge.from} -> ${edge.to} ran out of rounds`);
|
|
8492
|
+
return false;
|
|
8493
|
+
};
|
|
8494
|
+
/**
|
|
8495
|
+
* What a list over its cap runs: asked of a live owner, then the human. All
|
|
8496
|
+
* of it, the first `maxItems`, or `null` to stop. A stop (`signal`) ends the
|
|
8497
|
+
* wait and withdraws the question; the list comes back whole, and nothing of
|
|
8498
|
+
* it starts because the stop also bars its items.
|
|
8499
|
+
*/
|
|
8500
|
+
const overList = async (node, list, maxItems, round, signal) => {
|
|
8501
|
+
const question = eachQuestion(node, list.length, maxItems, round);
|
|
8502
|
+
const asking = async () => await options.bus?.askOwner(node, question) ?? (signal.aborted ? null : options.human.ask({
|
|
8503
|
+
nodeId: node,
|
|
8504
|
+
...question,
|
|
8505
|
+
signal
|
|
8506
|
+
}));
|
|
8507
|
+
const stopped = Promise.withResolvers();
|
|
8508
|
+
signal.addEventListener("abort", () => {
|
|
8509
|
+
stopped.resolve(null);
|
|
8510
|
+
}, { once: true });
|
|
8511
|
+
const choice = await Promise.race([asking(), stopped.promise]);
|
|
8512
|
+
if (signal.aborted) return list;
|
|
8513
|
+
if (choice === EACH_CHOICES.first) return list.slice(0, maxItems);
|
|
8514
|
+
return choice === EACH_CHOICES.all ? list : null;
|
|
8515
|
+
};
|
|
8516
|
+
return {
|
|
8517
|
+
take,
|
|
8518
|
+
overList
|
|
8343
8519
|
};
|
|
8344
8520
|
};
|
|
8345
8521
|
// ../core/src/run/delegation.ts
|
|
@@ -8543,6 +8719,10 @@ const nodeContext = (deps, activation, cwd, round, clock) => {
|
|
|
8543
8719
|
reserveBudget: (usd) => {
|
|
8544
8720
|
deps.spend.reserve(id, usd, deps.nodeOf(activation)?.type !== "agent.leader");
|
|
8545
8721
|
},
|
|
8722
|
+
tokensLeftNow: deps.spend.tokensLeft,
|
|
8723
|
+
reserveTokens: (tokens) => {
|
|
8724
|
+
deps.spend.reserve(id, tokens, deps.nodeOf(activation)?.type !== "agent.leader", "tokens");
|
|
8725
|
+
},
|
|
8546
8726
|
budgetFreed: (signal) => deps.spend.freed(id, signal),
|
|
8547
8727
|
quota: deps.spend.quota,
|
|
8548
8728
|
ownSpend: (sessionId, total) => deps.spend.ownOf(id, sessionId, total),
|
|
@@ -8552,9 +8732,10 @@ const nodeContext = (deps, activation, cwd, round, clock) => {
|
|
|
8552
8732
|
...options.calls === void 0 ? {} : { calls: options.calls },
|
|
8553
8733
|
emit: (event) => {
|
|
8554
8734
|
deps.ports.owners.seen(id, event, cwd);
|
|
8735
|
+
if (event.type === "session.started") deps.spend.started(event.driver);
|
|
8555
8736
|
if (event.type === "session.finished" && event.fork !== true) {
|
|
8556
8737
|
const { sessionId, usage } = event.outcome;
|
|
8557
|
-
deps.spend.
|
|
8738
|
+
deps.spend.reported(id, sessionId, usage);
|
|
8558
8739
|
}
|
|
8559
8740
|
options.onEvent({
|
|
8560
8741
|
type: "node.event",
|
|
@@ -8663,6 +8844,22 @@ const scriptContactsOf = (id, nodes, channels) => {
|
|
|
8663
8844
|
/** Whether a program in a tool node may write to the session `id`. */
|
|
8664
8845
|
const hearsFromScripts = (id, nodes, channels) => nodes.some((node) => isTool(node) && scriptContactsOf(node.id, nodes, channels).includes(id));
|
|
8665
8846
|
// ../core/src/usage/spend.ts
|
|
8847
|
+
/**
|
|
8848
|
+
* @file What a session spent, from what it reported (ADR-046). Claude Code
|
|
8849
|
+
* reports a resumed session's cost as its total so far, carried over from its
|
|
8850
|
+
* transcript when that has one, and a crash's result may carry zero. The
|
|
8851
|
+
* ledger and the scheduler both read a session's reports through `ownSpend`;
|
|
8852
|
+
* `earlierSpend` is what a resumed run starts from.
|
|
8853
|
+
*/
|
|
8854
|
+
/**
|
|
8855
|
+
* The tokens a token budget or quota counts (RFC-015): input, output and cache
|
|
8856
|
+
* writes. Cache reads never count. The ledger's `tokensSince` sums the same.
|
|
8857
|
+
*/
|
|
8858
|
+
const countedTokens = (usage) => usage.inputTokens + usage.outputTokens + usage.cacheWriteTokens;
|
|
8859
|
+
/** The drivers that report no dollars: a dollar total can't see their sessions. */
|
|
8860
|
+
const UNPRICED_DRIVERS = AGENT_DRIVERS.filter((driver) => !driver.capabilities.price).map((driver) => driver.id);
|
|
8861
|
+
/** Whether a session on `driver` spends dollars no total can see. */
|
|
8862
|
+
const unpriced = (driver) => driver !== void 0 && UNPRICED_DRIVERS.includes(driver);
|
|
8666
8863
|
/** What a report would add to the session's earlier ones, counting nothing. */
|
|
8667
8864
|
const unreported = (reported, key, total) => {
|
|
8668
8865
|
if (total <= 0) return 0;
|
|
@@ -8692,18 +8889,23 @@ const earlierSpend = (events) => {
|
|
|
8692
8889
|
const open = /* @__PURE__ */ new Map();
|
|
8693
8890
|
const forks = /* @__PURE__ */ new Map();
|
|
8694
8891
|
let total = 0;
|
|
8892
|
+
let tokens = 0;
|
|
8893
|
+
let partial = false;
|
|
8695
8894
|
for (const event of events) if (event.type === "node.started") open.set(event.id, null);
|
|
8696
8895
|
else if (event.type === "node.finished") {
|
|
8697
8896
|
total += event.usage?.costUsd ?? 0;
|
|
8897
|
+
tokens += event.usage === void 0 ? 0 : countedTokens(event.usage);
|
|
8698
8898
|
const last = open.get(event.id);
|
|
8699
8899
|
if (last !== null && last !== void 0) ownSpend(reported, last.key, last.cost);
|
|
8700
8900
|
open.delete(event.id);
|
|
8701
|
-
} else if (event.type === "node.event" && event.event.type === "session.
|
|
8901
|
+
} else if (event.type === "node.event" && event.event.type === "session.started") partial ||= unpriced(event.event.driver);
|
|
8902
|
+
else if (event.type === "node.event" && event.event.type === "session.finished") {
|
|
8702
8903
|
const { sessionId, usage } = event.event.outcome;
|
|
8703
8904
|
const key = `${event.id}/${sessionId}`;
|
|
8704
8905
|
if (event.event.fork === true) {
|
|
8705
8906
|
const own = ownSpend(reported, key, usage.costUsd);
|
|
8706
8907
|
total += own;
|
|
8908
|
+
tokens += countedTokens(usage);
|
|
8707
8909
|
forks.set(event.id, (forks.get(event.id) ?? 0) + own);
|
|
8708
8910
|
} else if (open.has(event.id)) open.set(event.id, {
|
|
8709
8911
|
key,
|
|
@@ -8713,6 +8915,8 @@ const earlierSpend = (events) => {
|
|
|
8713
8915
|
}
|
|
8714
8916
|
return {
|
|
8715
8917
|
total,
|
|
8918
|
+
tokens,
|
|
8919
|
+
partial,
|
|
8716
8920
|
reported,
|
|
8717
8921
|
forks
|
|
8718
8922
|
};
|
|
@@ -8727,20 +8931,27 @@ const createReserves = () => {
|
|
|
8727
8931
|
for (const resolve of woken) resolve();
|
|
8728
8932
|
};
|
|
8729
8933
|
return {
|
|
8730
|
-
/** All that is set aside now
|
|
8731
|
-
total: () => [...held.values()].reduce((sum, reserve) => sum + reserve
|
|
8934
|
+
/** All that is set aside now, in `unit`. */
|
|
8935
|
+
total: (unit = "usd") => [...held.values()].reduce((sum, reserve) => sum + reserve[unit], 0),
|
|
8732
8936
|
/**
|
|
8733
|
-
* `id` sets aside `
|
|
8734
|
-
* for a leader, whose children must never wait on it.
|
|
8937
|
+
* `id` sets aside `amount` of `unit` (nothing, or 0, lets it go).
|
|
8938
|
+
* `waitable` is false for a leader, whose children must never wait on it.
|
|
8735
8939
|
*/
|
|
8736
|
-
reserve: (id,
|
|
8737
|
-
const
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
usd,
|
|
8940
|
+
reserve: (id, amount, waitable, unit = "usd") => {
|
|
8941
|
+
const now = held.get(id) ?? {
|
|
8942
|
+
usd: 0,
|
|
8943
|
+
tokens: 0,
|
|
8741
8944
|
waitable
|
|
8742
|
-
}
|
|
8743
|
-
|
|
8945
|
+
};
|
|
8946
|
+
const before = now[unit];
|
|
8947
|
+
const next = {
|
|
8948
|
+
...now,
|
|
8949
|
+
waitable,
|
|
8950
|
+
[unit]: Math.max(amount ?? 0, 0)
|
|
8951
|
+
};
|
|
8952
|
+
if (next.usd === 0 && next.tokens === 0) held.delete(id);
|
|
8953
|
+
else held.set(id, next);
|
|
8954
|
+
if ((amount ?? 0) < before) wake();
|
|
8744
8955
|
},
|
|
8745
8956
|
/** The node ended: whatever it still held is let go. */
|
|
8746
8957
|
drop: (id) => {
|
|
@@ -8788,40 +8999,77 @@ const withOwnSpend = (id, result, reported) => {
|
|
|
8788
8999
|
} : result.output
|
|
8789
9000
|
};
|
|
8790
9001
|
};
|
|
8791
|
-
|
|
8792
|
-
|
|
9002
|
+
const sum = (values) => [...values].reduce((total, value) => total + value, 0);
|
|
9003
|
+
/** The least of the limits that are set; `undefined` with none. */
|
|
9004
|
+
const least = (...limits) => {
|
|
9005
|
+
const known = limits.filter((limit) => limit !== void 0);
|
|
9006
|
+
return known.length === 0 ? void 0 : Math.min(...known);
|
|
9007
|
+
};
|
|
9008
|
+
/**
|
|
9009
|
+
* A run's spending so far; a resumed run starts from what it had spent.
|
|
9010
|
+
* `budgetTokens` caps its counted tokens as `budgetUsd` caps its dollars.
|
|
9011
|
+
*/
|
|
9012
|
+
const createSpend = (budgetUsd, earlier, limits, budgetTokens) => {
|
|
8793
9013
|
const reported = new Map(earlier?.reported);
|
|
8794
9014
|
let spent = earlier?.total ?? 0;
|
|
9015
|
+
let tokens = earlier?.tokens ?? 0;
|
|
9016
|
+
let partial = earlier?.partial ?? false;
|
|
8795
9017
|
/** What running sessions last reported, not yet in `spent`, by `node/session`. */
|
|
8796
9018
|
const inFlight = /* @__PURE__ */ new Map();
|
|
8797
|
-
|
|
9019
|
+
/** Their counted tokens: each process's own, so a session's add up. */
|
|
9020
|
+
const tokensInFlight = /* @__PURE__ */ new Map();
|
|
9021
|
+
const committed = () => spent + sum(inFlight.values());
|
|
9022
|
+
const committedTokens = () => tokens + sum(tokensInFlight.values());
|
|
8798
9023
|
/** Caps running sessions were given (ADR-077): out of what is offered. */
|
|
8799
9024
|
const reserves = createReserves();
|
|
8800
9025
|
const budgetLeft = () => budgetUsd === void 0 ? void 0 : budgetUsd - committed() - reserves.total();
|
|
9026
|
+
const tokensLeft = () => budgetTokens === void 0 ? void 0 : budgetTokens - committedTokens() - reserves.total("tokens");
|
|
9027
|
+
/**
|
|
9028
|
+
* A running session said what it has spent so far, and the counted tokens of
|
|
9029
|
+
* the process that just ended.
|
|
9030
|
+
*/
|
|
9031
|
+
const report = (id, sessionId, total, counted = 0) => {
|
|
9032
|
+
const key = `${id}/${sessionId}`;
|
|
9033
|
+
inFlight.set(key, unreported(reported, key, total));
|
|
9034
|
+
tokensInFlight.set(key, (tokensInFlight.get(key) ?? 0) + counted);
|
|
9035
|
+
};
|
|
8801
9036
|
return {
|
|
8802
9037
|
total: () => spent,
|
|
9038
|
+
/** The counted tokens of what `total` counts. */
|
|
9039
|
+
tokens: () => tokens,
|
|
9040
|
+
/** A session ran on a driver that reports no dollars: `total` is partial. */
|
|
9041
|
+
partial: () => partial,
|
|
9042
|
+
/** What the run's result says it spent. */
|
|
9043
|
+
sums: () => ({
|
|
9044
|
+
costUsd: spent,
|
|
9045
|
+
tokens,
|
|
9046
|
+
partial
|
|
9047
|
+
}),
|
|
9048
|
+
/** A session ran on `driver`. */
|
|
9049
|
+
started: (driver) => {
|
|
9050
|
+
partial ||= unpriced(driver);
|
|
9051
|
+
},
|
|
8803
9052
|
/** A node sets aside its session's cap; nothing or 0 lets it go. */
|
|
8804
9053
|
reserve: reserves.reserve,
|
|
9054
|
+
/** A session finished with `usage`: `report` with its counted tokens. */
|
|
9055
|
+
reported: (id, sessionId, usage) => {
|
|
9056
|
+
report(id, sessionId, usage.costUsd, countedTokens(usage));
|
|
9057
|
+
},
|
|
8805
9058
|
/** Waits until another node's reserve frees; `false` when none can. */
|
|
8806
9059
|
freed: reserves.freed,
|
|
8807
9060
|
/** What a session has spent in this activation, from its total so far. */
|
|
8808
9061
|
ownOf: (id, sessionId, total) => unreported(reported, `${id}/${sessionId}`, total),
|
|
8809
|
-
|
|
8810
|
-
report: (id, sessionId, total) => {
|
|
8811
|
-
const key = `${id}/${sessionId}`;
|
|
8812
|
-
inFlight.set(key, unreported(reported, key, total));
|
|
8813
|
-
},
|
|
9062
|
+
report,
|
|
8814
9063
|
/** The node ended: `count` has what its sessions last reported. */
|
|
8815
9064
|
settled: (id) => {
|
|
8816
|
-
for (const key of
|
|
9065
|
+
for (const map of [inFlight, tokensInFlight]) for (const key of map.keys()) if (key.startsWith(`${id}/`)) map.delete(key);
|
|
8817
9066
|
reserves.drop(id);
|
|
8818
9067
|
},
|
|
8819
9068
|
/** What a session may spend: the least of the budget and any quota. */
|
|
8820
|
-
left: () =>
|
|
8821
|
-
const known = [budgetLeft(), limits?.().leftUsd].filter((left) => left !== void 0);
|
|
8822
|
-
return known.length === 0 ? void 0 : Math.min(...known);
|
|
8823
|
-
},
|
|
9069
|
+
left: () => least(budgetLeft(), limits?.().leftUsd),
|
|
8824
9070
|
budgetLeft,
|
|
9071
|
+
/** What is left of the run's `budgetTokens`, its reserves out. */
|
|
9072
|
+
tokensLeft,
|
|
8825
9073
|
/** What the limits across runs allow now. */
|
|
8826
9074
|
quota: () => limits?.() ?? {},
|
|
8827
9075
|
/**
|
|
@@ -8834,9 +9082,13 @@ const createSpend = (budgetUsd, earlier, limits) => {
|
|
|
8834
9082
|
status: "quota_exhausted",
|
|
8835
9083
|
reason: blocked
|
|
8836
9084
|
};
|
|
8837
|
-
|
|
9085
|
+
if (budgetUsd !== void 0 && committed() >= budgetUsd) return {
|
|
8838
9086
|
status: "budget_exhausted",
|
|
8839
9087
|
reason: `the run's budget of $${String(budgetUsd)} is spent`
|
|
9088
|
+
};
|
|
9089
|
+
return budgetTokens !== void 0 && committedTokens() >= budgetTokens ? {
|
|
9090
|
+
status: "budget_exhausted",
|
|
9091
|
+
reason: `the run's budget of ${String(budgetTokens)} tokens is spent`
|
|
8840
9092
|
} : null;
|
|
8841
9093
|
},
|
|
8842
9094
|
/**
|
|
@@ -8847,12 +9099,13 @@ const createSpend = (budgetUsd, earlier, limits) => {
|
|
|
8847
9099
|
if (replayed) return result;
|
|
8848
9100
|
const own = withOwnSpend(id, result, reported);
|
|
8849
9101
|
spent += own.usage?.costUsd ?? 0;
|
|
9102
|
+
tokens += own.usage === void 0 ? 0 : countedTokens(own.usage);
|
|
8850
9103
|
return own;
|
|
8851
9104
|
}
|
|
8852
9105
|
};
|
|
8853
9106
|
};
|
|
8854
9107
|
/** A run's spending, from what the scheduler is given. */
|
|
8855
|
-
const spendOf = (budgetUsd, options) => createSpend(budgetUsd, options.replay?.earlier, options.limits);
|
|
9108
|
+
const spendOf = (budgetUsd, options, budgetTokens) => createSpend(budgetUsd, options.replay?.earlier, options.limits, budgetTokens);
|
|
8856
9109
|
// ../core/src/run/gather.ts
|
|
8857
9110
|
const asList = (value) => Array.isArray(value) ? value : [];
|
|
8858
9111
|
/** `before` then `now`, leaving out an entry already there. */
|
|
@@ -9015,6 +9268,8 @@ const runResult = (run) => ({
|
|
|
9015
9268
|
runId: run.runId,
|
|
9016
9269
|
status: run.stopped ?? (run.reasons.length > 0 ? "failed" : "succeeded"),
|
|
9017
9270
|
costUsd: run.costUsd,
|
|
9271
|
+
...run.tokens === void 0 || run.tokens === 0 ? {} : { tokens: run.tokens },
|
|
9272
|
+
...run.partial === true ? { partial: true } : {},
|
|
9018
9273
|
nodes: run.nodes,
|
|
9019
9274
|
reasons: run.reasons,
|
|
9020
9275
|
worktrees: run.worktrees,
|
|
@@ -9291,6 +9546,8 @@ const runTree = (events) => {
|
|
|
9291
9546
|
node.costUsd += event.usage?.costUsd ?? 0;
|
|
9292
9547
|
node.inputTokens += event.usage?.inputTokens ?? 0;
|
|
9293
9548
|
node.outputTokens += event.usage?.outputTokens ?? 0;
|
|
9549
|
+
const written = event.usage?.cacheWriteTokens ?? 0;
|
|
9550
|
+
if (written > 0) node.cacheWriteTokens = (node.cacheWriteTokens ?? 0) + written;
|
|
9294
9551
|
} else if (event.type === "node.skipped") {
|
|
9295
9552
|
const node = nodes.get(event.id);
|
|
9296
9553
|
if (node !== void 0) node.state = "skipped";
|
|
@@ -9306,7 +9563,10 @@ const runTree = (events) => {
|
|
|
9306
9563
|
if (node !== void 0) node.calledRun = event.run;
|
|
9307
9564
|
} else if (event.type === "node.event" && event.event.type === "session.started") {
|
|
9308
9565
|
const node = nodes.get(event.id);
|
|
9309
|
-
if (node !== void 0)
|
|
9566
|
+
if (node !== void 0) {
|
|
9567
|
+
node.model = event.event.model;
|
|
9568
|
+
if (unpriced(event.event.driver)) node.unpriced = true;
|
|
9569
|
+
}
|
|
9310
9570
|
}
|
|
9311
9571
|
const roots = [];
|
|
9312
9572
|
for (const node of nodes.values()) {
|
|
@@ -9323,6 +9583,19 @@ const slotWaitsOf = (events) => {
|
|
|
9323
9583
|
else if (event.type === "slot.taken" || event.type === "node.finished") waiting.delete(event.id);
|
|
9324
9584
|
return [...waiting];
|
|
9325
9585
|
};
|
|
9586
|
+
/** The nodes of a run waiting for a lock now (RFC-016). */
|
|
9587
|
+
const lockWaitsOf = (events) => {
|
|
9588
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
9589
|
+
for (const event of events) if (event.type === "lock.waiting") {
|
|
9590
|
+
const { id, lock, holders } = event;
|
|
9591
|
+
waiting.set(id, {
|
|
9592
|
+
id,
|
|
9593
|
+
lock,
|
|
9594
|
+
holders
|
|
9595
|
+
});
|
|
9596
|
+
} else if (event.type === "lock.taken" || event.type === "node.finished") waiting.delete(event.id);
|
|
9597
|
+
return [...waiting.values()];
|
|
9598
|
+
};
|
|
9326
9599
|
/** Folds a run's `ork pause` and `ork continue` commands (ADR-041). */
|
|
9327
9600
|
const pausedOf = (events) => {
|
|
9328
9601
|
let run = false;
|
|
@@ -9526,7 +9799,7 @@ const createStops = (run) => {
|
|
|
9526
9799
|
/** A promise that never settles: the losing side of a race. */
|
|
9527
9800
|
const NEVER = new Promise(() => {});
|
|
9528
9801
|
/** For a rejection no one is left to hear. */
|
|
9529
|
-
const ignore = () => {};
|
|
9802
|
+
const ignore$1 = () => {};
|
|
9530
9803
|
/** The timeout that applies to a node; a gate's own means something else. */
|
|
9531
9804
|
const timeoutOf = (node, workflow) => (isToolNode(node) || isClaudeNode(node) || node.type === "workflow.call" || node.type === "mcp.call") && node.type !== "agent.subagent" ? node.timeout ?? workflow.defaults?.timeout : void 0;
|
|
9532
9805
|
const createClock = (options) => {
|
|
@@ -9641,7 +9914,7 @@ const withTimeout = async (bound) => {
|
|
|
9641
9914
|
}, graceMs);
|
|
9642
9915
|
return promise;
|
|
9643
9916
|
});
|
|
9644
|
-
running.catch(ignore);
|
|
9917
|
+
running.catch(ignore$1);
|
|
9645
9918
|
try {
|
|
9646
9919
|
const result = await Promise.race([running, leftBehind]);
|
|
9647
9920
|
return expired || result === null ? timedOut(timeout, result) : result;
|
|
@@ -9837,8 +10110,7 @@ const runWatch = async (options) => {
|
|
|
9837
10110
|
};
|
|
9838
10111
|
/** Whether an event only repeats a look's call, once it's been recorded. */
|
|
9839
10112
|
const repeatsCall = (event) => event.type === "tool.called" || event.type === "tool.result";
|
|
9840
|
-
// ../core/src/run/execute.ts
|
|
9841
|
-
/** Runs an activation; control nodes pass their input through. */
|
|
10113
|
+
// ../core/src/run/execute-placed.ts
|
|
9842
10114
|
const saidBy = (error) => error instanceof Error ? error.message : String(error);
|
|
9843
10115
|
/**
|
|
9844
10116
|
* A thrown error's end: a failed git or missing program by its text, else
|
|
@@ -9853,10 +10125,70 @@ const thrown = (error, fallback) => {
|
|
|
9853
10125
|
});
|
|
9854
10126
|
return failedWith(read.kind === "unknown" ? endFor(fallback, { reason: text }, text) : read);
|
|
9855
10127
|
};
|
|
9856
|
-
/** Runs an activation
|
|
9857
|
-
const
|
|
9858
|
-
const
|
|
9859
|
-
const {
|
|
10128
|
+
/** Runs an activation in its checkout, under its timeout. */
|
|
10129
|
+
const executePlaced = async (deps, activation, round, node, executor) => {
|
|
10130
|
+
const { options, contextDeps, clock, graceMs } = deps;
|
|
10131
|
+
const { workspace } = options;
|
|
10132
|
+
const place = await (workspace?.prepare(activation.id, activation.template) ?? Promise.resolve({ cwd: options.cwd })).catch((error) => thrown(error, "invalid_node"));
|
|
10133
|
+
if ("ok" in place) return place;
|
|
10134
|
+
const context = nodeContext(contextDeps, activation, place.cwd, round, clock);
|
|
10135
|
+
const once = (signal, emit) => withTimeout({
|
|
10136
|
+
timeout: timeoutOf(node, options.graph.workflow),
|
|
10137
|
+
clock: clock.forNode(activation.id),
|
|
10138
|
+
signal,
|
|
10139
|
+
graceMs,
|
|
10140
|
+
start: (inner) => executor({
|
|
10141
|
+
...context,
|
|
10142
|
+
emit,
|
|
10143
|
+
signal: inner,
|
|
10144
|
+
node,
|
|
10145
|
+
...place.checkout === void 0 ? {} : { checkout: place.checkout }
|
|
10146
|
+
})
|
|
10147
|
+
});
|
|
10148
|
+
const watch = "watch" in node ? node.watch : void 0;
|
|
10149
|
+
const result = watch === void 0 ? await once(context.signal, context.emit) : await runWatch({
|
|
10150
|
+
id: activation.id,
|
|
10151
|
+
watch,
|
|
10152
|
+
declared: "output" in node ? node.output?.schema : void 0,
|
|
10153
|
+
look: (signal, quiet) => once(signal, (event) => {
|
|
10154
|
+
if (!quiet || !repeatsCall(event)) context.emit(event);
|
|
10155
|
+
}),
|
|
10156
|
+
scope: () => runScope(options.inputs, contextDeps.outputs, contextDeps.runId),
|
|
10157
|
+
hold: context.hold,
|
|
10158
|
+
signal: context.signal,
|
|
10159
|
+
graceMs,
|
|
10160
|
+
onEvent: options.onEvent,
|
|
10161
|
+
resting: (on) => {
|
|
10162
|
+
clock.resting(activation.id, on);
|
|
10163
|
+
},
|
|
10164
|
+
everyMs: options.watchEveryMs
|
|
10165
|
+
});
|
|
10166
|
+
const ended = result.ok || !context.signal.aborted ? result : endedWith(result, stoppedEnd(context.signal));
|
|
10167
|
+
return await workspace?.finish(activation.id, ended) ?? ended;
|
|
10168
|
+
};
|
|
10169
|
+
// ../core/src/run/execute.ts
|
|
10170
|
+
/**
|
|
10171
|
+
* Runs activations, each end's detail scrubbed of the run's paths; and the
|
|
10172
|
+
* run's locks, which the run lets go when it ends.
|
|
10173
|
+
*/
|
|
10174
|
+
const createExecute = (deps) => {
|
|
10175
|
+
const { options, contextDeps, clock } = deps;
|
|
10176
|
+
const { runId, outputs } = contextDeps;
|
|
10177
|
+
const locks = createRunLocks({
|
|
10178
|
+
graph: options.graph,
|
|
10179
|
+
ledgers: options.locks,
|
|
10180
|
+
runId,
|
|
10181
|
+
scope: (item) => runScope(options.inputs, outputs, runId, item),
|
|
10182
|
+
onEvent: options.onEvent,
|
|
10183
|
+
waitFor: clock.waitFor
|
|
10184
|
+
});
|
|
10185
|
+
return {
|
|
10186
|
+
execute: async (activation, round) => scrubbedEnd(await executeOne(deps, locks, activation, round), [options.cwd, options.projectRoot ?? ""]),
|
|
10187
|
+
locks
|
|
10188
|
+
};
|
|
10189
|
+
};
|
|
10190
|
+
const executeOne = async (deps, locks, activation, round) => {
|
|
10191
|
+
const { options, contextDeps, nodeOf } = deps;
|
|
9860
10192
|
const node = nodeOf(activation);
|
|
9861
10193
|
if (node === void 0) return failedWith(endFor("unknown", {}, `unknown node ${activation.id}`));
|
|
9862
10194
|
if (node.type.startsWith("control.")) return passThrough(activation.input);
|
|
@@ -9865,43 +10197,14 @@ const executeOne = async (deps, activation, round) => {
|
|
|
9865
10197
|
try {
|
|
9866
10198
|
const forEach = activation.item === void 0 ? forEachOf(node) : void 0;
|
|
9867
10199
|
if (forEach !== void 0) return await deps.fanOut(activation, forEach, round);
|
|
9868
|
-
|
|
9869
|
-
const
|
|
9870
|
-
if (
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
graceMs,
|
|
9877
|
-
start: (inner) => executor({
|
|
9878
|
-
...context,
|
|
9879
|
-
emit,
|
|
9880
|
-
signal: inner,
|
|
9881
|
-
node,
|
|
9882
|
-
...place.checkout === void 0 ? {} : { checkout: place.checkout }
|
|
9883
|
-
})
|
|
9884
|
-
});
|
|
9885
|
-
const watch = "watch" in node ? node.watch : void 0;
|
|
9886
|
-
const result = watch === void 0 ? await once(context.signal, context.emit) : await runWatch({
|
|
9887
|
-
id: activation.id,
|
|
9888
|
-
watch,
|
|
9889
|
-
declared: "output" in node ? node.output?.schema : void 0,
|
|
9890
|
-
look: (signal, quiet) => once(signal, (event) => {
|
|
9891
|
-
if (!quiet || !repeatsCall(event)) context.emit(event);
|
|
9892
|
-
}),
|
|
9893
|
-
scope: () => runScope(options.inputs, contextDeps.outputs, contextDeps.runId),
|
|
9894
|
-
hold: context.hold,
|
|
9895
|
-
signal: context.signal,
|
|
9896
|
-
graceMs,
|
|
9897
|
-
onEvent: options.onEvent,
|
|
9898
|
-
resting: (on) => {
|
|
9899
|
-
clock.resting(activation.id, on);
|
|
9900
|
-
},
|
|
9901
|
-
everyMs: options.watchEveryMs
|
|
9902
|
-
});
|
|
9903
|
-
const ended = result.ok || !context.signal.aborted ? result : endedWith(result, stoppedEnd(context.signal));
|
|
9904
|
-
return await workspace?.finish(activation.id, ended) ?? ended;
|
|
10200
|
+
if (!("lock" in node) || node.lock === void 0) return await executePlaced(deps, activation, round, node, executor);
|
|
10201
|
+
const signal = contextDeps.stops.signalFor(activation.id, activation.template);
|
|
10202
|
+
if (!await locks.enter(activation, signal)) return failedWith(stoppedEnd(signal));
|
|
10203
|
+
try {
|
|
10204
|
+
return await executePlaced(deps, activation, round, node, executor);
|
|
10205
|
+
} finally {
|
|
10206
|
+
locks.leave(activation);
|
|
10207
|
+
}
|
|
9905
10208
|
} catch (error) {
|
|
9906
10209
|
return thrown(error, "unknown");
|
|
9907
10210
|
}
|
|
@@ -10620,7 +10923,7 @@ const startRun = (options) => {
|
|
|
10620
10923
|
const outputs = {};
|
|
10621
10924
|
const rounds = /* @__PURE__ */ new Map();
|
|
10622
10925
|
const sessionIds = /* @__PURE__ */ new Map();
|
|
10623
|
-
const spend = spendOf(limits.budgetUsd, options);
|
|
10926
|
+
const spend = spendOf(limits.budgetUsd, options, limits.budgetTokens);
|
|
10624
10927
|
const running = /* @__PURE__ */ new Set();
|
|
10625
10928
|
const { summaries, summary } = createSummaries(options.replay?.earlier);
|
|
10626
10929
|
const queue = [];
|
|
@@ -10705,12 +11008,13 @@ const startRun = (options) => {
|
|
|
10705
11008
|
});
|
|
10706
11009
|
const close = async () => {
|
|
10707
11010
|
cancelTimeout();
|
|
11011
|
+
locks.end();
|
|
10708
11012
|
const worktrees = await closeWorkspace(options.workspace, reasons);
|
|
10709
11013
|
await options.beforeFinish?.();
|
|
10710
11014
|
const result = runResult({
|
|
10711
11015
|
runId,
|
|
10712
11016
|
stopped,
|
|
10713
|
-
|
|
11017
|
+
...spend.sums(),
|
|
10714
11018
|
nodes: [...summaries.values()],
|
|
10715
11019
|
reasons,
|
|
10716
11020
|
worktrees,
|
|
@@ -10763,7 +11067,7 @@ const startRun = (options) => {
|
|
|
10763
11067
|
spend,
|
|
10764
11068
|
summary
|
|
10765
11069
|
};
|
|
10766
|
-
const execute = createExecute({
|
|
11070
|
+
const { execute, locks } = createExecute({
|
|
10767
11071
|
options,
|
|
10768
11072
|
contextDeps,
|
|
10769
11073
|
clock,
|
|
@@ -12153,6 +12457,9 @@ const planNodeSession = (workflow, id, options) => {
|
|
|
12153
12457
|
if (maxTurns !== void 0) spec.maxTurns = maxTurns;
|
|
12154
12458
|
const budgets = [node.budgetUsd ?? defaults.budgetUsd, options.budgetLeftUsd].filter((b) => b !== void 0);
|
|
12155
12459
|
if (budgets.length > 0) spec.budgetUsd = Math.min(...budgets);
|
|
12460
|
+
const own = node.budgetTokens ?? defaults.budgetTokens;
|
|
12461
|
+
const left = options.budgetLeftTokens ?? own;
|
|
12462
|
+
if (left !== void 0) spec.budgetTokens = Math.min(left, own ?? left);
|
|
12156
12463
|
const plugin = options.pluginDir === void 0 ? null : writePlugin(context, options.pluginDir, `Skills Ork compiled for ${id}`);
|
|
12157
12464
|
if (plugin !== null) spec.plugins = [plugin];
|
|
12158
12465
|
const settings = sessionSettings(context, options.cwd);
|
|
@@ -12363,6 +12670,9 @@ const FIELDS = {
|
|
|
12363
12670
|
week: "weekUsd",
|
|
12364
12671
|
month: "monthUsd"
|
|
12365
12672
|
};
|
|
12673
|
+
/** The windows a quota in counted tokens (RFC-015) has, and their fields. */
|
|
12674
|
+
const TOKEN_FIELDS = { day: "dayTokens" };
|
|
12675
|
+
const inTokens = (cap) => "capTokens" in cap;
|
|
12366
12676
|
/** Where a window began and when the next begins, in local time. */
|
|
12367
12677
|
const windowOf = (window, now) => {
|
|
12368
12678
|
const [year, month, day] = [
|
|
@@ -12384,33 +12694,50 @@ const windowOf = (window, now) => {
|
|
|
12384
12694
|
until: new Date(year, month, monday + 7)
|
|
12385
12695
|
};
|
|
12386
12696
|
};
|
|
12387
|
-
/**
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
const
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
}
|
|
12397
|
-
|
|
12697
|
+
/**
|
|
12698
|
+
* The caps that apply: the user's on everything, and the project's on itself;
|
|
12699
|
+
* those in dollars, then those in tokens.
|
|
12700
|
+
*/
|
|
12701
|
+
const capsOf = (user, project, root) => {
|
|
12702
|
+
const whose = (field, cap) => {
|
|
12703
|
+
const everywhere = user?.[field];
|
|
12704
|
+
const here = project?.[field];
|
|
12705
|
+
return [...everywhere === void 0 ? [] : [cap(everywhere)], ...here === void 0 || root === void 0 ? [] : [Object.assign(cap(here), { project: root })]];
|
|
12706
|
+
};
|
|
12707
|
+
return [...WINDOWS.flatMap((window) => whose(FIELDS[window], (capUsd) => ({
|
|
12398
12708
|
window,
|
|
12399
|
-
capUsd
|
|
12400
|
-
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12709
|
+
capUsd
|
|
12710
|
+
}))), ...WINDOWS.flatMap((window) => {
|
|
12711
|
+
const field = TOKEN_FIELDS[window];
|
|
12712
|
+
return field === void 0 ? [] : whose(field, (capTokens) => ({
|
|
12713
|
+
window,
|
|
12714
|
+
capTokens
|
|
12715
|
+
}));
|
|
12716
|
+
})];
|
|
12717
|
+
};
|
|
12718
|
+
/** Each cap in dollars with what has been spent against it. */
|
|
12719
|
+
const standingOf = (store, caps, now) => caps.flatMap((cap) => {
|
|
12720
|
+
if (inTokens(cap)) return [];
|
|
12406
12721
|
const { since, until } = windowOf(cap.window, now);
|
|
12407
12722
|
const spentUsd = store.spentSince(since.toISOString(), cap.project);
|
|
12408
|
-
return {
|
|
12723
|
+
return [{
|
|
12409
12724
|
...cap,
|
|
12410
12725
|
spentUsd,
|
|
12411
12726
|
leftUsd: cap.capUsd - spentUsd,
|
|
12412
12727
|
resetsAt: until.toISOString()
|
|
12413
|
-
};
|
|
12728
|
+
}];
|
|
12729
|
+
});
|
|
12730
|
+
/** Each cap in tokens with the counted tokens used against it. */
|
|
12731
|
+
const tokenStandingOf = (store, caps, now) => caps.flatMap((cap) => {
|
|
12732
|
+
if (!inTokens(cap)) return [];
|
|
12733
|
+
const { since, until } = windowOf(cap.window, now);
|
|
12734
|
+
const spentTokens = store.spentSince(since.toISOString(), cap.project, "tokens");
|
|
12735
|
+
return [{
|
|
12736
|
+
...cap,
|
|
12737
|
+
spentTokens,
|
|
12738
|
+
leftTokens: cap.capTokens - spentTokens,
|
|
12739
|
+
resetsAt: until.toISOString()
|
|
12740
|
+
}];
|
|
12414
12741
|
});
|
|
12415
12742
|
/** Dollars: cents, or more digits for an amount under a dollar. */
|
|
12416
12743
|
const usd = (amount) => `$${amount.toFixed(amount > 0 && amount < 1 ? 4 : 2)}`;
|
|
@@ -12418,7 +12745,10 @@ const at = (iso) => new Date(iso).toLocaleString(void 0, {
|
|
|
12418
12745
|
dateStyle: "medium",
|
|
12419
12746
|
timeStyle: "short"
|
|
12420
12747
|
});
|
|
12421
|
-
const capReason = (spent) =>
|
|
12748
|
+
const capReason = (spent) => {
|
|
12749
|
+
const [cap, used] = "capTokens" in spent ? [`${String(spent.capTokens)} tokens`, String(spent.spentTokens)] : [usd(spent.capUsd), usd(spent.spentUsd)];
|
|
12750
|
+
return `${spent.project === void 0 ? "your" : "this project's"} ${spent.window} quota of ${cap} is spent (${used}); it lifts on ${at(spent.resetsAt)}, or raise it in ${spent.project === void 0 ? "~/.ork/settings.yaml" : ".ork/project.yaml"}`;
|
|
12751
|
+
};
|
|
12422
12752
|
/** What a run or a call refused at its start says first, for its caller to read. */
|
|
12423
12753
|
const NOT_STARTED = "Not started: ";
|
|
12424
12754
|
/** Why a plan limit stops a run. */
|
|
@@ -12441,11 +12771,13 @@ const SHOWN_MS = 864e5;
|
|
|
12441
12771
|
/** What the caps and the plan allow a run now. */
|
|
12442
12772
|
const limitsNow = (store, caps, now) => {
|
|
12443
12773
|
const standing = standingOf(store, caps, now);
|
|
12774
|
+
const tokens = tokenStandingOf(store, caps, now);
|
|
12444
12775
|
const [plan] = refusing(store.planLimits(), now);
|
|
12445
|
-
const spent = standing.find((cap) => cap.leftUsd <= 0);
|
|
12776
|
+
const spent = standing.find((cap) => cap.leftUsd <= 0) ?? tokens.find((cap) => cap.leftTokens <= 0);
|
|
12446
12777
|
const blocked = plan === void 0 ? spent === void 0 ? void 0 : capReason(spent) : planReason(plan);
|
|
12447
12778
|
return {
|
|
12448
12779
|
...standing.length === 0 ? {} : { leftUsd: Math.min(...standing.map((cap) => cap.leftUsd)) },
|
|
12780
|
+
...tokens.length === 0 ? {} : { leftTokens: Math.min(...tokens.map((cap) => cap.leftTokens)) },
|
|
12449
12781
|
...blocked === void 0 ? {} : { blocked }
|
|
12450
12782
|
};
|
|
12451
12783
|
};
|
|
@@ -12453,6 +12785,7 @@ const limitsNow = (store, caps, now) => {
|
|
|
12453
12785
|
const limitsReport = (store, { caps, problems }, now) => ({
|
|
12454
12786
|
problems,
|
|
12455
12787
|
caps: standingOf(store, caps, now),
|
|
12788
|
+
tokenCaps: tokenStandingOf(store, caps, now),
|
|
12456
12789
|
plan: store.planLimits().filter((limit) => {
|
|
12457
12790
|
if (limit.status === "allowed") return false;
|
|
12458
12791
|
return (limit.resetsAt === void 0 ? Date.parse(limit.at) + SHOWN_MS : Date.parse(limit.resetsAt)) > now.getTime();
|
|
@@ -12783,7 +13116,9 @@ const mailOf = (events) => {
|
|
|
12783
13116
|
* more than once reads as one history: a later result replaces the earlier
|
|
12784
13117
|
* one at its place. Also: the run's inputs and settings, its worktrees, the
|
|
12785
13118
|
* answers to its loops, and a fanned node's lists and its items' results by
|
|
12786
|
-
* round (RFC-013).
|
|
13119
|
+
* round (RFC-013). A lock the run ended holding (RFC-016) hands back nothing
|
|
13120
|
+
* its nodes did under it, so they run again under the lock. `replay.ts` hands
|
|
13121
|
+
* them back.
|
|
12787
13122
|
*/
|
|
12788
13123
|
const listKey = (node, round) => `${node}/${String(round)}`;
|
|
12789
13124
|
/** How a run ended that cut its working nodes off, rather than failing them. */
|
|
@@ -12818,6 +13153,11 @@ const resumeState = (events) => {
|
|
|
12818
13153
|
/** Nodes whose last activation did not finish well, and its session. */
|
|
12819
13154
|
const unfinished = /* @__PURE__ */ new Map();
|
|
12820
13155
|
const places = /* @__PURE__ */ new Map();
|
|
13156
|
+
/**
|
|
13157
|
+
* Locks held at the end so far, each node that ran under it and how many of
|
|
13158
|
+
* its results came before (RFC-016).
|
|
13159
|
+
*/
|
|
13160
|
+
const spans = /* @__PURE__ */ new Map();
|
|
12821
13161
|
/** A new run of `id`'s `round` replaces what is recorded there and after. */
|
|
12822
13162
|
const supersede = (id, round) => {
|
|
12823
13163
|
fanOuts.items.get(id)?.delete(round);
|
|
@@ -12883,6 +13223,18 @@ const resumeState = (events) => {
|
|
|
12883
13223
|
} else history.set(event.id, [...history.get(event.id) ?? [], result]);
|
|
12884
13224
|
continue;
|
|
12885
13225
|
}
|
|
13226
|
+
if (event.type === "lock.taken") {
|
|
13227
|
+
if (open.get(event.id)?.item === true) continue;
|
|
13228
|
+
const span = spans.get(event.lock) ?? /* @__PURE__ */ new Map();
|
|
13229
|
+
if (!span.has(event.id)) span.set(event.id, history.get(event.id)?.length ?? 0);
|
|
13230
|
+
spans.set(event.lock, span);
|
|
13231
|
+
continue;
|
|
13232
|
+
}
|
|
13233
|
+
if (event.type === "lock.released") {
|
|
13234
|
+
if (open.get(event.id)?.item === true) continue;
|
|
13235
|
+
if (event.end === "span") spans.delete(event.lock);
|
|
13236
|
+
continue;
|
|
13237
|
+
}
|
|
12886
13238
|
if (event.type === "worktree") {
|
|
12887
13239
|
const known = worktrees.get(event.id);
|
|
12888
13240
|
if (event.action === "created" || known === void 0) worktrees.set(event.id, {
|
|
@@ -12934,6 +13286,11 @@ const resumeState = (events) => {
|
|
|
12934
13286
|
continue;
|
|
12935
13287
|
}
|
|
12936
13288
|
}
|
|
13289
|
+
for (const span of spans.values()) for (const [id, kept] of span) {
|
|
13290
|
+
const results = history.get(id)?.slice(0, kept) ?? [];
|
|
13291
|
+
if (results.length === 0) history.delete(id);
|
|
13292
|
+
else history.set(id, results);
|
|
13293
|
+
}
|
|
12937
13294
|
for (const [id, current] of open) {
|
|
12938
13295
|
closed.add(id);
|
|
12939
13296
|
if (current.sessionId !== void 0) unfinished.set(id, current.sessionId);
|
|
@@ -13161,6 +13518,63 @@ const inTransaction = (db, work) => {
|
|
|
13161
13518
|
throw error;
|
|
13162
13519
|
}
|
|
13163
13520
|
};
|
|
13521
|
+
/**
|
|
13522
|
+
* A run's row, with its nodes' counted tokens and whether a session ran on a
|
|
13523
|
+
* driver that reports no dollars (RFC-015), from the usage ledger.
|
|
13524
|
+
*/
|
|
13525
|
+
const RUN_COLUMNS = `runs.*,
|
|
13526
|
+
(SELECT COALESCE(SUM(input_tokens + output_tokens + cache_write_tokens), 0)
|
|
13527
|
+
FROM usage WHERE usage.run_id = runs.id AND kind = 'node') AS tokens,
|
|
13528
|
+
EXISTS (SELECT 1 FROM usage WHERE usage.run_id = runs.id
|
|
13529
|
+
AND driver IN (${UNPRICED_DRIVERS.map((id) => [
|
|
13530
|
+
"'",
|
|
13531
|
+
id,
|
|
13532
|
+
"'"
|
|
13533
|
+
].join("")).join(", ") || "NULL"})) AS partial`;
|
|
13534
|
+
/** A run row's `tokens` and `partial`, as `RUN_COLUMNS` reads them. */
|
|
13535
|
+
const spendOfRow = (row) => {
|
|
13536
|
+
const tokens = Number(row?.["tokens"] ?? 0);
|
|
13537
|
+
return {
|
|
13538
|
+
...tokens > 0 ? { tokens } : {},
|
|
13539
|
+
...Number(row?.["partial"] ?? 0) === 1 ? { partial: true } : {}
|
|
13540
|
+
};
|
|
13541
|
+
};
|
|
13542
|
+
const STATUSES$1 = [
|
|
13543
|
+
"running",
|
|
13544
|
+
"succeeded",
|
|
13545
|
+
"failed",
|
|
13546
|
+
"budget_exhausted",
|
|
13547
|
+
"quota_exhausted",
|
|
13548
|
+
"timed_out",
|
|
13549
|
+
"interrupted"
|
|
13550
|
+
];
|
|
13551
|
+
const statusOf$1 = (value) => STATUSES$1.find((s) => s === value) ?? "failed";
|
|
13552
|
+
/** A run's record from its row, read with `RUN_COLUMNS`. */
|
|
13553
|
+
const recordOf$3 = (row) => {
|
|
13554
|
+
const record = {
|
|
13555
|
+
id: text(row, "id"),
|
|
13556
|
+
workflow: text(row, "workflow"),
|
|
13557
|
+
file: text(row, "file"),
|
|
13558
|
+
project: text(row, "project"),
|
|
13559
|
+
status: statusOf$1(text(row, "status")),
|
|
13560
|
+
pid: Number(row?.["pid"]),
|
|
13561
|
+
startedAt: text(row, "started_at"),
|
|
13562
|
+
costUsd: Number(row?.["cost_usd"]),
|
|
13563
|
+
depth: Number(row?.["depth"] ?? 0),
|
|
13564
|
+
...spendOfRow(row)
|
|
13565
|
+
};
|
|
13566
|
+
const processStart = text(row, "process_start");
|
|
13567
|
+
if (processStart !== "") record.processStart = processStart;
|
|
13568
|
+
const parent = text(row, "parent");
|
|
13569
|
+
if (parent !== "") record.parent = parent;
|
|
13570
|
+
const finished = text(row, "finished_at");
|
|
13571
|
+
if (finished !== "") record.finishedAt = finished;
|
|
13572
|
+
const skipped = row?.["skipped"];
|
|
13573
|
+
if (typeof skipped === "number") record.skipped = skipped;
|
|
13574
|
+
const handled = row?.["failures_handled"];
|
|
13575
|
+
if (typeof handled === "number") record.failuresHandled = handled;
|
|
13576
|
+
return record;
|
|
13577
|
+
};
|
|
13164
13578
|
// ../core/src/store/usage.ts
|
|
13165
13579
|
/** Folds a run's events into its sessions' rows, oldest first. */
|
|
13166
13580
|
const usageRowsOf = (run, events) => {
|
|
@@ -13289,8 +13703,14 @@ const alive = (row) => {
|
|
|
13289
13703
|
/** The ledger's queries; `recordUsage` is the store's own, called on append. */
|
|
13290
13704
|
const usageQueries = (db) => {
|
|
13291
13705
|
const insert = db.prepare(INSERT$1);
|
|
13292
|
-
const
|
|
13293
|
-
|
|
13706
|
+
const spentOf = (column) => ({
|
|
13707
|
+
all: db.prepare(`SELECT COALESCE(SUM(${column}), 0) AS spent FROM usage WHERE at >= ?`),
|
|
13708
|
+
here: db.prepare(`SELECT COALESCE(SUM(${column}), 0) AS spent FROM usage WHERE at >= ? AND project = ?`)
|
|
13709
|
+
});
|
|
13710
|
+
const spent = {
|
|
13711
|
+
usd: spentOf("cost_usd"),
|
|
13712
|
+
tokens: spentOf("input_tokens + output_tokens + cache_write_tokens")
|
|
13713
|
+
};
|
|
13294
13714
|
const runById = db.prepare("SELECT id, project, workflow FROM runs WHERE id = ?");
|
|
13295
13715
|
const lastEvent = db.prepare("SELECT at FROM events WHERE run_id = ? ORDER BY seq DESC LIMIT 1");
|
|
13296
13716
|
const liveOne = db.prepare("SELECT pid, process_start FROM runs WHERE id = ? AND status = 'running'");
|
|
@@ -13313,7 +13733,7 @@ const usageQueries = (db) => {
|
|
|
13313
13733
|
});
|
|
13314
13734
|
for (const row of rows) writeRow(insert, row);
|
|
13315
13735
|
},
|
|
13316
|
-
spentSince: (since, project) => Number((project === void 0 ?
|
|
13736
|
+
spentSince: (since, project, unit = "usd") => Number((project === void 0 ? spent[unit].all.get(since) : spent[unit].here.get(since, project))?.["spent"] ?? 0),
|
|
13317
13737
|
usage: (filter = {}) => {
|
|
13318
13738
|
const { sql, values } = whereOf(filter);
|
|
13319
13739
|
return db.prepare(`SELECT * FROM usage ${sql} ORDER BY at, run_id, seq`).all(...values).map((r) => rowOf$1(r));
|
|
@@ -13369,7 +13789,7 @@ const oneNode = (run, cannot) => run.id.startsWith("node-") ? `Run ${run.id} run
|
|
|
13369
13789
|
/** Every node of a run's tree, spawned instances included. */
|
|
13370
13790
|
const flattenTree = (nodes) => nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
|
|
13371
13791
|
/** What a run's stored events say about it now. */
|
|
13372
|
-
const stateOf = (store, run) => {
|
|
13792
|
+
const stateOf$1 = (store, run) => {
|
|
13373
13793
|
const events = store.events(run.id).map((stored) => stored.event);
|
|
13374
13794
|
return {
|
|
13375
13795
|
paused: pausedOf(events),
|
|
@@ -13393,14 +13813,14 @@ const stopRefusal = (store, run, node) => {
|
|
|
13393
13813
|
if (refused !== null || node === void 0) return refused;
|
|
13394
13814
|
const single = oneNode(run, "stop by name");
|
|
13395
13815
|
if (single !== null) return single;
|
|
13396
|
-
const named = stateOf(store, run).nodes.filter((n) => n.id === node || n.template === node);
|
|
13816
|
+
const named = stateOf$1(store, run).nodes.filter((n) => n.id === node || n.template === node);
|
|
13397
13817
|
if (named.length === 0) return `${node} is not a node of run ${run.id}.`;
|
|
13398
13818
|
return named.some((n) => n.state === "running") ? null : `${node} is not running in run ${run.id}.`;
|
|
13399
13819
|
};
|
|
13400
13820
|
const pauseRefusal = (store, run, node) => {
|
|
13401
13821
|
const refused = notRunning(run) ?? oneNode(run, "pause or continue");
|
|
13402
13822
|
if (refused !== null) return refused;
|
|
13403
|
-
const { paused, nodes } = stateOf(store, run);
|
|
13823
|
+
const { paused, nodes } = stateOf$1(store, run);
|
|
13404
13824
|
if (paused.run) return `Run ${run.id} is already paused.`;
|
|
13405
13825
|
if (node === void 0) return null;
|
|
13406
13826
|
if (!nodes.some((n) => n.id === node)) return `${node} is not a node of run ${run.id}.`;
|
|
@@ -13409,7 +13829,7 @@ const pauseRefusal = (store, run, node) => {
|
|
|
13409
13829
|
const continueRefusal = (store, run, node) => {
|
|
13410
13830
|
const refused = notRunning(run) ?? oneNode(run, "pause or continue");
|
|
13411
13831
|
if (refused !== null) return refused;
|
|
13412
|
-
const { paused } = stateOf(store, run);
|
|
13832
|
+
const { paused } = stateOf$1(store, run);
|
|
13413
13833
|
if (node === void 0) return paused.run || paused.nodes.length > 0 ? null : `Run ${run.id} is not paused.`;
|
|
13414
13834
|
if (paused.nodes.includes(node)) return null;
|
|
13415
13835
|
return paused.run ? `Run ${run.id} is paused as a whole; continue the run, not ${node}.` : `${node} is not paused in run ${run.id}.`;
|
|
@@ -13800,17 +14220,282 @@ const createCalls = (options) => {
|
|
|
13800
14220
|
done();
|
|
13801
14221
|
}
|
|
13802
14222
|
};
|
|
13803
|
-
return {
|
|
13804
|
-
invoke: (request, signal) => {
|
|
13805
|
-
if (request.node === void 0) return callOnce(request, signal);
|
|
13806
|
-
const turn = (queues.get(request.callId) ?? Promise.resolve()).catch(() => null).then(() => converse(request, signal));
|
|
13807
|
-
queues.set(request.callId, turn);
|
|
13808
|
-
return turn;
|
|
13809
|
-
},
|
|
13810
|
-
stopAll: () => {
|
|
13811
|
-
for (const id of started) stop(id);
|
|
13812
|
-
}
|
|
14223
|
+
return {
|
|
14224
|
+
invoke: (request, signal) => {
|
|
14225
|
+
if (request.node === void 0) return callOnce(request, signal);
|
|
14226
|
+
const turn = (queues.get(request.callId) ?? Promise.resolve()).catch(() => null).then(() => converse(request, signal));
|
|
14227
|
+
queues.set(request.callId, turn);
|
|
14228
|
+
return turn;
|
|
14229
|
+
},
|
|
14230
|
+
stopAll: () => {
|
|
14231
|
+
for (const id of started) stop(id);
|
|
14232
|
+
}
|
|
14233
|
+
};
|
|
14234
|
+
};
|
|
14235
|
+
// ../core/src/remote/decide.ts
|
|
14236
|
+
/**
|
|
14237
|
+
* @file The open decisions of live runs, and answering one from another process
|
|
14238
|
+
* (`ork decide`, the local server), with the person's note if they gave one.
|
|
14239
|
+
* An answer goes to the store (the first one wins), then the run is woken on
|
|
14240
|
+
* its socket so it picks the answer up at once (specs/messaging.md).
|
|
14241
|
+
*/
|
|
14242
|
+
/** Open decisions whose run is still live, oldest first, ANDing the filter. */
|
|
14243
|
+
const pendingDecisions = (store, filter) => {
|
|
14244
|
+
const live = /* @__PURE__ */ new Map();
|
|
14245
|
+
return store.decisions({
|
|
14246
|
+
...filter,
|
|
14247
|
+
status: "open"
|
|
14248
|
+
}).filter((decision) => {
|
|
14249
|
+
if (!live.has(decision.runId)) {
|
|
14250
|
+
const [run] = store.lookup(decision.runId);
|
|
14251
|
+
live.set(decision.runId, run !== void 0 && liveStatus(run) === "running" && (filter.workflow === void 0 || run.workflow === filter.workflow));
|
|
14252
|
+
}
|
|
14253
|
+
return live.get(decision.runId) === true;
|
|
14254
|
+
});
|
|
14255
|
+
};
|
|
14256
|
+
/**
|
|
14257
|
+
* Answers an open decision with one of its options, and wakes its run. `home`
|
|
14258
|
+
* is `~`, where the sockets are (tests pass a folder); `by` says who answered;
|
|
14259
|
+
* `note` is what they said with it, if anything (blank is nothing).
|
|
14260
|
+
*/
|
|
14261
|
+
const answerDecision = async (store, id, choice, home, by = "decide", note) => {
|
|
14262
|
+
const decision = store.decision(id);
|
|
14263
|
+
if (decision === void 0) return {
|
|
14264
|
+
ok: false,
|
|
14265
|
+
error: `No decision \`${id}\`.`
|
|
14266
|
+
};
|
|
14267
|
+
if (!decision.options.some((o) => o.id === choice)) return {
|
|
14268
|
+
ok: false,
|
|
14269
|
+
error: `\`${choice}\` is not an option of \`${id}\`: \`${decision.options.map((o) => o.id).join("`, `")}\``
|
|
14270
|
+
};
|
|
14271
|
+
if (!store.answerDecision(id, choice, by, note)) {
|
|
14272
|
+
const now = store.decision(id);
|
|
14273
|
+
return {
|
|
14274
|
+
ok: false,
|
|
14275
|
+
error: now?.status === "answered" ? `${id} was already answered: ${now.answer ?? ""} (${now.answeredBy ?? ""})` : `${id} was withdrawn`
|
|
14276
|
+
};
|
|
14277
|
+
}
|
|
14278
|
+
return {
|
|
14279
|
+
ok: true,
|
|
14280
|
+
woke: await wakeRun(socketPath(decision.runId, home))
|
|
14281
|
+
};
|
|
14282
|
+
};
|
|
14283
|
+
// ../core/src/remote/follow-tree.ts
|
|
14284
|
+
/**
|
|
14285
|
+
* Calls `onEvent` for each event of `root` and of the runs it called, once
|
|
14286
|
+
* each. A called run's stored events come right after the `call.started` that
|
|
14287
|
+
* started it; live events as they arrive. Ends when the root ends or `signal`
|
|
14288
|
+
* aborts.
|
|
14289
|
+
*/
|
|
14290
|
+
const followRunTree = async (store, root, onEvent, options = {}) => {
|
|
14291
|
+
/** Every run followed, with its call path, in the order found. */
|
|
14292
|
+
const paths = /* @__PURE__ */ new Map([[root, []]]);
|
|
14293
|
+
/** Each run's last event seen, for the catch-up once the root ends. */
|
|
14294
|
+
const seqs = /* @__PURE__ */ new Map();
|
|
14295
|
+
const followers = [];
|
|
14296
|
+
/** Ends the called runs' following once the root's has ended. */
|
|
14297
|
+
const halt = new AbortController();
|
|
14298
|
+
const signal = options.signal === void 0 ? halt.signal : AbortSignal.any([options.signal, halt.signal]);
|
|
14299
|
+
const handle = (run, path) => (stored) => {
|
|
14300
|
+
seqs.set(run, stored.seq);
|
|
14301
|
+
onEvent({
|
|
14302
|
+
run,
|
|
14303
|
+
path,
|
|
14304
|
+
stored
|
|
14305
|
+
});
|
|
14306
|
+
const { event } = stored;
|
|
14307
|
+
if (event.type === "call.started" && !paths.has(event.run)) follow(event.run, [...path, event.node]);
|
|
14308
|
+
};
|
|
14309
|
+
const follow = (run, path) => {
|
|
14310
|
+
paths.set(run, path);
|
|
14311
|
+
followers.push(followEvents(store, run, handle(run, path), {
|
|
14312
|
+
...options,
|
|
14313
|
+
follow: true,
|
|
14314
|
+
after: seqs.get(run) ?? 0,
|
|
14315
|
+
signal
|
|
14316
|
+
}));
|
|
14317
|
+
};
|
|
14318
|
+
/** Waits for every follower, including those started while waiting. */
|
|
14319
|
+
const settle = async () => {
|
|
14320
|
+
while (followers.length > 0) await Promise.all(followers.splice(0));
|
|
14321
|
+
};
|
|
14322
|
+
const end = await followEvents(store, root, handle(root, []), {
|
|
14323
|
+
...options,
|
|
14324
|
+
follow: true
|
|
14325
|
+
});
|
|
14326
|
+
halt.abort();
|
|
14327
|
+
await settle();
|
|
14328
|
+
if (end !== "aborted") {
|
|
14329
|
+
for (const [run, path] of [...paths].slice(1)) followers.push(followEvents(store, run, handle(run, path), {
|
|
14330
|
+
follow: false,
|
|
14331
|
+
after: seqs.get(run) ?? 0
|
|
14332
|
+
}));
|
|
14333
|
+
await settle();
|
|
14334
|
+
}
|
|
14335
|
+
return {
|
|
14336
|
+
end,
|
|
14337
|
+
stillRunning: [...paths.keys()].slice(1).filter((id) => {
|
|
14338
|
+
const [run] = store.lookup(id);
|
|
14339
|
+
return run !== void 0 && liveStatus(run) === "running";
|
|
14340
|
+
})
|
|
14341
|
+
};
|
|
14342
|
+
};
|
|
14343
|
+
// ../core/src/remote/relay.ts
|
|
14344
|
+
/**
|
|
14345
|
+
* @file A run's side of the relay (RFC-003): its nodes' links, read from the
|
|
14346
|
+
* store, and sending to a node of another run. A message is a row in the
|
|
14347
|
+
* store and a wake on that run's socket, so no process has to stay up for it,
|
|
14348
|
+
* and the store has it if the socket is gone. Reaching a link's limit asks
|
|
14349
|
+
* the user whether to go on, as a loop out of rounds does.
|
|
14350
|
+
*/
|
|
14351
|
+
/** For a refused or abandoned question: a stopped run has nothing to do. */
|
|
14352
|
+
const ignore = () => {};
|
|
14353
|
+
const createRelay = (options) => {
|
|
14354
|
+
const { store, runId, home, human } = options;
|
|
14355
|
+
/** Links with the question about their limit open. */
|
|
14356
|
+
const asking = /* @__PURE__ */ new Set();
|
|
14357
|
+
const askToGoOn = async (link, node) => {
|
|
14358
|
+
if (asking.has(link.id)) return;
|
|
14359
|
+
asking.add(link.id);
|
|
14360
|
+
try {
|
|
14361
|
+
if (await human.ask({
|
|
14362
|
+
nodeId: node,
|
|
14363
|
+
question: `${addressOf(link.a)} and ${addressOf(link.b)} have used all ${String(link.maxMessages)} messages of their link. Let them go on?`,
|
|
14364
|
+
options: [{
|
|
14365
|
+
id: "extend",
|
|
14366
|
+
description: `${String(link.maxMessages)} more`
|
|
14367
|
+
}, {
|
|
14368
|
+
id: "end",
|
|
14369
|
+
description: "End the link"
|
|
14370
|
+
}],
|
|
14371
|
+
recommended: "end"
|
|
14372
|
+
}) === "extend") {
|
|
14373
|
+
store.extendLink(link.id, link.maxMessages);
|
|
14374
|
+
await Promise.all([link.a, link.b].map((end) => tellNode(store, end, "[ork] The user let this link go on; you can send again.", home)));
|
|
14375
|
+
} else await endLink(store, link, "the user ended it", home);
|
|
14376
|
+
} finally {
|
|
14377
|
+
asking.delete(link.id);
|
|
14378
|
+
}
|
|
14379
|
+
};
|
|
14380
|
+
/** Why `peer`'s run cannot take a message now, or null. */
|
|
14381
|
+
const closed = (peer) => {
|
|
14382
|
+
const [run] = store.lookup(peer.run);
|
|
14383
|
+
if (run === void 0) return `run ${peer.run} is gone`;
|
|
14384
|
+
const status = liveStatus(run);
|
|
14385
|
+
return status === "running" ? null : `its run has finished (${status}). A finished run's session is not resumed by another agent; the user can resume it with \`ork chat\``;
|
|
14386
|
+
};
|
|
14387
|
+
const isRunning = (peer) => {
|
|
14388
|
+
const events = store.events(peer.run).map((stored) => stored.event);
|
|
14389
|
+
return flattenTree(runTree(events)).some((n) => n.id === peer.node && n.state === "running");
|
|
14390
|
+
};
|
|
14391
|
+
return {
|
|
14392
|
+
peers: (node) => store.links({
|
|
14393
|
+
run: runId,
|
|
14394
|
+
live: true
|
|
14395
|
+
}).flatMap((link) => {
|
|
14396
|
+
const peer = peerOf(link, runId, node);
|
|
14397
|
+
return peer === void 0 ? [] : [addressOf(peer)];
|
|
14398
|
+
}),
|
|
14399
|
+
send: (node, to, body) => {
|
|
14400
|
+
const link = store.links({
|
|
14401
|
+
run: runId,
|
|
14402
|
+
live: true
|
|
14403
|
+
}).find((candidate) => {
|
|
14404
|
+
const peer = peerOf(candidate, runId, node);
|
|
14405
|
+
return peer !== void 0 && addressOf(peer) === to;
|
|
14406
|
+
});
|
|
14407
|
+
const peer = link && peerOf(link, runId, node);
|
|
14408
|
+
if (link === void 0 || peer === void 0) return {
|
|
14409
|
+
ok: false,
|
|
14410
|
+
error: `you have no live link to ${to}`
|
|
14411
|
+
};
|
|
14412
|
+
const why = closed(peer);
|
|
14413
|
+
if (why !== null) {
|
|
14414
|
+
endLink(store, link, why, home);
|
|
14415
|
+
return {
|
|
14416
|
+
ok: false,
|
|
14417
|
+
error: `${to} cannot take it: ${why}`
|
|
14418
|
+
};
|
|
14419
|
+
}
|
|
14420
|
+
if (link.messages >= link.maxMessages) {
|
|
14421
|
+
askToGoOn(link, node).catch(ignore);
|
|
14422
|
+
return {
|
|
14423
|
+
ok: false,
|
|
14424
|
+
error: `this link has used all ${String(link.maxMessages)} of its messages, and the user has been asked whether to go on`
|
|
14425
|
+
};
|
|
14426
|
+
}
|
|
14427
|
+
store.countMessage(link.id);
|
|
14428
|
+
store.command(peer.run, {
|
|
14429
|
+
kind: "relay",
|
|
14430
|
+
node: peer.node,
|
|
14431
|
+
from: `${runId}/${node}`,
|
|
14432
|
+
body
|
|
14433
|
+
});
|
|
14434
|
+
wakeRun(socketPath(peer.run, home));
|
|
14435
|
+
return {
|
|
14436
|
+
ok: true,
|
|
14437
|
+
delivered: isRunning(peer) ? "now" : "queued"
|
|
14438
|
+
};
|
|
14439
|
+
}
|
|
14440
|
+
};
|
|
14441
|
+
};
|
|
14442
|
+
// ../core/src/remote/send.ts
|
|
14443
|
+
/**
|
|
14444
|
+
* @file `ork send` (RFC-005): a program in a tool node writes to a session on
|
|
14445
|
+
* its run's bus. The check is on the sender's side, as a refusal must be an
|
|
14446
|
+
* exit code a script can act on; then a row in the store and a wake, as for a
|
|
14447
|
+
* message between linked runs. `ORK_RUN` and `ORK_NODE` say who it is, and
|
|
14448
|
+
* they are attribution, not proof: the check stops mistakes, not a program
|
|
14449
|
+
* running as the same user.
|
|
14450
|
+
*/
|
|
14451
|
+
/** A first value, as for links; real use will show whether it is the right one. */
|
|
14452
|
+
const DEFAULT_SCRIPT_MESSAGES = 100;
|
|
14453
|
+
/**
|
|
14454
|
+
* Why this sender may not write to this node; `null` when it may. The body is
|
|
14455
|
+
* not needed, so a program is told before it pipes a whole message in.
|
|
14456
|
+
*/
|
|
14457
|
+
const scriptRefusal = (store, send) => {
|
|
14458
|
+
const { run: runId, node, to } = send;
|
|
14459
|
+
if (runId === void 0 || node === void 0) return "`ork send` runs inside a tool node of a run: `ORK_RUN` and `ORK_NODE` are not set.";
|
|
14460
|
+
if (runId.startsWith("node-")) return `Run ${runId} runs one node, which has no message bus; run the whole workflow.`;
|
|
14461
|
+
const run = store.lookup(runId).find((found) => found.id === runId);
|
|
14462
|
+
if (run === void 0) return `There is no run ${runId}.`;
|
|
14463
|
+
const status = liveStatus(run);
|
|
14464
|
+
if (status !== "running") return `Run ${runId} is not running (${status}).`;
|
|
14465
|
+
const started = store.events(runId).map((stored) => stored.event).findLast((event) => event.type === "run.started");
|
|
14466
|
+
if (started === void 0) return `Run ${runId} has not started yet.`;
|
|
14467
|
+
const own = started.nodes.find((found) => found.id === node);
|
|
14468
|
+
if (own === void 0 || !own.type.startsWith("tool.")) return `${node} is not a tool node of run ${runId}.`;
|
|
14469
|
+
const contacts = scriptContactsOf(node, started.nodes, started.channels ?? []);
|
|
14470
|
+
if (contacts.includes(to)) return null;
|
|
14471
|
+
return contacts.length === 0 ? `\`${node}\` has no one to write to: give it an owner, or a channel under \`messages:\`.` : `\`${to}\` is not a contact of \`${node}\`, which can write to \`${contacts.join("`, `")}\` (its owner and its channels).`;
|
|
14472
|
+
};
|
|
14473
|
+
/** Reading all of it would cost as much as sending it. */
|
|
14474
|
+
const TOO_LONG = "The message is over 64 KiB. Write it to a file and send the file's path.";
|
|
14475
|
+
/** Checks the message, writes it for the run, and wakes it. */
|
|
14476
|
+
const sendFromScript = async (store, send, home) => {
|
|
14477
|
+
const refused = scriptRefusal(store, send);
|
|
14478
|
+
if (refused !== null) return {
|
|
14479
|
+
ok: false,
|
|
14480
|
+
error: refused
|
|
13813
14481
|
};
|
|
14482
|
+
if (Buffer.byteLength(send.body) > 65536) return {
|
|
14483
|
+
ok: false,
|
|
14484
|
+
error: TOO_LONG
|
|
14485
|
+
};
|
|
14486
|
+
const run = String(send.run);
|
|
14487
|
+
const node = String(send.node);
|
|
14488
|
+
if (!store.scriptCommand(run, {
|
|
14489
|
+
kind: "script",
|
|
14490
|
+
node: send.to,
|
|
14491
|
+
from: node,
|
|
14492
|
+
body: send.body
|
|
14493
|
+
}, DEFAULT_SCRIPT_MESSAGES)) return {
|
|
14494
|
+
ok: false,
|
|
14495
|
+
error: `\`${node}\` has sent ${String(DEFAULT_SCRIPT_MESSAGES)} messages in this run, which is the most it may.`
|
|
14496
|
+
};
|
|
14497
|
+
await wakeRun(socketPath(run, home));
|
|
14498
|
+
return { ok: true };
|
|
13814
14499
|
};
|
|
13815
14500
|
// ../core/src/run/driver-queues.ts
|
|
13816
14501
|
const isSession = (node) => node.type === "agent.leader" || node.type === "agent.worker";
|
|
@@ -13878,7 +14563,7 @@ const checkoutContention = (workflow, include) => {
|
|
|
13878
14563
|
}
|
|
13879
14564
|
return found;
|
|
13880
14565
|
};
|
|
13881
|
-
const messageOf$
|
|
14566
|
+
const messageOf$2 = (driver, found) => {
|
|
13882
14567
|
const names = found.nodes.map((n) => `\`${n}\``).join(", ");
|
|
13883
14568
|
const where = found.checkout === void 0 ? "the project checkout" : `the worktree of \`${found.checkout}\``;
|
|
13884
14569
|
return found.owner === void 0 ? `driver \`${driver}\` runs one session per checkout, but ${names} work in ${where} and could run at once: one session waits for the other. Give them separate worktrees (\`isolation: worktree\`), or run them on another driver.` : `node \`${found.owner}\` runs on driver \`${driver}\`, which runs one session per checkout, but ${names} work in ${where} and one owns another: the owner's session holds the checkout while the nodes it owns wait for it, so the run would never finish. Give each owned node its own worktree (\`isolation: worktree\`), or run them on another driver.`;
|
|
@@ -13893,16 +14578,17 @@ const driverQueues = (workflow, drivers, choice) => drivers.filter((driver) => d
|
|
|
13893
14578
|
return node !== void 0 && nodeDriver(node, workflow, choice) === driver.id;
|
|
13894
14579
|
}).map((found) => Object.assign({}, found, {
|
|
13895
14580
|
driver: driver.id,
|
|
13896
|
-
message: messageOf$
|
|
14581
|
+
message: messageOf$2(driver.id, found)
|
|
13897
14582
|
})));
|
|
13898
14583
|
// ../core/src/run/driver-needs.ts
|
|
13899
14584
|
const SESSION_TYPES = /* @__PURE__ */ new Set(["agent.leader", "agent.worker"]);
|
|
13900
|
-
/** The node's turn limit and
|
|
14585
|
+
/** The node's turn limit and budgets, its own or the workflow's default. */
|
|
13901
14586
|
const limitsOf = (node, workflow) => {
|
|
13902
14587
|
const own = isClaudeNode(node) ? node : void 0;
|
|
13903
14588
|
return {
|
|
13904
14589
|
maxTurns: own?.maxTurns ?? workflow.defaults?.maxTurns,
|
|
13905
|
-
budgetUsd: own?.budgetUsd ?? workflow.defaults?.budgetUsd
|
|
14590
|
+
budgetUsd: own?.budgetUsd ?? workflow.defaults?.budgetUsd,
|
|
14591
|
+
budgetTokens: own?.budgetTokens ?? workflow.defaults?.budgetTokens
|
|
13906
14592
|
};
|
|
13907
14593
|
};
|
|
13908
14594
|
/**
|
|
@@ -13932,7 +14618,7 @@ const subagentRefusals = (id, workflow, driver) => Object.entries(workflow.nodes
|
|
|
13932
14618
|
* rendered names the driver.
|
|
13933
14619
|
*/
|
|
13934
14620
|
const sessionRefusals = (id, node, workflow, driver) => {
|
|
13935
|
-
const { maxTurns
|
|
14621
|
+
const { maxTurns } = limitsOf(node, workflow);
|
|
13936
14622
|
const refuse = (message) => ({
|
|
13937
14623
|
node: id,
|
|
13938
14624
|
message: `node \`${id}\` ${message}`
|
|
@@ -13940,14 +14626,37 @@ const sessionRefusals = (id, node, workflow, driver) => {
|
|
|
13940
14626
|
const refusals = [];
|
|
13941
14627
|
if (!driver.proven) refusals.push(refuse(`runs on driver \`${driver.id}\`, which is not yet proven on a real session`));
|
|
13942
14628
|
if (maxTurns !== void 0 && !driver.capabilities.turnLimit) refusals.push(refuse(`sets maxTurns, but driver \`${driver.id}\` has no turn limit`));
|
|
13943
|
-
if (budgetUsd !== void 0 && !driver.capabilities.price) refusals.push(refuse(`sets budgetUsd, but driver \`${driver.id}\` does not report cost in dollars (a token budget comes with RFC-015)`));
|
|
13944
|
-
else if (workflow.limits?.budgetUsd !== void 0 && !driver.capabilities.price) refusals.push(refuse(`runs under the workflow's limits.budgetUsd, but driver \`${driver.id}\` does not report cost in dollars, so the budget can't be kept (a token budget comes with RFC-015)`));
|
|
13945
14629
|
if ((("mcp" in node ? node.mcp : void 0) ?? workflow.defaults?.mcp ?? []).length > 0 && !driver.capabilities.mcpServers) refusals.push(refuse(`names MCP servers (\`mcp\`), but driver \`${driver.id}\` does not load a node's own MCP servers`));
|
|
13946
14630
|
const ask = askOfTools(id, node, workflow);
|
|
13947
14631
|
if (ask !== void 0 && !driver.capabilities.questions) refusals.push(refuse(`${ask}, but driver \`${driver.id}\` does not reach Ork's tools (ask_user, delegate, messages)`));
|
|
13948
14632
|
return [...refusals, ...subagentRefusals(id, workflow, driver)];
|
|
13949
14633
|
};
|
|
13950
14634
|
/**
|
|
14635
|
+
* What a dollar budget can't see on `driver`, which reports no dollars
|
|
14636
|
+
* (RFC-015): the node's own `budgetUsd` with no `budgetTokens` beside it, or
|
|
14637
|
+
* the run's `limits.budgetUsd`, which this node's sessions spend unseen.
|
|
14638
|
+
*/
|
|
14639
|
+
const sessionBudgetWarnings = (id, node, workflow, driver) => {
|
|
14640
|
+
if (driver.capabilities.price) return [];
|
|
14641
|
+
const { budgetUsd, budgetTokens } = limitsOf(node, workflow);
|
|
14642
|
+
const warn = (message) => ({
|
|
14643
|
+
node: id,
|
|
14644
|
+
message: `node \`${id}\` ${message}`
|
|
14645
|
+
});
|
|
14646
|
+
return [...budgetUsd !== void 0 && budgetTokens === void 0 ? [warn(`sets only budgetUsd, but driver \`${driver.id}\` reports no dollars: only budgetTokens applies on ${driver.id}`)] : [], ...workflow.limits?.budgetUsd === void 0 ? [] : [warn(`runs under the workflow's limits.budgetUsd, but driver \`${driver.id}\` reports no dollars, so that limit can't see its sessions`)]];
|
|
14647
|
+
};
|
|
14648
|
+
/**
|
|
14649
|
+
* The warnings `sessionBudgetWarnings` gives each session node (or only
|
|
14650
|
+
* `only`), on the driver it takes as for `driverRefusals`. Never a refusal.
|
|
14651
|
+
*/
|
|
14652
|
+
const budgetWarnings = (workflow, drivers, choice) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
14653
|
+
if (!SESSION_TYPES.has(node.type)) return [];
|
|
14654
|
+
if (choice.only !== void 0 && choice.only !== id) return [];
|
|
14655
|
+
const driverId = nodeDriver(node, workflow, choice);
|
|
14656
|
+
const driver = drivers.find((d) => d.id === driverId);
|
|
14657
|
+
return driver === void 0 ? [] : sessionBudgetWarnings(id, node, workflow, driver);
|
|
14658
|
+
});
|
|
14659
|
+
/**
|
|
13951
14660
|
* What stops each session node (or only `only`) from running on its driver: its
|
|
13952
14661
|
* model's prefix, else `override` (`--driver`), else what the workflow names,
|
|
13953
14662
|
* else `ready`. A node with no driver to check is left out; the run refuses
|
|
@@ -14023,6 +14732,89 @@ const parseInputPairs = (pairs) => {
|
|
|
14023
14732
|
raw
|
|
14024
14733
|
};
|
|
14025
14734
|
};
|
|
14735
|
+
// ../core/src/run/replay.ts
|
|
14736
|
+
const NO_FAN_OUTS = {
|
|
14737
|
+
lists: /* @__PURE__ */ new Map(),
|
|
14738
|
+
items: /* @__PURE__ */ new Map()
|
|
14739
|
+
};
|
|
14740
|
+
/** Whether an activation's input carries a result this run made anew. */
|
|
14741
|
+
const carries = (fresh, input) => {
|
|
14742
|
+
if (input === void 0) return false;
|
|
14743
|
+
if (fresh.has(input)) return true;
|
|
14744
|
+
const results = input["results"];
|
|
14745
|
+
if (results === null || typeof results !== "object") return false;
|
|
14746
|
+
return Object.values(results).some((value) => value !== null && typeof value === "object" && fresh.has(value));
|
|
14747
|
+
};
|
|
14748
|
+
/**
|
|
14749
|
+
* Hands each node's recorded results back in order, until the node runs anew
|
|
14750
|
+
* once (it failed or never finished last time, or its input is new); from then
|
|
14751
|
+
* on it always runs. A node cut off mid-session continues that session, once.
|
|
14752
|
+
*/
|
|
14753
|
+
const createReplay = (history, cutOff = /* @__PURE__ */ new Map(), mail, earlier, fanOuts = NO_FAN_OUTS) => {
|
|
14754
|
+
const sessions = new Map(cutOff);
|
|
14755
|
+
const used = /* @__PURE__ */ new Map();
|
|
14756
|
+
const anew = /* @__PURE__ */ new Set();
|
|
14757
|
+
const fresh = /* @__PURE__ */ new WeakSet();
|
|
14758
|
+
/** Fanned nodes whose list was resolved anew: their items can't be replayed. */
|
|
14759
|
+
const renewed = /* @__PURE__ */ new Set();
|
|
14760
|
+
const takeItem = (activation, of) => {
|
|
14761
|
+
const recorded = fanOuts.items.get(activation.id)?.get(activation.round ?? 0);
|
|
14762
|
+
if (recorded !== void 0 && !renewed.has(of) && !carries(fresh, activation.input)) return recorded;
|
|
14763
|
+
anew.add(activation.id);
|
|
14764
|
+
return null;
|
|
14765
|
+
};
|
|
14766
|
+
return {
|
|
14767
|
+
take: (activation) => {
|
|
14768
|
+
const { id } = activation;
|
|
14769
|
+
if (activation.brief !== void 0 || anew.has(id)) return null;
|
|
14770
|
+
if (activation.item !== void 0) return takeItem(activation, activation.item.of);
|
|
14771
|
+
const index = used.get(id) ?? 0;
|
|
14772
|
+
const recorded = history.get(id)?.[index];
|
|
14773
|
+
if (recorded === void 0 || carries(fresh, activation.input)) {
|
|
14774
|
+
anew.add(id);
|
|
14775
|
+
return null;
|
|
14776
|
+
}
|
|
14777
|
+
used.set(id, index + 1);
|
|
14778
|
+
return recorded;
|
|
14779
|
+
},
|
|
14780
|
+
ran: (result) => {
|
|
14781
|
+
fresh.add(result.output);
|
|
14782
|
+
},
|
|
14783
|
+
cutOff: (activation) => {
|
|
14784
|
+
const session = activation.brief === void 0 && (activation.item === void 0 || !renewed.has(activation.item.of)) ? sessions.get(activation.id) : void 0;
|
|
14785
|
+
sessions.delete(activation.id);
|
|
14786
|
+
return session;
|
|
14787
|
+
},
|
|
14788
|
+
listed: (id, round, source) => {
|
|
14789
|
+
const list = fanOuts.lists.get(listKey(id, round));
|
|
14790
|
+
const ranAnew = typeof source === "object" && source !== null && fresh.has(source);
|
|
14791
|
+
if (list !== void 0 && !ranAnew) return list;
|
|
14792
|
+
renewed.add(id);
|
|
14793
|
+
return null;
|
|
14794
|
+
},
|
|
14795
|
+
previousSession: ({ id, item, round = 0 }) => item === void 0 || renewed.has(item.of) ? void 0 : [...fanOuts.items.get(id) ?? []].findLast(([before]) => before < round)?.[1].sessionId,
|
|
14796
|
+
lastOutput: (id) => history.get(id)?.at(-1)?.output,
|
|
14797
|
+
kept: () => [...history.keys(), ...fanOuts.items.keys()],
|
|
14798
|
+
...mail === void 0 ? {} : { mail },
|
|
14799
|
+
...earlier === void 0 ? {} : { earlier }
|
|
14800
|
+
};
|
|
14801
|
+
};
|
|
14802
|
+
/**
|
|
14803
|
+
* The run's `HumanChannel`, answering questions about loops out of rounds as
|
|
14804
|
+
* they were answered last time, in order; anything else goes to `human`.
|
|
14805
|
+
*/
|
|
14806
|
+
const replayLoopAnswers = (human, answers) => {
|
|
14807
|
+
const left = [...answers];
|
|
14808
|
+
return {
|
|
14809
|
+
permit: (request) => human.permit(request),
|
|
14810
|
+
...human.settled === void 0 ? {} : { settled: human.settled },
|
|
14811
|
+
ask: (question) => {
|
|
14812
|
+
const index = left.findIndex((a) => a.question === question.question);
|
|
14813
|
+
const [given] = index === -1 ? [] : left.splice(index, 1);
|
|
14814
|
+
return given === void 0 ? human.ask(question) : Promise.resolve(given.answer);
|
|
14815
|
+
}
|
|
14816
|
+
};
|
|
14817
|
+
};
|
|
14026
14818
|
// ../core/src/store/drafts.ts
|
|
14027
14819
|
const rowOf = (row) => {
|
|
14028
14820
|
const baseHash = text(row, "base_hash");
|
|
@@ -14053,15 +14845,6 @@ const draftQueries = (db) => {
|
|
|
14053
14845
|
}
|
|
14054
14846
|
};
|
|
14055
14847
|
};
|
|
14056
|
-
// ../core/src/worktree/manager.ts
|
|
14057
|
-
/**
|
|
14058
|
-
* @file Ork's git worktrees: one per `worktree` node, beside the clone, by
|
|
14059
|
-
* default in `../<clone>-worktrees/ork-<run>-<node>` on branch
|
|
14060
|
-
* `ork/<run>/<node>` (ADR-020; a project's git rules rename them, ADR-032).
|
|
14061
|
-
* Ork only creates, commits, merges and removes what its runs created; it
|
|
14062
|
-
* never pushes.
|
|
14063
|
-
*/
|
|
14064
|
-
const BRANCH_PREFIX = "ork/";
|
|
14065
14848
|
/** Gitignored files a session needs as much in a worktree as in the clone. */
|
|
14066
14849
|
const LOCAL_AGENT_FILES = ["CLAUDE.local.md", ".claude/settings.local.json"];
|
|
14067
14850
|
/** Where the project's instructions, skills and agents live. */
|
|
@@ -14205,11 +14988,27 @@ const createWorktree = async (options) => {
|
|
|
14205
14988
|
};
|
|
14206
14989
|
/** Copies files that exist in `from` and are missing in the worktree. */
|
|
14207
14990
|
const carryFiles = (from, worktree, files) => copyInto(from, worktree, files.filter((f) => !path.isAbsolute(f) && !f.split(/[\\/]/u).includes("..")));
|
|
14991
|
+
/** What git keeps in a worktree's git dir while a rebase, merge or pick runs. */
|
|
14992
|
+
const UNDERWAY = [
|
|
14993
|
+
"rebase-merge",
|
|
14994
|
+
"rebase-apply",
|
|
14995
|
+
"MERGE_HEAD",
|
|
14996
|
+
"CHERRY_PICK_HEAD",
|
|
14997
|
+
"REVERT_HEAD"
|
|
14998
|
+
];
|
|
14999
|
+
/** Whether a rebase, merge, cherry-pick or revert is underway in `worktree`. */
|
|
15000
|
+
const gitOperationUnderway = async (worktree) => {
|
|
15001
|
+
const paths = await gitOk(worktree, ["rev-parse", ...UNDERWAY.flatMap((name) => ["--git-path", name])]);
|
|
15002
|
+
return lines(paths).some((file) => existsSync(path.resolve(worktree, file)));
|
|
15003
|
+
};
|
|
14208
15004
|
/**
|
|
14209
15005
|
* Commits what a session left uncommitted. The repo's hooks run; when they
|
|
14210
|
-
* refuse, the error says why and the worktree keeps the changes.
|
|
15006
|
+
* refuse, the error says why and the worktree keeps the changes. A worktree
|
|
15007
|
+
* mid-rebase (or merge, cherry-pick, revert) is left exactly as it is: its
|
|
15008
|
+
* changes are the operation's to finish, as a conflict resolver's are.
|
|
14211
15009
|
*/
|
|
14212
15010
|
const commitLeftovers = async (worktree, message) => {
|
|
15011
|
+
if (await gitOperationUnderway(worktree)) return false;
|
|
14213
15012
|
const status = await gitOk(worktree, ["status", "--porcelain"]);
|
|
14214
15013
|
if (lines(status).length === 0) return false;
|
|
14215
15014
|
await gitOk(worktree, ["add", "-A"]);
|
|
@@ -14264,6 +15063,245 @@ const removeWorktree = async (root, worktree, force = false) => {
|
|
|
14264
15063
|
worktree
|
|
14265
15064
|
]);
|
|
14266
15065
|
};
|
|
15066
|
+
// ../core/src/worktree/list.ts
|
|
15067
|
+
/**
|
|
15068
|
+
* @file What `ork worktrees` shows: every worktree and branch Ork's runs
|
|
15069
|
+
* recorded creating, whatever the project's git rules named them, and any
|
|
15070
|
+
* `ork/*` branch besides, with their state read from git itself. What clean
|
|
15071
|
+
* and remove do with them is clean.ts.
|
|
15072
|
+
*/
|
|
15073
|
+
/**
|
|
15074
|
+
* The worktrees the project's runs recorded creating, oldest run first; one
|
|
15075
|
+
* entry per branch or folder, from the latest run that created it.
|
|
15076
|
+
*/
|
|
15077
|
+
const createdWorktrees = (store, project) => {
|
|
15078
|
+
const byName = /* @__PURE__ */ new Map();
|
|
15079
|
+
for (const run of store.runs({ project }).toReversed()) for (const { event } of store.events(run.id)) {
|
|
15080
|
+
if (event.type !== "worktree" || event.action !== "created") continue;
|
|
15081
|
+
byName.set(event.branch ?? event.path, {
|
|
15082
|
+
runId: run.id,
|
|
15083
|
+
nodeId: event.id,
|
|
15084
|
+
path: event.path,
|
|
15085
|
+
...event.branch === void 0 ? {} : { branch: event.branch }
|
|
15086
|
+
});
|
|
15087
|
+
}
|
|
15088
|
+
return [...byName.values()];
|
|
15089
|
+
};
|
|
15090
|
+
const parsePorcelain = (text) => text.split("\n\n").map((block) => lines(block)).map((fields) => {
|
|
15091
|
+
const value = (key) => fields.find((f) => f.startsWith(`${key} `))?.slice(key.length + 1);
|
|
15092
|
+
return {
|
|
15093
|
+
path: value("worktree") ?? "",
|
|
15094
|
+
branch: (value("branch") ?? "").replace(/^refs\/heads\//u, ""),
|
|
15095
|
+
prunable: fields.some((f) => f.startsWith("prunable"))
|
|
15096
|
+
};
|
|
15097
|
+
}).filter((entry) => entry.path !== "");
|
|
15098
|
+
/**
|
|
15099
|
+
* The `ork/<run>/<node>` branches of runs that recorded nothing; with no path
|
|
15100
|
+
* recorded, the worktree is whichever has the branch checked out.
|
|
15101
|
+
*/
|
|
15102
|
+
const prefixed = (branch) => {
|
|
15103
|
+
const [, runId = "", ...rest] = branch.split("/");
|
|
15104
|
+
return {
|
|
15105
|
+
runId,
|
|
15106
|
+
nodeId: rest.join("/"),
|
|
15107
|
+
path: "",
|
|
15108
|
+
branch
|
|
15109
|
+
};
|
|
15110
|
+
};
|
|
15111
|
+
/** How many commits a detached worktree holds that no branch, tag or remote has. */
|
|
15112
|
+
const onNoBranch = async (entry) => entry === void 0 ? "0" : (await git(entry.path, [
|
|
15113
|
+
"rev-list",
|
|
15114
|
+
"--count",
|
|
15115
|
+
"HEAD",
|
|
15116
|
+
"--not",
|
|
15117
|
+
"--branches",
|
|
15118
|
+
"--tags",
|
|
15119
|
+
"--remotes"
|
|
15120
|
+
])).stdout;
|
|
15121
|
+
/** One created worktree or branch, with its state read from git. */
|
|
15122
|
+
const stateOf = async (root, entries, item, recorded) => {
|
|
15123
|
+
const { branch } = item;
|
|
15124
|
+
const entry = branch === void 0 || item.path !== "" ? entries.find((e) => e.path === item.path) : entries.find((e) => e.branch === branch);
|
|
15125
|
+
const holder = branch === void 0 ? void 0 : entries.find((e) => e.branch === branch && e.path !== entry?.path);
|
|
15126
|
+
const missing = entry !== void 0 && (entry.prunable || !existsSync(entry.path));
|
|
15127
|
+
const status = entry === void 0 || missing ? "" : (await git(entry.path, ["status", "--porcelain"])).stdout;
|
|
15128
|
+
const checkedOut = branch === void 0 && entry !== void 0 && entry.branch !== "" ? entry.branch : void 0;
|
|
15129
|
+
const on = branch ?? checkedOut;
|
|
15130
|
+
const counted = on === void 0 ? await onNoBranch(entry === void 0 || missing ? void 0 : entry) : (await git(root, [
|
|
15131
|
+
"rev-list",
|
|
15132
|
+
"--count",
|
|
15133
|
+
`HEAD..${on}`
|
|
15134
|
+
])).stdout;
|
|
15135
|
+
const ahead = Number(counted);
|
|
15136
|
+
const changes = lines(status).length;
|
|
15137
|
+
return {
|
|
15138
|
+
...branch === void 0 ? {} : { branch },
|
|
15139
|
+
...checkedOut === void 0 ? {} : { checkedOut },
|
|
15140
|
+
runId: item.runId,
|
|
15141
|
+
nodeId: item.nodeId,
|
|
15142
|
+
recorded,
|
|
15143
|
+
path: entry?.path ?? null,
|
|
15144
|
+
missing,
|
|
15145
|
+
dirty: changes > 0,
|
|
15146
|
+
changes,
|
|
15147
|
+
ahead: Number.isNaN(ahead) ? 0 : ahead,
|
|
15148
|
+
merged: on !== void 0 && ahead === 0,
|
|
15149
|
+
inMainCheckout: entry?.path === root || holder?.path === root,
|
|
15150
|
+
...holder === void 0 || holder.path === root ? {} : { heldBy: holder.path }
|
|
15151
|
+
};
|
|
15152
|
+
};
|
|
15153
|
+
/**
|
|
15154
|
+
* Every worktree and branch Ork's runs created in the repository at `root`,
|
|
15155
|
+
* with its state: those the runs recorded (`created`, whatever the project's
|
|
15156
|
+
* git rules named them), and any `ork/` branch besides. A recorded one that git
|
|
15157
|
+
* no longer has is left out.
|
|
15158
|
+
*/
|
|
15159
|
+
const listOrkWorktrees = async (root, created = []) => {
|
|
15160
|
+
const entries = parsePorcelain(await gitOk(root, [
|
|
15161
|
+
"worktree",
|
|
15162
|
+
"list",
|
|
15163
|
+
"--porcelain"
|
|
15164
|
+
]));
|
|
15165
|
+
const branches = lines(await gitOk(root, [
|
|
15166
|
+
"for-each-ref",
|
|
15167
|
+
"--format=%(refname:short)",
|
|
15168
|
+
"refs/heads/"
|
|
15169
|
+
]));
|
|
15170
|
+
const recordedBranches = new Set(created.flatMap((c) => c.branch ?? []));
|
|
15171
|
+
const items = [...created.filter((c) => c.branch === void 0 ? entries.some((e) => e.path === c.path) : branches.includes(c.branch)).map((item) => ({
|
|
15172
|
+
item,
|
|
15173
|
+
recorded: true
|
|
15174
|
+
})), ...branches.filter((b) => b.startsWith("ork/") && !recordedBranches.has(b)).map((b) => ({
|
|
15175
|
+
item: prefixed(b),
|
|
15176
|
+
recorded: false
|
|
15177
|
+
}))];
|
|
15178
|
+
return Promise.all(items.map(({ item, recorded }) => stateOf(root, entries, item, recorded)));
|
|
15179
|
+
};
|
|
15180
|
+
// ../core/src/store/prune.ts
|
|
15181
|
+
/**
|
|
15182
|
+
* @file `ork prune` (ADR-046): which runs may lose their transcript, and doing
|
|
15183
|
+
* it. A run goes with the runs it called or was called by, or not at all, and
|
|
15184
|
+
* not while it is live, is linked, or has a worktree or branch left: those
|
|
15185
|
+
* read the run's events. The usage ledger and feedback records stay.
|
|
15186
|
+
*/
|
|
15187
|
+
const messageOf$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
15188
|
+
/**
|
|
15189
|
+
* The runs in a project that still have a worktree or branch. With its folder
|
|
15190
|
+
* gone, or no longer a repository, there is nothing left to clean up; any other
|
|
15191
|
+
* failure says nothing about them, so it rejects.
|
|
15192
|
+
*/
|
|
15193
|
+
const runsWithWorktrees = (store) => async (root) => {
|
|
15194
|
+
if (!existsSync(root)) return /* @__PURE__ */ new Set();
|
|
15195
|
+
try {
|
|
15196
|
+
const found = await listOrkWorktrees(root, createdWorktrees(store, root));
|
|
15197
|
+
return new Set(found.map((w) => w.runId));
|
|
15198
|
+
} catch (error) {
|
|
15199
|
+
if (/not a git repository/iu.test(messageOf$1(error))) return /* @__PURE__ */ new Set();
|
|
15200
|
+
throw error;
|
|
15201
|
+
}
|
|
15202
|
+
};
|
|
15203
|
+
/** The run at the top of a run's chain of calls. */
|
|
15204
|
+
const callRootOf = (run, byId) => {
|
|
15205
|
+
let top = run;
|
|
15206
|
+
const seen = /* @__PURE__ */ new Set([run.id]);
|
|
15207
|
+
while (top.parent !== void 0) {
|
|
15208
|
+
const parent = byId.get(top.parent);
|
|
15209
|
+
if (parent === void 0 || seen.has(parent.id)) break;
|
|
15210
|
+
seen.add(parent.id);
|
|
15211
|
+
top = parent;
|
|
15212
|
+
}
|
|
15213
|
+
return top.id;
|
|
15214
|
+
};
|
|
15215
|
+
/** Sorts the runs before the cutoff into those to remove and those to keep. */
|
|
15216
|
+
const planPrune = async (store, filter, worktreesOf = runsWithWorktrees(store)) => {
|
|
15217
|
+
const all = store.runs();
|
|
15218
|
+
const byId = new Map(all.map((run) => [run.id, run]));
|
|
15219
|
+
const inScope = (run) => filter.project === void 0 || run.project === filter.project;
|
|
15220
|
+
const old = (run) => (store.lastEventAt(run.id) ?? run.finishedAt ?? run.startedAt) < filter.before;
|
|
15221
|
+
const trees = /* @__PURE__ */ new Map();
|
|
15222
|
+
for (const run of all) {
|
|
15223
|
+
const root = callRootOf(run, byId);
|
|
15224
|
+
trees.set(root, [...trees.get(root) ?? [], run]);
|
|
15225
|
+
}
|
|
15226
|
+
const projects = [...new Set(all.filter((r) => old(r) && inScope(r)).map((r) => r.project))];
|
|
15227
|
+
const withWorktrees = new Map(await Promise.all(projects.map(async (project) => {
|
|
15228
|
+
try {
|
|
15229
|
+
return [project, await worktreesOf(project)];
|
|
15230
|
+
} catch (error) {
|
|
15231
|
+
return [project, messageOf$1(error)];
|
|
15232
|
+
}
|
|
15233
|
+
})));
|
|
15234
|
+
/** Why this run itself cannot go, or `null` when it can. */
|
|
15235
|
+
const blocker = (run) => {
|
|
15236
|
+
if (liveStatus(run) === "running") return "it is still running";
|
|
15237
|
+
const worktrees = withWorktrees.get(run.project);
|
|
15238
|
+
if (typeof worktrees === "string") return `cannot tell whether it has a worktree left: ${worktrees}`;
|
|
15239
|
+
if (worktrees?.has(run.id) === true) return "it has a worktree or branch left (ork worktrees clean)";
|
|
15240
|
+
if (store.links({
|
|
15241
|
+
run: run.id,
|
|
15242
|
+
live: true
|
|
15243
|
+
}).length > 0) return "it has a live link";
|
|
15244
|
+
return null;
|
|
15245
|
+
};
|
|
15246
|
+
const plan = {
|
|
15247
|
+
remove: [],
|
|
15248
|
+
keep: []
|
|
15249
|
+
};
|
|
15250
|
+
for (const tree of trees.values()) {
|
|
15251
|
+
const held = tree.map((run) => {
|
|
15252
|
+
if (!inScope(run)) return `run ${run.id} of its call is in another project (--all)`;
|
|
15253
|
+
if (!old(run)) return `run ${run.id} of its call is newer`;
|
|
15254
|
+
const why = blocker(run);
|
|
15255
|
+
return why === null ? null : `run ${run.id} of its call stays: ${why}`;
|
|
15256
|
+
}).find((why) => why !== null);
|
|
15257
|
+
for (const run of tree) {
|
|
15258
|
+
if (!inScope(run) || !old(run)) continue;
|
|
15259
|
+
if (held === void 0) plan.remove.push(run);
|
|
15260
|
+
else plan.keep.push({
|
|
15261
|
+
run,
|
|
15262
|
+
reason: blocker(run) ?? held
|
|
15263
|
+
});
|
|
15264
|
+
}
|
|
15265
|
+
}
|
|
15266
|
+
return plan;
|
|
15267
|
+
};
|
|
15268
|
+
const SAFE_ID = /^[\w-]+$/u;
|
|
15269
|
+
/** Forgets the plan's runs and removes their folders. */
|
|
15270
|
+
const applyPrune = (store, plan) => {
|
|
15271
|
+
const outcome = {
|
|
15272
|
+
removed: 0,
|
|
15273
|
+
folders: 0,
|
|
15274
|
+
failed: []
|
|
15275
|
+
};
|
|
15276
|
+
const places = store.projects();
|
|
15277
|
+
for (const run of plan.remove) {
|
|
15278
|
+
if (!store.deleteRun(run.id)) {
|
|
15279
|
+
outcome.failed.push({
|
|
15280
|
+
run: run.id,
|
|
15281
|
+
reason: "it is running again"
|
|
15282
|
+
});
|
|
15283
|
+
continue;
|
|
15284
|
+
}
|
|
15285
|
+
outcome.removed += 1;
|
|
15286
|
+
if (!SAFE_ID.test(run.id)) continue;
|
|
15287
|
+
const place = places.find((p) => p.root === run.project) ?? placeFor(run.project, places);
|
|
15288
|
+
const folder = runDirOf(place, run.id);
|
|
15289
|
+
if (!existsSync(folder)) continue;
|
|
15290
|
+
try {
|
|
15291
|
+
rmSync(folder, {
|
|
15292
|
+
recursive: true,
|
|
15293
|
+
force: true
|
|
15294
|
+
});
|
|
15295
|
+
outcome.folders += 1;
|
|
15296
|
+
} catch (error) {
|
|
15297
|
+
outcome.failed.push({
|
|
15298
|
+
run: run.id,
|
|
15299
|
+
reason: `its folder stays, at ${folder}: ${messageOf$1(error)}`
|
|
15300
|
+
});
|
|
15301
|
+
}
|
|
15302
|
+
}
|
|
15303
|
+
return outcome;
|
|
15304
|
+
};
|
|
14267
15305
|
// ../core/src/store/decisions.ts
|
|
14268
15306
|
const choicesOf = (json) => {
|
|
14269
15307
|
const parsed = JSON.parse(json);
|
|
@@ -14284,7 +15322,7 @@ const noteOf = (note) => {
|
|
|
14284
15322
|
return trimmed === "" ? null : maskSecrets(trimmed);
|
|
14285
15323
|
};
|
|
14286
15324
|
const kindOf = (value) => value === "permission" ? "permission" : "question";
|
|
14287
|
-
const statusOf
|
|
15325
|
+
const statusOf = (value) => value === "answered" || value === "withdrawn" ? value : "open";
|
|
14288
15326
|
const decisionOf = (row) => {
|
|
14289
15327
|
const record = {
|
|
14290
15328
|
id: text(row, "id"),
|
|
@@ -14294,7 +15332,7 @@ const decisionOf = (row) => {
|
|
|
14294
15332
|
question: text(row, "question"),
|
|
14295
15333
|
options: choicesOf(text(row, "options") || "[]"),
|
|
14296
15334
|
openedAt: text(row, "opened_at"),
|
|
14297
|
-
status: statusOf
|
|
15335
|
+
status: statusOf(text(row, "status"))
|
|
14298
15336
|
};
|
|
14299
15337
|
const optional = {
|
|
14300
15338
|
recommended: text(row, "recommended"),
|
|
@@ -14391,7 +15429,7 @@ const decisionQueries = (db) => {
|
|
|
14391
15429
|
};
|
|
14392
15430
|
};
|
|
14393
15431
|
// ../core/src/store/feedback.ts
|
|
14394
|
-
const STATUSES
|
|
15432
|
+
const STATUSES = [
|
|
14395
15433
|
"new",
|
|
14396
15434
|
"triaged",
|
|
14397
15435
|
"accepted",
|
|
@@ -14419,7 +15457,7 @@ const ORIGIN_COLUMNS = [
|
|
|
14419
15457
|
["driver", "driver"],
|
|
14420
15458
|
["version", "version"]
|
|
14421
15459
|
];
|
|
14422
|
-
const recordOf$
|
|
15460
|
+
const recordOf$2 = (row) => {
|
|
14423
15461
|
const origin = {};
|
|
14424
15462
|
for (const [key, column] of ORIGIN_COLUMNS) {
|
|
14425
15463
|
const value = key === "driver" ? currentDriver(text(row, column)) : text(row, column);
|
|
@@ -14455,7 +15493,7 @@ const itemOf = (row) => {
|
|
|
14455
15493
|
return {
|
|
14456
15494
|
id,
|
|
14457
15495
|
merged: strings(row, "fingerprints").filter((f) => f !== id),
|
|
14458
|
-
status: oneOf(STATUSES
|
|
15496
|
+
status: oneOf(STATUSES, text(row, "status"), "new"),
|
|
14459
15497
|
title: text(row, "title"),
|
|
14460
15498
|
target: oneOf(FEEDBACK_TARGETS, text(row, "target"), "workflow"),
|
|
14461
15499
|
kind: oneOf(FEEDBACK_KINDS, text(row, "kind"), "idea"),
|
|
@@ -14520,7 +15558,7 @@ const feedbackQueries = (db) => {
|
|
|
14520
15558
|
insertFeedback: insertRecord,
|
|
14521
15559
|
feedbackItems: (filter = {}) => (filter.all === true ? every : shown).all().map((r) => itemOf(r)),
|
|
14522
15560
|
findFeedback: (prefix) => byPrefix.all(prefix.length, prefix).map((r) => itemOf(r)),
|
|
14523
|
-
feedbackRecords: (fingerprint) => records.all(fingerprint, fingerprint).map((r) => recordOf$
|
|
15561
|
+
feedbackRecords: (fingerprint) => records.all(fingerprint, fingerprint).map((r) => recordOf$2(r)),
|
|
14524
15562
|
setFeedbackStatus: (fingerprint, status) => Number(setStatus.run(status, (/* @__PURE__ */ new Date()).toISOString(), fingerprint).changes) > 0,
|
|
14525
15563
|
mergeFeedback: (into, from) => {
|
|
14526
15564
|
const root = text(rootOf.get(into), "root");
|
|
@@ -14529,7 +15567,7 @@ const feedbackQueries = (db) => {
|
|
|
14529
15567
|
return inTransaction(db, () => from.filter((fingerprint) => fingerprint !== root).reduce((moved, fingerprint) => moved + Number(mergeInto.run(root, at, fingerprint, fingerprint, root).changes), 0));
|
|
14530
15568
|
},
|
|
14531
15569
|
splitFeedback: (fingerprint) => Number(split.run((/* @__PURE__ */ new Date()).toISOString(), fingerprint).changes),
|
|
14532
|
-
exportFeedback: (filter = {}) => exported.all(filter.all === true ? 1 : 0).map((r) => recordOf$
|
|
15570
|
+
exportFeedback: (filter = {}) => exported.all(filter.all === true ? 1 : 0).map((r) => recordOf$2(r)),
|
|
14533
15571
|
importFeedback: (record) => {
|
|
14534
15572
|
if (known.get(record.runId, record.seq) !== void 0) return false;
|
|
14535
15573
|
insertRecord(record.runId, record.seq, record.at, record.node, record.feedback);
|
|
@@ -14618,7 +15656,7 @@ const planLimitQueries = (db) => {
|
|
|
14618
15656
|
* `~/.ork/projects/<id>/` when attached), and moving it from one to the other
|
|
14619
15657
|
* (`ork migrate`) with its runs' workflow files.
|
|
14620
15658
|
*/
|
|
14621
|
-
const recordOf$
|
|
15659
|
+
const recordOf$1 = (row) => ({
|
|
14622
15660
|
id: text(row, "id"),
|
|
14623
15661
|
root: text(row, "root"),
|
|
14624
15662
|
mode: PROJECT_MODES.find((m) => m === text(row, "mode")) ?? "in-repo",
|
|
@@ -14639,7 +15677,7 @@ const projectQueries = (db) => {
|
|
|
14639
15677
|
const refile = db.prepare(`UPDATE runs SET file = ? || substr(file, ?)
|
|
14640
15678
|
WHERE project = ? AND substr(file, 1, ?) = ?`);
|
|
14641
15679
|
return {
|
|
14642
|
-
projects: () => all.all().map((r) => recordOf$
|
|
15680
|
+
projects: () => all.all().map((r) => recordOf$1(r)),
|
|
14643
15681
|
addProject: (project) => {
|
|
14644
15682
|
const record = {
|
|
14645
15683
|
...project,
|
|
@@ -14655,7 +15693,7 @@ const projectQueries = (db) => {
|
|
|
14655
15693
|
return Number(remove.run(id).changes) > 0;
|
|
14656
15694
|
},
|
|
14657
15695
|
moveProject: (id, place) => {
|
|
14658
|
-
const before = all.all().map((r) => recordOf$
|
|
15696
|
+
const before = all.all().map((r) => recordOf$1(r)).find((p) => p.id === id);
|
|
14659
15697
|
if (before === void 0) return false;
|
|
14660
15698
|
const from = `${before.dir}${path.sep}`;
|
|
14661
15699
|
const root = place.root ?? before.root;
|
|
@@ -14751,7 +15789,7 @@ const toolCpuQueries = (db) => ({
|
|
|
14751
15789
|
}
|
|
14752
15790
|
});
|
|
14753
15791
|
// ../core/src/store/triggers.ts
|
|
14754
|
-
const recordOf
|
|
15792
|
+
const recordOf = (row) => {
|
|
14755
15793
|
const acceptedHash = text(row, "accepted_hash");
|
|
14756
15794
|
const lookedAt = text(row, "looked_at");
|
|
14757
15795
|
const error = text(row, "error");
|
|
@@ -14793,7 +15831,7 @@ const triggerQueries = (db) => {
|
|
|
14793
15831
|
ON CONFLICT (id) DO UPDATE SET pid = excluded.pid, at = excluded.at`);
|
|
14794
15832
|
const letGo = db.prepare("DELETE FROM watch_lock WHERE pid = ?");
|
|
14795
15833
|
return {
|
|
14796
|
-
triggerRecords: (project) => (project === void 0 ? all.all() : ofProject.all(project)).map((row) => recordOf
|
|
15834
|
+
triggerRecords: (project) => (project === void 0 ? all.all() : ofProject.all(project)).map((row) => recordOf(row)),
|
|
14797
15835
|
acceptTrigger: (project, trigger, hash) => {
|
|
14798
15836
|
accept.run(project, trigger, hash);
|
|
14799
15837
|
},
|
|
@@ -15091,40 +16129,6 @@ const migrate = (db, migrations = MIGRATIONS) => {
|
|
|
15091
16129
|
*/
|
|
15092
16130
|
/** `~/.ork/state.db`; `home` is `~` (tests pass a folder). */
|
|
15093
16131
|
const storePath = (home = homedir()) => path.join(home, ".ork", "state.db");
|
|
15094
|
-
const STATUSES = [
|
|
15095
|
-
"running",
|
|
15096
|
-
"succeeded",
|
|
15097
|
-
"failed",
|
|
15098
|
-
"budget_exhausted",
|
|
15099
|
-
"quota_exhausted",
|
|
15100
|
-
"timed_out",
|
|
15101
|
-
"interrupted"
|
|
15102
|
-
];
|
|
15103
|
-
const statusOf = (value) => STATUSES.find((s) => s === value) ?? "failed";
|
|
15104
|
-
const recordOf = (row) => {
|
|
15105
|
-
const record = {
|
|
15106
|
-
id: text(row, "id"),
|
|
15107
|
-
workflow: text(row, "workflow"),
|
|
15108
|
-
file: text(row, "file"),
|
|
15109
|
-
project: text(row, "project"),
|
|
15110
|
-
status: statusOf(text(row, "status")),
|
|
15111
|
-
pid: Number(row?.["pid"]),
|
|
15112
|
-
startedAt: text(row, "started_at"),
|
|
15113
|
-
costUsd: Number(row?.["cost_usd"]),
|
|
15114
|
-
depth: Number(row?.["depth"] ?? 0)
|
|
15115
|
-
};
|
|
15116
|
-
const processStart = text(row, "process_start");
|
|
15117
|
-
if (processStart !== "") record.processStart = processStart;
|
|
15118
|
-
const parent = text(row, "parent");
|
|
15119
|
-
if (parent !== "") record.parent = parent;
|
|
15120
|
-
const finished = text(row, "finished_at");
|
|
15121
|
-
if (finished !== "") record.finishedAt = finished;
|
|
15122
|
-
const skipped = row?.["skipped"];
|
|
15123
|
-
if (typeof skipped === "number") record.skipped = skipped;
|
|
15124
|
-
const handled = row?.["failures_handled"];
|
|
15125
|
-
if (typeof handled === "number") record.failuresHandled = handled;
|
|
15126
|
-
return record;
|
|
15127
|
-
};
|
|
15128
16132
|
/** The node an event is about, for filtering. */
|
|
15129
16133
|
const nodeOf = (event) => {
|
|
15130
16134
|
if ("id" in event && typeof event.id === "string") return event.id;
|
|
@@ -15177,10 +16181,10 @@ const openStore = (file = storePath(), options = {}) => {
|
|
|
15177
16181
|
const finishRun = db.prepare("UPDATE runs SET status = ?, finished_at = ?, cost_usd = ?, skipped = ?, failures_handled = ? WHERE id = ?");
|
|
15178
16182
|
const reopenRun = db.prepare("UPDATE runs SET status = 'running', pid = ?, process_start = ?, finished_at = NULL, skipped = NULL, failures_handled = NULL WHERE id = ?");
|
|
15179
16183
|
const addCost = db.prepare("UPDATE runs SET cost_usd = cost_usd + ? WHERE id = ?");
|
|
15180
|
-
const byId = db.prepare(
|
|
15181
|
-
const byPrefix = db.prepare(
|
|
15182
|
-
const allRuns = db.prepare(
|
|
15183
|
-
const projectRuns = db.prepare(
|
|
16184
|
+
const byId = db.prepare(`SELECT ${RUN_COLUMNS} FROM runs WHERE id = ?`);
|
|
16185
|
+
const byPrefix = db.prepare(`SELECT ${RUN_COLUMNS} FROM runs WHERE substr(id, 1, ?) = ? ORDER BY started_at DESC, rowid DESC`);
|
|
16186
|
+
const allRuns = db.prepare(`SELECT ${RUN_COLUMNS} FROM runs ORDER BY started_at DESC, rowid DESC LIMIT ?`);
|
|
16187
|
+
const projectRuns = db.prepare(`SELECT ${RUN_COLUMNS} FROM runs WHERE project = ? ORDER BY started_at DESC, rowid DESC LIMIT ?`);
|
|
15184
16188
|
const eventsAfter = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND seq > ? ORDER BY seq");
|
|
15185
16189
|
const controlEvents = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND type IN ('run.command', 'run.started', 'run.finished') ORDER BY seq");
|
|
15186
16190
|
const runNodes = db.prepare("SELECT node FROM events WHERE run_id = ? AND node IS NOT NULL GROUP BY node ORDER BY MIN(seq)");
|
|
@@ -15227,12 +16231,12 @@ const openStore = (file = storePath(), options = {}) => {
|
|
|
15227
16231
|
}),
|
|
15228
16232
|
runs: (filter = {}) => {
|
|
15229
16233
|
const limit = filter.limit ?? -1;
|
|
15230
|
-
return (filter.project === void 0 ? allRuns.all(limit) : projectRuns.all(filter.project, limit)).map((r) => recordOf(r));
|
|
16234
|
+
return (filter.project === void 0 ? allRuns.all(limit) : projectRuns.all(filter.project, limit)).map((r) => recordOf$3(r));
|
|
15231
16235
|
},
|
|
15232
16236
|
lookup: (prefix) => {
|
|
15233
16237
|
const exact = byId.get(prefix);
|
|
15234
|
-
if (exact !== void 0) return [recordOf(exact)];
|
|
15235
|
-
return byPrefix.all(prefix.length, prefix).map((r) => recordOf(r));
|
|
16238
|
+
if (exact !== void 0) return [recordOf$3(exact)];
|
|
16239
|
+
return byPrefix.all(prefix.length, prefix).map((r) => recordOf$3(r));
|
|
15236
16240
|
},
|
|
15237
16241
|
events: (runId, after = 0) => eventsAfter.all(runId, after).map((r) => ({
|
|
15238
16242
|
runId,
|
|
@@ -15306,6 +16310,8 @@ const runActivity = (stored) => {
|
|
|
15306
16310
|
if (event.event.type === "tool.called") of(event.id).now = toolLine(event.event);
|
|
15307
16311
|
} else if (event.type === "slot.waiting") of(event.id).now = oneLine(slotWaitLine(event.holders));
|
|
15308
16312
|
else if (event.type === "slot.taken") delete of(event.id).now;
|
|
16313
|
+
else if (event.type === "lock.waiting") of(event.id).now = oneLine(lockWaitLine(event.lock, event.holders));
|
|
16314
|
+
else if (event.type === "lock.taken") delete of(event.id).now;
|
|
15309
16315
|
else if (event.type === "decision.opened") of(event.node).now = oneLine(`Asked: ${event.question}`);
|
|
15310
16316
|
else if (event.type === "decision.answered") of(event.node).now = oneLine(`Answered: ${event.answer}`);
|
|
15311
16317
|
else if (event.type === "decision.withdrawn") delete of(event.node).now;
|
|
@@ -16017,6 +17023,238 @@ const capsHere = (home, place) => {
|
|
|
16017
17023
|
};
|
|
16018
17024
|
}
|
|
16019
17025
|
};
|
|
17026
|
+
// ../core/src/usage/report.ts
|
|
17027
|
+
const USAGE_DIMENSIONS = [
|
|
17028
|
+
"day",
|
|
17029
|
+
"week",
|
|
17030
|
+
"month",
|
|
17031
|
+
"workflow",
|
|
17032
|
+
"node",
|
|
17033
|
+
"model",
|
|
17034
|
+
"driver",
|
|
17035
|
+
"project"
|
|
17036
|
+
];
|
|
17037
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
17038
|
+
/** `2026-11-03`, in the machine's own time zone. */
|
|
17039
|
+
const dayOf = (date) => `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
17040
|
+
/** The Monday of a time's week. */
|
|
17041
|
+
const weekOf = (date) => {
|
|
17042
|
+
const monday = new Date(date);
|
|
17043
|
+
monday.setDate(date.getDate() - (date.getDay() + 6) % 7);
|
|
17044
|
+
return dayOf(monday);
|
|
17045
|
+
};
|
|
17046
|
+
const KEYS = {
|
|
17047
|
+
day: (row) => dayOf(new Date(row.at)),
|
|
17048
|
+
week: (row) => weekOf(new Date(row.at)),
|
|
17049
|
+
month: (row) => dayOf(new Date(row.at)).slice(0, 7),
|
|
17050
|
+
workflow: (row) => row.workflow,
|
|
17051
|
+
node: (row) => `${row.workflow}/${row.template ?? row.node}`,
|
|
17052
|
+
model: (row) => row.model ?? "unknown",
|
|
17053
|
+
driver: (row) => row.driver ?? "unknown",
|
|
17054
|
+
project: (row) => row.project
|
|
17055
|
+
};
|
|
17056
|
+
const TIMES = /* @__PURE__ */ new Set([
|
|
17057
|
+
"day",
|
|
17058
|
+
"week",
|
|
17059
|
+
"month"
|
|
17060
|
+
]);
|
|
17061
|
+
/** Dimensions a tool command has no value in. */
|
|
17062
|
+
const SESSION_ONLY = /* @__PURE__ */ new Set(["model", "driver"]);
|
|
17063
|
+
const totalsOf = (rows, cpu) => {
|
|
17064
|
+
const totals = {
|
|
17065
|
+
costUsd: 0,
|
|
17066
|
+
tokens: 0,
|
|
17067
|
+
partial: false,
|
|
17068
|
+
inputTokens: 0,
|
|
17069
|
+
outputTokens: 0,
|
|
17070
|
+
cacheReadTokens: 0,
|
|
17071
|
+
cacheWriteTokens: 0,
|
|
17072
|
+
turns: 0,
|
|
17073
|
+
durationMs: 0,
|
|
17074
|
+
sessions: rows.length,
|
|
17075
|
+
runs: new Set(rows.map((row) => row.runId)).size,
|
|
17076
|
+
failedUsd: 0,
|
|
17077
|
+
reworkUsd: 0,
|
|
17078
|
+
chatUsd: 0,
|
|
17079
|
+
cacheHitRate: 0,
|
|
17080
|
+
cpuMs: cpu.reduce((sum, row) => sum + row.userMs + row.systemMs, 0)
|
|
17081
|
+
};
|
|
17082
|
+
for (const row of rows) {
|
|
17083
|
+
totals.costUsd += row.costUsd;
|
|
17084
|
+
totals.tokens += countedTokens(row);
|
|
17085
|
+
totals.partial ||= unpriced(row.driver);
|
|
17086
|
+
totals.inputTokens += row.inputTokens;
|
|
17087
|
+
totals.outputTokens += row.outputTokens;
|
|
17088
|
+
totals.cacheReadTokens += row.cacheReadTokens;
|
|
17089
|
+
totals.cacheWriteTokens += row.cacheWriteTokens;
|
|
17090
|
+
totals.turns += row.turns;
|
|
17091
|
+
totals.durationMs += row.durationMs;
|
|
17092
|
+
if (row.failed) totals.failedUsd += row.costUsd;
|
|
17093
|
+
if (row.kind === "chat") totals.chatUsd += row.costUsd;
|
|
17094
|
+
else if (row.round > 1) totals.reworkUsd += row.costUsd;
|
|
17095
|
+
}
|
|
17096
|
+
const read = totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens;
|
|
17097
|
+
totals.cacheHitRate = read === 0 ? 0 : totals.cacheReadTokens / read;
|
|
17098
|
+
return totals;
|
|
17099
|
+
};
|
|
17100
|
+
const groupBy = (rows, cpu, dimension) => {
|
|
17101
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
17102
|
+
const bucketOf = (row) => {
|
|
17103
|
+
const key = KEYS[dimension](row);
|
|
17104
|
+
const found = buckets.get(key);
|
|
17105
|
+
if (found !== void 0) return found;
|
|
17106
|
+
const fresh = {
|
|
17107
|
+
rows: [],
|
|
17108
|
+
cpu: []
|
|
17109
|
+
};
|
|
17110
|
+
buckets.set(key, fresh);
|
|
17111
|
+
return fresh;
|
|
17112
|
+
};
|
|
17113
|
+
for (const row of rows) bucketOf(row).rows.push(row);
|
|
17114
|
+
if (!SESSION_ONLY.has(dimension)) for (const row of cpu) bucketOf(row).cpu.push(row);
|
|
17115
|
+
const groups = [...buckets].map(([key, bucket]) => Object.assign(totalsOf(bucket.rows, bucket.cpu), { key }));
|
|
17116
|
+
return TIMES.has(dimension) ? groups.toSorted((a, b) => a.key.localeCompare(b.key)) : groups.toSorted((a, b) => b.costUsd - a.costUsd);
|
|
17117
|
+
};
|
|
17118
|
+
/** Below this, a percentage says nothing worth acting on. */
|
|
17119
|
+
const MIN_SPEND_USD = .5;
|
|
17120
|
+
const REWORK_SHARE = .2;
|
|
17121
|
+
const FAILED_SHARE = .1;
|
|
17122
|
+
const CONCENTRATED_SHARE = .5;
|
|
17123
|
+
const LOW_CACHE_RATE = .5;
|
|
17124
|
+
const MIN_CACHE_TOKENS = 1e5;
|
|
17125
|
+
const percent = (share) => `${String(Math.round(share * 100))}%`;
|
|
17126
|
+
const hintsOf = (totals, nodes) => {
|
|
17127
|
+
const spend = totals.costUsd;
|
|
17128
|
+
if (spend < MIN_SPEND_USD) return [];
|
|
17129
|
+
const hints = [];
|
|
17130
|
+
if (totals.reworkUsd / spend >= REWORK_SHARE) hints.push({
|
|
17131
|
+
code: "rework",
|
|
17132
|
+
message: `${percent(totals.reworkUsd / spend)} of the spend went to loop rounds after the first. Sharper briefs or an earlier check can stop work coming back.`
|
|
17133
|
+
});
|
|
17134
|
+
if (totals.failedUsd / spend >= FAILED_SHARE) hints.push({
|
|
17135
|
+
code: "failed",
|
|
17136
|
+
message: `${percent(totals.failedUsd / spend)} of the spend went to sessions that failed. A cheap check before them, or a lower budget per session, can stop them sooner.`
|
|
17137
|
+
});
|
|
17138
|
+
const top = nodes[0];
|
|
17139
|
+
if (top !== void 0 && nodes.length > 1 && top.costUsd / spend >= CONCENTRATED_SHARE) hints.push({
|
|
17140
|
+
code: "concentrated",
|
|
17141
|
+
message: `${top.key} took ${percent(top.costUsd / spend)} of the spend. It is the first place to try a smaller model, a lower effort or a tighter brief.`
|
|
17142
|
+
});
|
|
17143
|
+
if (totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens >= MIN_CACHE_TOKENS && totals.cacheHitRate < LOW_CACHE_RATE) hints.push({
|
|
17144
|
+
code: "cache",
|
|
17145
|
+
message: `Only ${percent(totals.cacheHitRate)} of input tokens came from the prompt cache. Long runs of fresh sessions cost more than resumed ones.`
|
|
17146
|
+
});
|
|
17147
|
+
return hints;
|
|
17148
|
+
};
|
|
17149
|
+
/** Everything a report shows, from the ledger's rows and tool commands' CPU. */
|
|
17150
|
+
const usageReport = (rows, cpu = []) => {
|
|
17151
|
+
const totals = totalsOf(rows, cpu);
|
|
17152
|
+
const by = (dimension) => groupBy(rows, cpu, dimension);
|
|
17153
|
+
const groups = {
|
|
17154
|
+
day: by("day"),
|
|
17155
|
+
week: by("week"),
|
|
17156
|
+
month: by("month"),
|
|
17157
|
+
workflow: by("workflow"),
|
|
17158
|
+
node: by("node"),
|
|
17159
|
+
model: by("model"),
|
|
17160
|
+
driver: by("driver"),
|
|
17161
|
+
project: by("project")
|
|
17162
|
+
};
|
|
17163
|
+
return {
|
|
17164
|
+
totals,
|
|
17165
|
+
groups,
|
|
17166
|
+
hints: hintsOf(totals, groups.node)
|
|
17167
|
+
};
|
|
17168
|
+
};
|
|
17169
|
+
// ../core/src/worktree/clean.ts
|
|
17170
|
+
/**
|
|
17171
|
+
* @file What `ork worktrees clean` and `ork worktrees remove` do with what
|
|
17172
|
+
* list.ts reads: the plan (what goes, what's kept and why), by one rule for
|
|
17173
|
+
* every caller, and carrying it out. The CLI and the Worktrees panel both
|
|
17174
|
+
* plan here, so neither removes what the other would keep.
|
|
17175
|
+
*/
|
|
17176
|
+
/** What names an item for `remove`: its branch, or its worktree's path. */
|
|
17177
|
+
const keyOf = (item) => item.branch ?? item.path ?? `${item.runId}/${item.nodeId}`;
|
|
17178
|
+
/** The reasons no `force` overrides: removing would break something. */
|
|
17179
|
+
const MAIN_CHECKOUT = "checked out in the main checkout";
|
|
17180
|
+
const RUN_GOING = "its run is still going";
|
|
17181
|
+
const NOT_ORKS = "not created by Ork";
|
|
17182
|
+
const kept = (item, skip) => ({
|
|
17183
|
+
item,
|
|
17184
|
+
removeWorktree: false,
|
|
17185
|
+
deleteBranch: false,
|
|
17186
|
+
skip
|
|
17187
|
+
});
|
|
17188
|
+
/** What clean does with one item. */
|
|
17189
|
+
const cleanStep = (item, options) => {
|
|
17190
|
+
if (!item.recorded) return kept(item, NOT_ORKS);
|
|
17191
|
+
if (item.inMainCheckout) return kept(item, MAIN_CHECKOUT);
|
|
17192
|
+
if (item.heldBy !== void 0) return kept(item, `checked out in ${item.heldBy}`);
|
|
17193
|
+
if (options.live?.has(item.runId) === true) return kept(item, RUN_GOING);
|
|
17194
|
+
if (item.dirty && !options.force) return kept(item, "uncommitted changes");
|
|
17195
|
+
if (item.branch === void 0 && item.checkedOut === void 0 && item.ahead > 0 && !options.force) return kept(item, "commits on no branch");
|
|
17196
|
+
const removable = item.path !== null && !item.missing;
|
|
17197
|
+
if (options.branches && item.branch !== void 0 && !item.merged && !options.force) return {
|
|
17198
|
+
item,
|
|
17199
|
+
removeWorktree: removable,
|
|
17200
|
+
deleteBranch: false,
|
|
17201
|
+
skip: "branch not merged"
|
|
17202
|
+
};
|
|
17203
|
+
return {
|
|
17204
|
+
item,
|
|
17205
|
+
removeWorktree: removable,
|
|
17206
|
+
deleteBranch: options.branches && item.branch !== void 0
|
|
17207
|
+
};
|
|
17208
|
+
};
|
|
17209
|
+
/** Whether a step drops a worktree whose folder is gone, by its own path. */
|
|
17210
|
+
const stepPrunes = (step) => step.item.missing && step.item.path !== null && step.skip === void 0;
|
|
17211
|
+
/** Whether a step does anything. */
|
|
17212
|
+
const stepActs = (step) => step.removeWorktree || step.deleteBranch || stepPrunes(step);
|
|
17213
|
+
/**
|
|
17214
|
+
* What `ork worktrees clean` would do. A worktree with uncommitted changes, an
|
|
17215
|
+
* unmerged branch, or a detached worktree holding commits no branch has, is
|
|
17216
|
+
* kept unless `force`; one checked out in the main checkout or in a worktree
|
|
17217
|
+
* Ork didn't create, whose run is still going, or an `ork/` branch no run
|
|
17218
|
+
* recorded creating, is kept even then.
|
|
17219
|
+
*/
|
|
17220
|
+
const planClean = (items, filter, options) => items.filter((item) => filter.runId === void 0 || item.runId === filter.runId).filter((item) => filter.key === void 0 || keyOf(item) === filter.key).filter((item) => !filter.merged || item.merged).map((item) => cleanStep(item, options));
|
|
17221
|
+
/**
|
|
17222
|
+
* Carries out one step; what it did goes into `done` as it goes. It touches
|
|
17223
|
+
* only its own item: a missing worktree is dropped by its own path (`git
|
|
17224
|
+
* worktree remove`), never by `git worktree prune`, which would drop every
|
|
17225
|
+
* missing worktree in the repository, the user's own included.
|
|
17226
|
+
*/
|
|
17227
|
+
const applyStep = async (root, step, force, done) => {
|
|
17228
|
+
const { item } = step;
|
|
17229
|
+
if (stepPrunes(step) && !step.removeWorktree && item.path !== null) {
|
|
17230
|
+
await gitOk(root, [
|
|
17231
|
+
"worktree",
|
|
17232
|
+
"remove",
|
|
17233
|
+
item.path
|
|
17234
|
+
]);
|
|
17235
|
+
done.push(`pruned ${item.path}`);
|
|
17236
|
+
}
|
|
17237
|
+
if (step.removeWorktree && item.path !== null) {
|
|
17238
|
+
await removeWorktree(root, item.path, force);
|
|
17239
|
+
done.push(`removed ${item.path}`);
|
|
17240
|
+
}
|
|
17241
|
+
if (step.deleteBranch && item.branch !== void 0) {
|
|
17242
|
+
await gitOk(root, [
|
|
17243
|
+
"branch",
|
|
17244
|
+
force ? "-D" : "-d",
|
|
17245
|
+
item.branch
|
|
17246
|
+
]);
|
|
17247
|
+
done.push(`deleted ${item.branch}`);
|
|
17248
|
+
}
|
|
17249
|
+
};
|
|
17250
|
+
/**
|
|
17251
|
+
* Carries out a clean plan, step by step. What it did goes into `done` as it
|
|
17252
|
+
* goes, so a caller still has it when git fails halfway.
|
|
17253
|
+
*/
|
|
17254
|
+
const applyClean = async (root, steps, force, done = []) => {
|
|
17255
|
+
for (const step of steps) await applyStep(root, step, force, done);
|
|
17256
|
+
return done;
|
|
17257
|
+
};
|
|
16020
17258
|
// ../core/src/worktree/names.ts
|
|
16021
17259
|
/**
|
|
16022
17260
|
* @file The names Ork gives a node's worktree and branch, from the project's
|
|
@@ -16147,6 +17385,203 @@ const syncFrom = async (worktree, from) => {
|
|
|
16147
17385
|
if (!applied.ok) throw new Error(`could not take ${from}'s changes: ${applied.error}`);
|
|
16148
17386
|
return snapshot(worktree);
|
|
16149
17387
|
};
|
|
17388
|
+
// ../core/src/worktree/words.ts
|
|
17389
|
+
/**
|
|
17390
|
+
* @file The plain words for what `ork worktrees` shows, in one place, so the
|
|
17391
|
+
* CLI and the Worktrees panel say the same thing: an item's name, its merge
|
|
17392
|
+
* state, its state as parts (each marked when it's work that removing would
|
|
17393
|
+
* lose), a plan's line, and why an item can't be removed. The CLI colors
|
|
17394
|
+
* them; the panel lays them out.
|
|
17395
|
+
*/
|
|
17396
|
+
/** An item's name: its branch, or its run and node when created detached. */
|
|
17397
|
+
const labelOf = (item) => item.branch ?? `${item.runId}/${item.nodeId}`;
|
|
17398
|
+
/** Merged into the current branch, not, or detached on no branch at all. */
|
|
17399
|
+
const mergeStateOf = (item) => {
|
|
17400
|
+
if (item.branch === void 0 && item.checkedOut === void 0) return "detached";
|
|
17401
|
+
return item.merged ? "merged" : "unmerged";
|
|
17402
|
+
};
|
|
17403
|
+
/** `3 commits`, `1 commit`. */
|
|
17404
|
+
const counted = (count, noun) => `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
|
|
17405
|
+
/**
|
|
17406
|
+
* An item's state, in order: where its worktree is when it isn't plain, the
|
|
17407
|
+
* workflow's own branch, uncommitted changes, unmerged or unreachable commits,
|
|
17408
|
+
* and the main checkout or another worktree holding the branch. `into` names
|
|
17409
|
+
* the branch "not merged" is measured against, when the words should say it.
|
|
17410
|
+
*/
|
|
17411
|
+
const stateParts = (item, into) => {
|
|
17412
|
+
const parts = [];
|
|
17413
|
+
const part = (text, risk = false) => {
|
|
17414
|
+
parts.push({
|
|
17415
|
+
text,
|
|
17416
|
+
risk
|
|
17417
|
+
});
|
|
17418
|
+
};
|
|
17419
|
+
if (item.path === null) part("no worktree");
|
|
17420
|
+
else if (item.missing) part("missing (prunable)");
|
|
17421
|
+
if (item.checkedOut !== void 0) part(`on the workflow's branch ${item.checkedOut}`);
|
|
17422
|
+
if (item.dirty) part("uncommitted changes", true);
|
|
17423
|
+
const state = mergeStateOf(item);
|
|
17424
|
+
if (state === "unmerged") {
|
|
17425
|
+
const where = into === void 0 ? "" : ` into ${into}`;
|
|
17426
|
+
part(`${counted(item.ahead, "commit")} not merged${where}`);
|
|
17427
|
+
}
|
|
17428
|
+
if (state === "detached") {
|
|
17429
|
+
if (item.ahead > 0) part(`${counted(item.ahead, "commit")} on no branch`, true);
|
|
17430
|
+
else if (item.path !== null && !item.missing) part("nothing committed");
|
|
17431
|
+
}
|
|
17432
|
+
if (!item.recorded) part("not created by Ork");
|
|
17433
|
+
if (item.inMainCheckout) part("checked out in the main checkout");
|
|
17434
|
+
if (item.heldBy !== void 0) part(`checked out in ${item.heldBy}`);
|
|
17435
|
+
return parts;
|
|
17436
|
+
};
|
|
17437
|
+
/** What a step does, as a plan says it. */
|
|
17438
|
+
const planWhat = (step) => {
|
|
17439
|
+
const actions = [
|
|
17440
|
+
...step.removeWorktree ? ["remove worktree"] : [],
|
|
17441
|
+
...stepPrunes(step) && !step.removeWorktree ? ["prune worktree"] : [],
|
|
17442
|
+
...step.deleteBranch ? ["delete branch"] : []
|
|
17443
|
+
];
|
|
17444
|
+
return actions.length > 0 ? actions.join(" + ") : "keep";
|
|
17445
|
+
};
|
|
17446
|
+
/** `ork/r1/lead: remove worktree`, `r1/check (detached): keep (…)`. */
|
|
17447
|
+
const planLine = (step) => {
|
|
17448
|
+
const { item } = step;
|
|
17449
|
+
return {
|
|
17450
|
+
text: `${item.branch === void 0 && item.checkedOut === void 0 ? `${labelOf(item)} (detached)` : labelOf(item)}: ${planWhat(step)}`,
|
|
17451
|
+
...step.skip === void 0 ? {} : { reason: step.skip }
|
|
17452
|
+
};
|
|
17453
|
+
};
|
|
17454
|
+
/**
|
|
17455
|
+
* Why an item can't be removed at all, even forced, if it can't: no run
|
|
17456
|
+
* recorded creating it, the main checkout or a worktree Ork didn't create holds
|
|
17457
|
+
* it, or its run is still going.
|
|
17458
|
+
*/
|
|
17459
|
+
const removeRefusal = (item, live) => {
|
|
17460
|
+
if (!item.recorded) return "not created by Ork (no run recorded it); delete it with git if it's yours to delete";
|
|
17461
|
+
if (item.inMainCheckout) return "checked out in the main checkout; switch it to another branch first";
|
|
17462
|
+
if (item.heldBy !== void 0) return `checked out in ${item.heldBy}, a worktree Ork didn't create; switch it to another branch first`;
|
|
17463
|
+
return live ? `run ${item.runId} is still going; stop it first` : void 0;
|
|
17464
|
+
};
|
|
17465
|
+
// ../core/src/worktree/remove.ts
|
|
17466
|
+
/** The ids of the project's runs whose process is still going. */
|
|
17467
|
+
const liveRunIds = (store, root) => new Set(store.runs({ project: root }).filter((run) => liveStatus(run) === "running").map((run) => run.id));
|
|
17468
|
+
/**
|
|
17469
|
+
* Whether `root` is a repository with a commit, and the branch checked out
|
|
17470
|
+
* there, which "merged" means merged into (null when its HEAD is detached).
|
|
17471
|
+
*/
|
|
17472
|
+
const projectHead = async (root) => {
|
|
17473
|
+
if ((await git(root, [
|
|
17474
|
+
"rev-parse",
|
|
17475
|
+
"--verify",
|
|
17476
|
+
"--quiet",
|
|
17477
|
+
"HEAD"
|
|
17478
|
+
])).code !== 0) return { ok: false };
|
|
17479
|
+
const answer = await git(root, [
|
|
17480
|
+
"symbolic-ref",
|
|
17481
|
+
"--short",
|
|
17482
|
+
"-q",
|
|
17483
|
+
"HEAD"
|
|
17484
|
+
]);
|
|
17485
|
+
return {
|
|
17486
|
+
ok: true,
|
|
17487
|
+
branch: answer.code === 0 ? answer.stdout.trim() : null
|
|
17488
|
+
};
|
|
17489
|
+
};
|
|
17490
|
+
/** The top of the repository `cwd` is in, even one with no commit yet. */
|
|
17491
|
+
const repoRoot = async (cwd) => {
|
|
17492
|
+
const top = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
17493
|
+
return top.code === 0 ? top.stdout.trim() : null;
|
|
17494
|
+
};
|
|
17495
|
+
/** What Ork created in the repo at `root`, from its runs' records and git. */
|
|
17496
|
+
const readWorktrees = async (store, root) => ({
|
|
17497
|
+
items: await listOrkWorktrees(root, createdWorktrees(store, root)),
|
|
17498
|
+
live: liveRunIds(store, root)
|
|
17499
|
+
});
|
|
17500
|
+
/**
|
|
17501
|
+
* The plan to remove one item, named by `keyOf`. A branch with no worktree is
|
|
17502
|
+
* removed by deleting it, so `deleteBranch` is implied there.
|
|
17503
|
+
*/
|
|
17504
|
+
const planRemove = ({ items, live }, key, deleteBranch) => {
|
|
17505
|
+
const item = items.find((i) => keyOf(i) === key);
|
|
17506
|
+
if (item === void 0) return {
|
|
17507
|
+
ok: false,
|
|
17508
|
+
error: `No Ork worktree or branch ${key}.`,
|
|
17509
|
+
gone: true
|
|
17510
|
+
};
|
|
17511
|
+
const refusal = removeRefusal(item, live.has(item.runId));
|
|
17512
|
+
if (refusal !== void 0) return {
|
|
17513
|
+
ok: false,
|
|
17514
|
+
error: `Can't remove ${labelOf(item)}: ${refusal}.`,
|
|
17515
|
+
gone: false
|
|
17516
|
+
};
|
|
17517
|
+
const branches = item.branch !== void 0 && (deleteBranch || item.path === null);
|
|
17518
|
+
const careful = cleanStep(item, {
|
|
17519
|
+
branches,
|
|
17520
|
+
force: false,
|
|
17521
|
+
live
|
|
17522
|
+
});
|
|
17523
|
+
if (careful.skip === void 0) return {
|
|
17524
|
+
ok: true,
|
|
17525
|
+
plan: {
|
|
17526
|
+
step: careful,
|
|
17527
|
+
force: false
|
|
17528
|
+
}
|
|
17529
|
+
};
|
|
17530
|
+
return {
|
|
17531
|
+
ok: true,
|
|
17532
|
+
plan: {
|
|
17533
|
+
step: cleanStep(item, {
|
|
17534
|
+
branches,
|
|
17535
|
+
force: true,
|
|
17536
|
+
live
|
|
17537
|
+
}),
|
|
17538
|
+
force: true
|
|
17539
|
+
}
|
|
17540
|
+
};
|
|
17541
|
+
};
|
|
17542
|
+
const out = async (cwd, args) => lines((await git(cwd, args)).stdout);
|
|
17543
|
+
/** What carrying out `plan` would lose, read from git. */
|
|
17544
|
+
const lostWork = async (root, { step }) => {
|
|
17545
|
+
const { item } = step;
|
|
17546
|
+
const inside = item.path !== null && !item.missing ? item.path : null;
|
|
17547
|
+
const changes = inside !== null && item.dirty && step.removeWorktree ? await out(inside, ["status", "--porcelain"]) : [];
|
|
17548
|
+
if (step.deleteBranch && item.branch !== void 0 && !item.merged) return {
|
|
17549
|
+
changes,
|
|
17550
|
+
commits: await out(root, [
|
|
17551
|
+
"log",
|
|
17552
|
+
"--oneline",
|
|
17553
|
+
`HEAD..${item.branch}`
|
|
17554
|
+
]),
|
|
17555
|
+
commitsOn: "unmerged"
|
|
17556
|
+
};
|
|
17557
|
+
if (item.branch === void 0 && item.checkedOut === void 0 && inside !== null && item.ahead > 0 && step.removeWorktree) return {
|
|
17558
|
+
changes,
|
|
17559
|
+
commits: await out(inside, [
|
|
17560
|
+
"log",
|
|
17561
|
+
"--oneline",
|
|
17562
|
+
"HEAD",
|
|
17563
|
+
"--not",
|
|
17564
|
+
"--branches",
|
|
17565
|
+
"--tags",
|
|
17566
|
+
"--remotes"
|
|
17567
|
+
]),
|
|
17568
|
+
commitsOn: "no branch"
|
|
17569
|
+
};
|
|
17570
|
+
return {
|
|
17571
|
+
changes,
|
|
17572
|
+
commits: [],
|
|
17573
|
+
commitsOn: null
|
|
17574
|
+
};
|
|
17575
|
+
};
|
|
17576
|
+
/**
|
|
17577
|
+
* Carries out a remove plan; what it did goes into `done` as it goes. The
|
|
17578
|
+
* caller has checked that a plan needing `force` was asked to force. It touches
|
|
17579
|
+
* only its own item, as each of clean's steps does.
|
|
17580
|
+
*/
|
|
17581
|
+
const applyRemove = async (root, { step, force }, done = []) => {
|
|
17582
|
+
await applyStep(root, step, force, done);
|
|
17583
|
+
return done;
|
|
17584
|
+
};
|
|
16150
17585
|
// ../core/src/worktree/resumed.ts
|
|
16151
17586
|
/**
|
|
16152
17587
|
* @file What a resumed run's workspace starts from: the worktrees the run left
|
|
@@ -16619,4 +18054,4 @@ const createWorkspace = (options) => {
|
|
|
16619
18054
|
close
|
|
16620
18055
|
};
|
|
16621
18056
|
};
|
|
16622
|
-
export {
|
|
18057
|
+
export { resolveInputs as $, maskSecrets as $n, PREFERRED_DRIVER as $r, countedTokens as $t, isNewer as A, END_KINDS as An, readDocument as Ar, usd as At, watchingPid as B, createFeedback as Bn, CheckReportSchema as Br, MESSAGE_LIMIT as Bt, capsHere as C, MCP_FILE as Cn, guardedBase as Cr, DETACHED_ENV as Ct, runningScript as D, endOf as Dn, WorkflowEditSchema as Dr, limitsNow as Dt, runningRunIds as E, createSignals as En, skillsIn as Er, NOT_STARTED as Et, triggerStanding as F, DISCUSSIONS as Fn, parseWorkflow as Fr, reportSchemaFor as Ft, applyPrune as G, parseTemplate as Gn, isToolNode as Gr, pausedOf as Gt, isNodeEvent as H, normalizeTitle as Hn, settingsOf as Hr, heldTimeout as Ht, declaredTriggers as I, planPost as In, serializeWorkflow as Ir, checkWorkflowText as It, listOrkWorktrees as J, serveRun as Jn, retiredDriverHint as Jr, templateOf as Jt, planPrune as K, renderTemplate as Kn, DriverSchema as Kr, runStatus as Kt, readProjectTriggers as L, runGh as Ln, markValue as Lr, walkChecks as Lt, lateTriggers as M, FEEDBACK_KINDS as Mn, beforeOf as Mr, explainSession as Mt, triggersReport as N, FEEDBACK_SEVERITIES as Nn, programsRunBy as Nr, planNodeSession as Nt, detectInstaller as O, outcomeEnd as On, applyEdits as Or, limitsReport as Ot, createWatcher as P, FEEDBACK_TARGETS as Pn, renderJsonSchema as Pr, parseReport as Pt, parseInputPairs as Q, maskReplacer as Qn, integrationFor as Qr, nestItems as Qt, workflowTextHash as R, sendPost as Rn, EdgeInputSchema as Rr, walkSummary as Rt, usageReport as S, machineRulesOf as Sn, estimateTokens as Sr, resumable as St, releaseNotes as T, serversIn as Tn, writePlainFile as Tr, followEvents as Tt, openStore as U, targetOf as Un, NodeSchema as Ur, liveStatus as Ut, activeTree as V, fingerprint as Vn, expandEdge as Vr, userMessageText as Vt, storePath as W, evaluate as Wn, isClaudeNode as Wr, lockWaitsOf as Wt, createReplay as X, socketRoom as Xn, INTEGRATIONS as Xr, stillTheSameProcess as Xt, inspectProject as Y, socketPath as Yn, timeoutMs as Yr, processAlive as Yt, replayLoopAnswers as Z, MASK as Zn, TOOL_PREFIX as Zr, isItem as Zt, keyOf as _, nodeSays as _n, shownPath as _r, requestPause as _t, planRemove as a, DRIVER_IDS as ai, stoppedEnd as an, toolsInUse as ar, scriptRefusal as at, stepPrunes as b, gitRulesOf as bn, projectRootOf as br, adoptedCalls as bt, repoRoot as c, experimentsOn as ci, slotWaitLine as cn, catalogAgent as cr, followRunTree as ct, planLine as d, git as dn, inRepoPlace as dr, createCalls as dt, driverChecks as ei, runScope as en, planAgentMigration as er, budgetWarnings as et, removeRefusal as f, gitOk as fn, newProjectId as fr, addressOf as ft, cleanStep as g, sessionDriver as gn, runDirOf as gr, requestContinue as gt, applyClean as h, namingNoDriver as hn, projectsHome as hr, unlinkNodes as ht, lostWork as i, AGENT_DRIVERS as ii, failedWith as in, toolLinks as ir, TOO_LONG as it, parseVersion as j, endFor as jn, renameNodeTypes as jr, explainWorkflowText as jt, compareVersions as k, END_DETAIL_LIMIT as kn, newWorkflowText as kr, planReason as kt, labelOf as l, knownExperiments as li, lowerChild as ln, loadAgents as lr, answerDecision as lt, resolveBase as m, envReadsOf as mn, placeFor as mr, linkNodes as mt, resumedOptions as n, AGENT_TOOL_LAYOUTS as ni, lockWaitLine as nn, planMoveInto as nr, sessionRefusals as nt, projectHead as o, firstLine as oi, declaredProblem as on, createAgent as or, sendFromScript as ot, stateParts as p, clearGitLocalEnv as pn, placeFiles as pr, endLinksOf as pt, createdWorktrees as q, readCondition as qn, NodeIdSchema as qr, slotWaitsOf as qt, applyRemove as r, ORK_LAYOUT as ri, endedWith as rn, linkTools as rr, driverQueues as rt, readWorktrees as s, EXPERIMENTS as si, slotClock as sn, deleteAgent as sr, createRelay as st, createWorkspace as t, readyDriver as ti, calledWorkflows as tn, applySteps as tr, driverRefusals as tt, mergeStateOf as u, startAt as un, hasInRepoFiles as ur, pendingDecisions as ut, planClean as v, declaredOutputOf as vn, workflowDirOf as vr, requestStop as vt, planUpgrade as w, mcpNotText as wn, plainPathInside as wr, detachRun as wt, USAGE_DIMENSIONS as x, loadProject as xn, LESSON_CAP_TOKENS as xr, detachResume as xt, stepActs as y, PROJECT_FILE as yn, inside as yr, tellLiveRun as yt, takeWatchLock as z, publishPost as zn, WorkflowSchema as zr, startRun as zt };
|