@elie-laloum/outpost 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.fr.md +251 -0
- package/README.md +226 -0
- package/ROADMAP.md +34 -0
- package/SECURITY.md +15 -0
- package/dist/application/campaign.d.ts +33 -0
- package/dist/application/campaign.js +195 -0
- package/dist/application/campaign.js.map +1 -0
- package/dist/application/execution.d.ts +48 -0
- package/dist/application/execution.js +322 -0
- package/dist/application/execution.js.map +1 -0
- package/dist/application/interactive-brief.d.ts +3 -0
- package/dist/application/interactive-brief.js +35 -0
- package/dist/application/interactive-brief.js.map +1 -0
- package/dist/application/outpost.d.ts +91 -0
- package/dist/application/outpost.js +629 -0
- package/dist/application/outpost.js.map +1 -0
- package/dist/application/remote-workspace.d.ts +11 -0
- package/dist/application/remote-workspace.js +264 -0
- package/dist/application/remote-workspace.js.map +1 -0
- package/dist/application/tasks.d.ts +17 -0
- package/dist/application/tasks.js +36 -0
- package/dist/application/tasks.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +94 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/scaffold.d.ts +28 -0
- package/dist/cli/scaffold.js +169 -0
- package/dist/cli/scaffold.js.map +1 -0
- package/dist/cli/starters.d.ts +3 -0
- package/dist/cli/starters.js +34 -0
- package/dist/cli/starters.js.map +1 -0
- package/dist/domain/backlog.d.ts +16 -0
- package/dist/domain/backlog.js +23 -0
- package/dist/domain/backlog.js.map +1 -0
- package/dist/domain/errors.d.ts +11 -0
- package/dist/domain/errors.js +37 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/ports.d.ts +183 -0
- package/dist/domain/ports.js +2 -0
- package/dist/domain/ports.js.map +1 -0
- package/dist/domain/prompts.d.ts +22 -0
- package/dist/domain/prompts.js +45 -0
- package/dist/domain/prompts.js.map +1 -0
- package/dist/domain/response.d.ts +37 -0
- package/dist/domain/response.js +50 -0
- package/dist/domain/response.js.map +1 -0
- package/dist/domain/workflow.d.ts +68 -0
- package/dist/domain/workflow.js +246 -0
- package/dist/domain/workflow.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/infrastructure/abort.d.ts +1 -0
- package/dist/infrastructure/abort.js +16 -0
- package/dist/infrastructure/abort.js.map +1 -0
- package/dist/infrastructure/conversations.d.ts +30 -0
- package/dist/infrastructure/conversations.js +222 -0
- package/dist/infrastructure/conversations.js.map +1 -0
- package/dist/infrastructure/files.d.ts +5 -0
- package/dist/infrastructure/files.js +85 -0
- package/dist/infrastructure/files.js.map +1 -0
- package/dist/infrastructure/git.d.ts +14 -0
- package/dist/infrastructure/git.js +304 -0
- package/dist/infrastructure/git.js.map +1 -0
- package/dist/infrastructure/journal.d.ts +10 -0
- package/dist/infrastructure/journal.js +53 -0
- package/dist/infrastructure/journal.js.map +1 -0
- package/dist/infrastructure/process.d.ts +6 -0
- package/dist/infrastructure/process.js +133 -0
- package/dist/infrastructure/process.js.map +1 -0
- package/dist/infrastructure/reporter.d.ts +10 -0
- package/dist/infrastructure/reporter.js +47 -0
- package/dist/infrastructure/reporter.js.map +1 -0
- package/dist/infrastructure/settings.d.ts +3 -0
- package/dist/infrastructure/settings.js +41 -0
- package/dist/infrastructure/settings.js.map +1 -0
- package/dist/infrastructure/shutdown.d.ts +1 -0
- package/dist/infrastructure/shutdown.js +67 -0
- package/dist/infrastructure/shutdown.js.map +1 -0
- package/dist/infrastructure/terminal.d.ts +11 -0
- package/dist/infrastructure/terminal.js +19 -0
- package/dist/infrastructure/terminal.js.map +1 -0
- package/dist/infrastructure/transfer.d.ts +3 -0
- package/dist/infrastructure/transfer.js +29 -0
- package/dist/infrastructure/transfer.js.map +1 -0
- package/dist/providers/agents.d.ts +17 -0
- package/dist/providers/agents.js +204 -0
- package/dist/providers/agents.js.map +1 -0
- package/dist/providers/backlogs.d.ts +9 -0
- package/dist/providers/backlogs.js +82 -0
- package/dist/providers/backlogs.js.map +1 -0
- package/dist/providers/cloud-files.d.ts +4 -0
- package/dist/providers/cloud-files.js +66 -0
- package/dist/providers/cloud-files.js.map +1 -0
- package/dist/providers/container.d.ts +21 -0
- package/dist/providers/container.js +310 -0
- package/dist/providers/container.js.map +1 -0
- package/dist/providers/daytona.d.ts +10 -0
- package/dist/providers/daytona.js +153 -0
- package/dist/providers/daytona.js.map +1 -0
- package/dist/providers/docker.d.ts +3 -0
- package/dist/providers/docker.js +3 -0
- package/dist/providers/docker.js.map +1 -0
- package/dist/providers/factories.d.ts +9 -0
- package/dist/providers/factories.js +12 -0
- package/dist/providers/factories.js.map +1 -0
- package/dist/providers/local.d.ts +4 -0
- package/dist/providers/local.js +64 -0
- package/dist/providers/local.js.map +1 -0
- package/dist/providers/podman.d.ts +3 -0
- package/dist/providers/podman.js +3 -0
- package/dist/providers/podman.js.map +1 -0
- package/dist/providers/vercel.d.ts +9 -0
- package/dist/providers/vercel.js +158 -0
- package/dist/providers/vercel.js.map +1 -0
- package/dist/providers/versions.d.ts +4 -0
- package/dist/providers/versions.js +5 -0
- package/dist/providers/versions.js.map +1 -0
- package/docs/api.md +147 -0
- package/docs/architecture.md +21 -0
- package/docs/migration-1.1.fr.md +44 -0
- package/docs/migration-1.1.md +38 -0
- package/docs/operations.md +101 -0
- package/docs/providers.md +119 -0
- package/docs/workflows.md +101 -0
- package/package.json +95 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readPlan } from "../domain/backlog.js";
|
|
3
|
+
import { invariant, OutpostError, positive } from "../domain/errors.js";
|
|
4
|
+
import { response } from "../domain/response.js";
|
|
5
|
+
import { task, workflow } from "../domain/workflow.js";
|
|
6
|
+
import { git } from "../infrastructure/git.js";
|
|
7
|
+
import { notify } from "./execution.js";
|
|
8
|
+
import { createSandbox, dispatch } from "./outpost.js";
|
|
9
|
+
export async function campaign(options) {
|
|
10
|
+
options.signal?.throwIfAborted();
|
|
11
|
+
const limit = positive(options.cycles ?? 10, "cycles");
|
|
12
|
+
const concurrency = positive(options.concurrency ?? 3, "concurrency");
|
|
13
|
+
positive(options.implementationPasses ?? 100, "implementationPasses");
|
|
14
|
+
positive(options.reviewPasses ?? 1, "reviewPasses");
|
|
15
|
+
const { backlog, planner, reviewer, merger, cycles: _cycles, concurrency: _concurrency, implementationPasses, reviewPasses, standards = "Follow the repository conventions. Keep changes focused, test behavior and commit the result.", observe, ...runtime } = options;
|
|
16
|
+
const repository = runtime.repository ?? process.cwd();
|
|
17
|
+
const outcomes = [];
|
|
18
|
+
const emit = (phase, cycle, issue) => notify(observe, { phase, cycle, ...(issue ? { issue } : {}) });
|
|
19
|
+
for (let cycle = 1; cycle <= limit; cycle++) {
|
|
20
|
+
runtime.signal?.throwIfAborted();
|
|
21
|
+
emit("backlog", cycle);
|
|
22
|
+
const available = await backlog.list(runtime.signal);
|
|
23
|
+
invariant(new Set(available.map((issue) => issue.id)).size === available.length, "Backlog contains duplicate issue ids");
|
|
24
|
+
if (!available.length)
|
|
25
|
+
return { cycles: cycle - 1, reason: "empty", issues: outcomes };
|
|
26
|
+
const pending = new Set(available.map((issue) => issue.id));
|
|
27
|
+
const ready = available.filter((issue) => !issue.blockedBy?.some((id) => pending.has(id)));
|
|
28
|
+
if (!ready.length)
|
|
29
|
+
return { cycles: cycle - 1, reason: "blocked", issues: outcomes };
|
|
30
|
+
const prefix = `outpost/batch-${randomUUID().slice(0, 8)}`;
|
|
31
|
+
let assignments;
|
|
32
|
+
if (planner !== false) {
|
|
33
|
+
emit("plan", cycle);
|
|
34
|
+
const plan = await dispatch({
|
|
35
|
+
...runtime,
|
|
36
|
+
agent: planner ?? runtime.agent,
|
|
37
|
+
branch: { mode: "named", name: `${prefix}-planning` },
|
|
38
|
+
brief: {
|
|
39
|
+
text: `Select independent, unblocked issues from this backlog. Read the repository without editing files. Return <assignments>{"issues":[{"id":"an existing id","branch":"${prefix}-short-name"}]}</assignments>. Return an empty array when blocked.\n${JSON.stringify(ready)}`,
|
|
40
|
+
},
|
|
41
|
+
response: response.json({
|
|
42
|
+
tag: "assignments",
|
|
43
|
+
schema: readPlan,
|
|
44
|
+
repairs: 1,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
invariant(!plan.commits.length && !plan.retainedDirectory, "Planner changed its workspace; inspect the planning branch before continuing");
|
|
48
|
+
assignments = plan.value;
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
assignments = ready.slice(0, concurrency).map((issue, index) => ({
|
|
52
|
+
id: issue.id,
|
|
53
|
+
branch: `${prefix}-${index + 1}`,
|
|
54
|
+
}));
|
|
55
|
+
if (!assignments.length)
|
|
56
|
+
return { cycles: cycle - 1, reason: "blocked", issues: outcomes };
|
|
57
|
+
const baseline = (await git(repository, ["rev-parse", "HEAD"])).trim();
|
|
58
|
+
for (const assignment of assignments) {
|
|
59
|
+
invariant(ready.some((issue) => issue.id === assignment.id), `Plan selected an unavailable or blocked issue: ${assignment.id}`);
|
|
60
|
+
await git(repository, [
|
|
61
|
+
"check-ref-format",
|
|
62
|
+
"--branch",
|
|
63
|
+
assignment.branch,
|
|
64
|
+
]);
|
|
65
|
+
const exists = await git(repository, [
|
|
66
|
+
"show-ref",
|
|
67
|
+
"--verify",
|
|
68
|
+
`refs/heads/${assignment.branch}`,
|
|
69
|
+
]).then(() => true, () => false);
|
|
70
|
+
invariant(!exists, `Issue branch already exists: ${assignment.branch}`);
|
|
71
|
+
}
|
|
72
|
+
const jobs = assignments.map((assignment, index) => task({
|
|
73
|
+
key: `issue-${index + 1}`,
|
|
74
|
+
async perform({ signal }) {
|
|
75
|
+
const issue = await backlog.get(assignment.id, signal);
|
|
76
|
+
invariant(issue.id === assignment.id, "Tracker returned a different issue");
|
|
77
|
+
const sandbox = await createSandbox({
|
|
78
|
+
...runtime,
|
|
79
|
+
signal,
|
|
80
|
+
branch: { mode: "named", name: assignment.branch, from: baseline },
|
|
81
|
+
});
|
|
82
|
+
let success = false;
|
|
83
|
+
try {
|
|
84
|
+
emit("implement", cycle, issue.id);
|
|
85
|
+
const implemented = await sandbox.dispatch({
|
|
86
|
+
agent: runtime.agent,
|
|
87
|
+
signal,
|
|
88
|
+
passes: implementationPasses ?? 100,
|
|
89
|
+
brief: {
|
|
90
|
+
text: `Implement issue ${issue.id}: ${issue.title}\n${issue.body ?? ""}\n${standards}\nDo not close the issue. When complete, write <outpost>done</outpost>.`,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
if (!implemented.commits.length) {
|
|
94
|
+
success = true;
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (reviewer !== false) {
|
|
98
|
+
emit("review", cycle, issue.id);
|
|
99
|
+
await sandbox.dispatch({
|
|
100
|
+
agent: reviewer ?? runtime.agent,
|
|
101
|
+
signal,
|
|
102
|
+
passes: reviewPasses ?? 1,
|
|
103
|
+
brief: {
|
|
104
|
+
text: `Review issue ${issue.id} on ${assignment.branch}. Inspect git diff ${baseline}...HEAD, fix concrete defects, run the relevant tests and commit corrections.\n${standards}\nDo not close the issue. When complete, write <outpost>done</outpost>.`,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const clean = await sandbox.command({
|
|
109
|
+
executable: "git",
|
|
110
|
+
arguments: ["status", "--porcelain"],
|
|
111
|
+
signal,
|
|
112
|
+
});
|
|
113
|
+
invariant(clean.status === 0 && !clean.stdout.trim(), `Issue ${issue.id} has uncommitted changes`);
|
|
114
|
+
const head = (await git(sandbox.workspace.directory, ["rev-parse", "HEAD"])).trim();
|
|
115
|
+
success = true;
|
|
116
|
+
return { ...assignment, head };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await sandbox.close({ preserve: !success });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
const execution = await workflow(`batch-${cycle}`, jobs).start({
|
|
124
|
+
concurrency,
|
|
125
|
+
stopOnError: false,
|
|
126
|
+
...(runtime.signal ? { signal: runtime.signal } : {}),
|
|
127
|
+
});
|
|
128
|
+
runtime.signal?.throwIfAborted();
|
|
129
|
+
const completed = [];
|
|
130
|
+
jobs.forEach((job, index) => {
|
|
131
|
+
const record = execution.tasks[index];
|
|
132
|
+
if (record.status !== "done")
|
|
133
|
+
outcomes.push({
|
|
134
|
+
...assignments[index],
|
|
135
|
+
state: "failed",
|
|
136
|
+
error: record.error,
|
|
137
|
+
});
|
|
138
|
+
else {
|
|
139
|
+
const item = execution.value(job);
|
|
140
|
+
if (item)
|
|
141
|
+
completed.push(item);
|
|
142
|
+
else
|
|
143
|
+
outcomes.push({ ...assignments[index], state: "empty" });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
if (!completed.length)
|
|
147
|
+
return { cycles: cycle, reason: "no-progress", issues: outcomes };
|
|
148
|
+
emit("merge", cycle);
|
|
149
|
+
const integration = await createSandbox({
|
|
150
|
+
...runtime,
|
|
151
|
+
agent: merger ?? runtime.agent,
|
|
152
|
+
branch: { mode: "integrate" },
|
|
153
|
+
});
|
|
154
|
+
let integrated = false;
|
|
155
|
+
try {
|
|
156
|
+
for (const item of completed) {
|
|
157
|
+
const merged = await integration.command({
|
|
158
|
+
executable: "git",
|
|
159
|
+
arguments: ["merge", "--no-edit", item.head],
|
|
160
|
+
...(runtime.signal ? { signal: runtime.signal } : {}),
|
|
161
|
+
});
|
|
162
|
+
await integration.dispatch({
|
|
163
|
+
agent: merger ?? runtime.agent,
|
|
164
|
+
...(runtime.signal ? { signal: runtime.signal } : {}),
|
|
165
|
+
brief: {
|
|
166
|
+
text: `Validate integration of issue ${item.id} from commit ${item.head}. ${merged.status ? "Resolve the current Git merge conflict and finish the merge." : "The commits have been merged."} Run relevant tests, fix integration defects and commit any changes. ${standards} Do not close issues. When complete, write <outpost>done</outpost>.`,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const verified = await integration.command({
|
|
170
|
+
executable: "git",
|
|
171
|
+
arguments: ["merge-base", "--is-ancestor", item.head, "HEAD"],
|
|
172
|
+
});
|
|
173
|
+
if (verified.status)
|
|
174
|
+
throw new OutpostError("conflict", "Merge did not include the issue commits", { issue: item.id, branch: item.branch });
|
|
175
|
+
}
|
|
176
|
+
const clean = await integration.command({
|
|
177
|
+
executable: "git",
|
|
178
|
+
arguments: ["status", "--porcelain"],
|
|
179
|
+
});
|
|
180
|
+
invariant(clean.status === 0 && !clean.stdout.trim(), "Integration has uncommitted changes");
|
|
181
|
+
await integration.workspace.integrate();
|
|
182
|
+
integrated = true;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await integration.close({ preserve: !integrated });
|
|
186
|
+
}
|
|
187
|
+
for (const item of completed) {
|
|
188
|
+
emit("close", cycle, item.id);
|
|
189
|
+
await backlog.close(item.id, runtime.signal);
|
|
190
|
+
outcomes.push({ id: item.id, branch: item.branch, state: "merged" });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { cycles: limit, reason: "limit", issues: outcomes };
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=campaign.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"campaign.js","sourceRoot":"","sources":["../../src/application/campaign.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { AgentAdapter } from \"../domain/ports.ts\";\nimport type { Assignment, Backlog, Issue } from \"../domain/backlog.ts\";\nimport { readPlan } from \"../domain/backlog.ts\";\nimport { invariant, OutpostError, positive } from \"../domain/errors.ts\";\nimport { response } from \"../domain/response.ts\";\nimport { task, workflow } from \"../domain/workflow.ts\";\nimport { git } from \"../infrastructure/git.ts\";\nimport { notify } from \"./execution.ts\";\nimport { createSandbox, dispatch, type SandboxOptions } from \"./outpost.ts\";\n\nexport interface CampaignEvent {\n readonly phase:\n \"backlog\" | \"plan\" | \"implement\" | \"review\" | \"merge\" | \"close\";\n readonly cycle: number;\n readonly issue?: string;\n}\n\nexport interface CampaignOptions extends Omit<\n SandboxOptions,\n \"workspace\" | \"branch\" | \"agent\"\n> {\n readonly agent: AgentAdapter;\n readonly backlog: Backlog;\n readonly planner?: AgentAdapter | false;\n readonly reviewer?: AgentAdapter | false;\n readonly merger?: AgentAdapter;\n readonly cycles?: number;\n readonly concurrency?: number;\n readonly implementationPasses?: number;\n readonly reviewPasses?: number;\n readonly standards?: string;\n readonly observe?: (event: CampaignEvent) => void;\n}\n\nexport interface IssueOutcome {\n readonly id: string;\n readonly branch: string;\n readonly state: \"empty\" | \"failed\" | \"merged\";\n readonly error?: unknown;\n}\n\nexport interface CampaignResult {\n readonly cycles: number;\n readonly reason: \"empty\" | \"blocked\" | \"no-progress\" | \"limit\";\n readonly issues: readonly IssueOutcome[];\n}\n\nexport async function campaign(\n options: CampaignOptions,\n): Promise<CampaignResult> {\n options.signal?.throwIfAborted();\n const limit = positive(options.cycles ?? 10, \"cycles\");\n const concurrency = positive(options.concurrency ?? 3, \"concurrency\");\n positive(options.implementationPasses ?? 100, \"implementationPasses\");\n positive(options.reviewPasses ?? 1, \"reviewPasses\");\n const {\n backlog,\n planner,\n reviewer,\n merger,\n cycles: _cycles,\n concurrency: _concurrency,\n implementationPasses,\n reviewPasses,\n standards = \"Follow the repository conventions. Keep changes focused, test behavior and commit the result.\",\n observe,\n ...runtime\n } = options;\n const repository = runtime.repository ?? process.cwd();\n const outcomes: IssueOutcome[] = [];\n const emit = (phase: CampaignEvent[\"phase\"], cycle: number, issue?: string) =>\n notify(observe, { phase, cycle, ...(issue ? { issue } : {}) });\n for (let cycle = 1; cycle <= limit; cycle++) {\n runtime.signal?.throwIfAborted();\n emit(\"backlog\", cycle);\n const available = await backlog.list(runtime.signal);\n invariant(\n new Set(available.map((issue) => issue.id)).size === available.length,\n \"Backlog contains duplicate issue ids\",\n );\n if (!available.length)\n return { cycles: cycle - 1, reason: \"empty\", issues: outcomes };\n const pending = new Set(available.map((issue) => issue.id));\n const ready = available.filter(\n (issue) => !issue.blockedBy?.some((id) => pending.has(id)),\n );\n if (!ready.length)\n return { cycles: cycle - 1, reason: \"blocked\", issues: outcomes };\n const prefix = `outpost/batch-${randomUUID().slice(0, 8)}`;\n let assignments: readonly Assignment[];\n if (planner !== false) {\n emit(\"plan\", cycle);\n const plan = await dispatch({\n ...runtime,\n agent: planner ?? runtime.agent,\n branch: { mode: \"named\", name: `${prefix}-planning` },\n brief: {\n text: `Select independent, unblocked issues from this backlog. Read the repository without editing files. Return <assignments>{\"issues\":[{\"id\":\"an existing id\",\"branch\":\"${prefix}-short-name\"}]}</assignments>. Return an empty array when blocked.\\n${JSON.stringify(ready)}`,\n },\n response: response.json({\n tag: \"assignments\",\n schema: readPlan,\n repairs: 1,\n }),\n });\n invariant(\n !plan.commits.length && !plan.retainedDirectory,\n \"Planner changed its workspace; inspect the planning branch before continuing\",\n );\n assignments = plan.value;\n } else\n assignments = ready.slice(0, concurrency).map((issue, index) => ({\n id: issue.id,\n branch: `${prefix}-${index + 1}`,\n }));\n if (!assignments.length)\n return { cycles: cycle - 1, reason: \"blocked\", issues: outcomes };\n const baseline = (await git(repository, [\"rev-parse\", \"HEAD\"])).trim();\n for (const assignment of assignments) {\n invariant(\n ready.some((issue) => issue.id === assignment.id),\n `Plan selected an unavailable or blocked issue: ${assignment.id}`,\n );\n await git(repository, [\n \"check-ref-format\",\n \"--branch\",\n assignment.branch,\n ]);\n const exists = await git(repository, [\n \"show-ref\",\n \"--verify\",\n `refs/heads/${assignment.branch}`,\n ]).then(\n () => true,\n () => false,\n );\n invariant(!exists, `Issue branch already exists: ${assignment.branch}`);\n }\n const jobs = assignments.map((assignment, index) =>\n task({\n key: `issue-${index + 1}`,\n async perform({ signal }) {\n const issue: Issue = await backlog.get(assignment.id, signal);\n invariant(\n issue.id === assignment.id,\n \"Tracker returned a different issue\",\n );\n const sandbox = await createSandbox({\n ...runtime,\n signal,\n branch: { mode: \"named\", name: assignment.branch, from: baseline },\n });\n let success = false;\n try {\n emit(\"implement\", cycle, issue.id);\n const implemented = await sandbox.dispatch({\n agent: runtime.agent,\n signal,\n passes: implementationPasses ?? 100,\n brief: {\n text: `Implement issue ${issue.id}: ${issue.title}\\n${issue.body ?? \"\"}\\n${standards}\\nDo not close the issue. When complete, write <outpost>done</outpost>.`,\n },\n });\n if (!implemented.commits.length) {\n success = true;\n return undefined;\n }\n if (reviewer !== false) {\n emit(\"review\", cycle, issue.id);\n await sandbox.dispatch({\n agent: reviewer ?? runtime.agent,\n signal,\n passes: reviewPasses ?? 1,\n brief: {\n text: `Review issue ${issue.id} on ${assignment.branch}. Inspect git diff ${baseline}...HEAD, fix concrete defects, run the relevant tests and commit corrections.\\n${standards}\\nDo not close the issue. When complete, write <outpost>done</outpost>.`,\n },\n });\n }\n const clean = await sandbox.command({\n executable: \"git\",\n arguments: [\"status\", \"--porcelain\"],\n signal,\n });\n invariant(\n clean.status === 0 && !clean.stdout.trim(),\n `Issue ${issue.id} has uncommitted changes`,\n );\n const head = (\n await git(sandbox.workspace.directory, [\"rev-parse\", \"HEAD\"])\n ).trim();\n success = true;\n return { ...assignment, head };\n } finally {\n await sandbox.close({ preserve: !success });\n }\n },\n }),\n );\n const execution = await workflow(`batch-${cycle}`, jobs).start({\n concurrency,\n stopOnError: false,\n ...(runtime.signal ? { signal: runtime.signal } : {}),\n });\n runtime.signal?.throwIfAborted();\n const completed: (Assignment & { head: string })[] = [];\n jobs.forEach((job, index) => {\n const record = execution.tasks[index]!;\n if (record.status !== \"done\")\n outcomes.push({\n ...assignments[index]!,\n state: \"failed\",\n error: record.error,\n });\n else {\n const item = execution.value(job);\n if (item) completed.push(item);\n else outcomes.push({ ...assignments[index]!, state: \"empty\" });\n }\n });\n if (!completed.length)\n return { cycles: cycle, reason: \"no-progress\", issues: outcomes };\n emit(\"merge\", cycle);\n const integration = await createSandbox({\n ...runtime,\n agent: merger ?? runtime.agent,\n branch: { mode: \"integrate\" },\n });\n let integrated = false;\n try {\n for (const item of completed) {\n const merged = await integration.command({\n executable: \"git\",\n arguments: [\"merge\", \"--no-edit\", item.head],\n ...(runtime.signal ? { signal: runtime.signal } : {}),\n });\n await integration.dispatch({\n agent: merger ?? runtime.agent,\n ...(runtime.signal ? { signal: runtime.signal } : {}),\n brief: {\n text: `Validate integration of issue ${item.id} from commit ${item.head}. ${merged.status ? \"Resolve the current Git merge conflict and finish the merge.\" : \"The commits have been merged.\"} Run relevant tests, fix integration defects and commit any changes. ${standards} Do not close issues. When complete, write <outpost>done</outpost>.`,\n },\n });\n const verified = await integration.command({\n executable: \"git\",\n arguments: [\"merge-base\", \"--is-ancestor\", item.head, \"HEAD\"],\n });\n if (verified.status)\n throw new OutpostError(\n \"conflict\",\n \"Merge did not include the issue commits\",\n { issue: item.id, branch: item.branch },\n );\n }\n const clean = await integration.command({\n executable: \"git\",\n arguments: [\"status\", \"--porcelain\"],\n });\n invariant(\n clean.status === 0 && !clean.stdout.trim(),\n \"Integration has uncommitted changes\",\n );\n await integration.workspace.integrate();\n integrated = true;\n } finally {\n await integration.close({ preserve: !integrated });\n }\n for (const item of completed) {\n emit(\"close\", cycle, item.id);\n await backlog.close(item.id, runtime.signal);\n outcomes.push({ id: item.id, branch: item.branch, state: \"merged\" });\n }\n }\n return { cycles: limit, reason: \"limit\", issues: outcomes };\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AgentAdapter, AgentObservation, SandboxLease, Usage, WorkspaceRecord } from "../domain/ports.ts";
|
|
2
|
+
import { type Brief } from "../domain/prompts.ts";
|
|
3
|
+
import { type ResponseSpec } from "../domain/response.ts";
|
|
4
|
+
import type { Logging } from "../infrastructure/journal.ts";
|
|
5
|
+
export interface DispatchOptions<T = undefined> {
|
|
6
|
+
readonly agent?: AgentAdapter;
|
|
7
|
+
readonly logging?: Logging;
|
|
8
|
+
readonly label?: string;
|
|
9
|
+
readonly brief: Brief;
|
|
10
|
+
readonly passes?: number;
|
|
11
|
+
readonly until?: string | readonly string[];
|
|
12
|
+
readonly idleMs?: number;
|
|
13
|
+
readonly idleWarningMs?: number;
|
|
14
|
+
readonly settleMs?: number;
|
|
15
|
+
readonly deadlineMs?: number;
|
|
16
|
+
readonly expansionMs?: number;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
readonly continuation?: {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly fork?: boolean;
|
|
21
|
+
};
|
|
22
|
+
readonly response?: ResponseSpec<T>;
|
|
23
|
+
readonly observe?: (event: AgentObservation) => void;
|
|
24
|
+
readonly warn?: (message: string) => void;
|
|
25
|
+
readonly diagnostic?: (message: string) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface Turn {
|
|
28
|
+
readonly text: string;
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly conversation?: string;
|
|
31
|
+
readonly transcript?: string;
|
|
32
|
+
readonly usage: Usage;
|
|
33
|
+
readonly durationMs: number;
|
|
34
|
+
}
|
|
35
|
+
export interface Execution<T> {
|
|
36
|
+
readonly text: string;
|
|
37
|
+
readonly turns: readonly Turn[];
|
|
38
|
+
readonly usage: Usage;
|
|
39
|
+
readonly conversation?: string;
|
|
40
|
+
readonly value: T;
|
|
41
|
+
readonly completed: boolean;
|
|
42
|
+
readonly completion?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function notify<T>(observer: ((value: T) => void) | undefined, value: T): void;
|
|
45
|
+
export declare function renderBrief(brief: Brief, workspace: WorkspaceRecord, lease: SandboxLease, host: boolean, options: Pick<DispatchOptions, "signal" | "expansionMs" | "warn" | "diagnostic">): Promise<string>;
|
|
46
|
+
export declare function validateDispatch(options: DispatchOptions<unknown>): void;
|
|
47
|
+
export declare function preflightDispatch(options: DispatchOptions<unknown>, _repository: string, agent?: AgentAdapter): Promise<void>;
|
|
48
|
+
export declare function execute<T>(workspace: WorkspaceRecord, lease: SandboxLease, agent: AgentAdapter, host: boolean, options: DispatchOptions<T>, afterTurn?: (turn: Turn) => Promise<Turn>): Promise<Execution<T>>;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { OutpostError, invariant, positive } from "../domain/errors.js";
|
|
4
|
+
import { prepareBrief, validateBrief } from "../domain/prompts.js";
|
|
5
|
+
import { ResponseError } from "../domain/response.js";
|
|
6
|
+
import { shell } from "../infrastructure/process.js";
|
|
7
|
+
export function notify(observer, value) {
|
|
8
|
+
try {
|
|
9
|
+
observer?.(value);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* Observers cannot change execution outcomes. */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function renderBrief(brief, workspace, lease, host, options) {
|
|
16
|
+
if (brief.text !== undefined)
|
|
17
|
+
return brief.text;
|
|
18
|
+
const source = await readFile(resolve(brief.file), "utf8");
|
|
19
|
+
const prepared = prepareBrief(source, brief.values, {
|
|
20
|
+
WORK_BRANCH: workspace.branch,
|
|
21
|
+
BASE_BRANCH: workspace.baseBranch,
|
|
22
|
+
});
|
|
23
|
+
if (prepared.unused.length)
|
|
24
|
+
notify(options.warn, `Unused prompt variables: ${prepared.unused.join(", ")}`);
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const signal = options.signal
|
|
27
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
28
|
+
: controller.signal;
|
|
29
|
+
const pending = prepared.fragments.map(async (fragment) => {
|
|
30
|
+
if (fragment.kind === "literal")
|
|
31
|
+
return fragment.value;
|
|
32
|
+
try {
|
|
33
|
+
const command = host
|
|
34
|
+
? shell(fragment.value)
|
|
35
|
+
: { executable: "sh", arguments: ["-c", fragment.value] };
|
|
36
|
+
const result = await lease.invoke({
|
|
37
|
+
...command,
|
|
38
|
+
signal,
|
|
39
|
+
deadlineMs: options.expansionMs ?? 30_000,
|
|
40
|
+
});
|
|
41
|
+
if (result.status !== 0)
|
|
42
|
+
throw new OutpostError("prompt", "Prompt command failed", {
|
|
43
|
+
command: fragment.value,
|
|
44
|
+
...result,
|
|
45
|
+
});
|
|
46
|
+
notify(options.diagnostic, `Prompt command expanded to approximately ${Math.ceil(result.stdout.length / 4)} tokens`);
|
|
47
|
+
return result.stdout.trimEnd();
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
controller.abort(cause);
|
|
51
|
+
throw cause;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const results = await Promise.allSettled(pending);
|
|
55
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
56
|
+
if (failure?.status === "rejected")
|
|
57
|
+
throw failure.reason;
|
|
58
|
+
return results
|
|
59
|
+
.map((result) => result.value)
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
62
|
+
export function validateDispatch(options) {
|
|
63
|
+
options.signal?.throwIfAborted();
|
|
64
|
+
validateBrief(options.brief);
|
|
65
|
+
positive(options.passes ?? 1, "passes");
|
|
66
|
+
invariant(Number.isSafeInteger(options.passes ?? 1), "passes must be an integer");
|
|
67
|
+
for (const key of [
|
|
68
|
+
"idleMs",
|
|
69
|
+
"idleWarningMs",
|
|
70
|
+
"settleMs",
|
|
71
|
+
"deadlineMs",
|
|
72
|
+
"expansionMs",
|
|
73
|
+
])
|
|
74
|
+
if (options[key] !== undefined)
|
|
75
|
+
positive(options[key], key);
|
|
76
|
+
if (options.response || options.continuation)
|
|
77
|
+
invariant((options.passes ?? 1) === 1, "Structured responses and continuations require one pass");
|
|
78
|
+
const markers = options.until === undefined
|
|
79
|
+
? []
|
|
80
|
+
: typeof options.until === "string"
|
|
81
|
+
? [options.until]
|
|
82
|
+
: options.until;
|
|
83
|
+
invariant(markers.every((marker) => marker.length > 0), "Completion markers cannot be empty");
|
|
84
|
+
}
|
|
85
|
+
export async function preflightDispatch(options, _repository, agent) {
|
|
86
|
+
validateDispatch(options);
|
|
87
|
+
if (!options.response)
|
|
88
|
+
return;
|
|
89
|
+
if (options.response.repairs > 0)
|
|
90
|
+
invariant(agent?.resumable ?? !!(agent?.conversations || agent?.storage), "Response repair requires an adapter that supports continuation");
|
|
91
|
+
const brief = options.brief;
|
|
92
|
+
const text = brief.text ?? (await readFile(resolve(brief.file), "utf8"));
|
|
93
|
+
const resolved = text.replace(/\{\{\s*(\w+)\s*\}\}/g, (token, key) => brief.values?.[key] === undefined ? token : String(brief.values[key]));
|
|
94
|
+
invariant(resolved.includes(`<${options.response.tag}>`), `Brief must request an opening <${options.response.tag}> tag`);
|
|
95
|
+
}
|
|
96
|
+
async function turn(lease, agent, prompt, options, continuation, markers, pass) {
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const signal = options.signal
|
|
100
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
101
|
+
: controller.signal;
|
|
102
|
+
let idle, settle;
|
|
103
|
+
let pending = "", text = "", conversation, failure;
|
|
104
|
+
let finalText, rawTail = "", lastActivity = Date.now();
|
|
105
|
+
let usage = { input: 0, cached: 0, output: 0 };
|
|
106
|
+
let completed = false;
|
|
107
|
+
const refresh = () => {
|
|
108
|
+
lastActivity = Date.now();
|
|
109
|
+
clearTimeout(idle);
|
|
110
|
+
clearTimeout(settle);
|
|
111
|
+
if (!completed)
|
|
112
|
+
idle = setTimeout(() => controller.abort(new OutpostError("timeout", "Agent produced no output before the idle deadline")), options.idleMs ?? 600_000);
|
|
113
|
+
if (completed)
|
|
114
|
+
settle = setTimeout(() => controller.abort("completion"), options.settleMs ?? 60_000);
|
|
115
|
+
};
|
|
116
|
+
const consume = (line) => {
|
|
117
|
+
const at = new Date().toISOString();
|
|
118
|
+
notify(options.observe, { kind: "raw", value: line, pass, at });
|
|
119
|
+
for (const event of agent.events(line)) {
|
|
120
|
+
if (event.kind === "text")
|
|
121
|
+
text += event.text;
|
|
122
|
+
if (event.kind === "result")
|
|
123
|
+
finalText = event.text;
|
|
124
|
+
if (event.kind === "conversation")
|
|
125
|
+
conversation = event.id;
|
|
126
|
+
if (event.kind === "failure")
|
|
127
|
+
failure = event.message;
|
|
128
|
+
if (event.kind === "usage")
|
|
129
|
+
usage = {
|
|
130
|
+
input: usage.input + event.tokens.input,
|
|
131
|
+
cached: usage.cached + event.tokens.cached,
|
|
132
|
+
output: usage.output + event.tokens.output,
|
|
133
|
+
...(usage.cacheCreated !== undefined ||
|
|
134
|
+
event.tokens.cacheCreated !== undefined
|
|
135
|
+
? {
|
|
136
|
+
cacheCreated: (usage.cacheCreated ?? 0) + (event.tokens.cacheCreated ?? 0),
|
|
137
|
+
}
|
|
138
|
+
: {}),
|
|
139
|
+
};
|
|
140
|
+
if (event.kind !== "raw")
|
|
141
|
+
notify(options.observe, { ...event, pass, at });
|
|
142
|
+
}
|
|
143
|
+
completed = markers.some((marker) => (finalText ?? text).includes(marker));
|
|
144
|
+
};
|
|
145
|
+
const warningInterval = options.idleWarningMs ?? 60_000;
|
|
146
|
+
const warnings = setInterval(() => {
|
|
147
|
+
if (Date.now() - lastActivity >= warningInterval) {
|
|
148
|
+
const message = `Agent has been idle for ${Math.floor((Date.now() - lastActivity) / 1000)} seconds`;
|
|
149
|
+
notify(options.warn, message);
|
|
150
|
+
notify(options.observe, {
|
|
151
|
+
kind: "warning",
|
|
152
|
+
message,
|
|
153
|
+
pass,
|
|
154
|
+
at: new Date().toISOString(),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}, warningInterval);
|
|
158
|
+
refresh();
|
|
159
|
+
let status = 0;
|
|
160
|
+
try {
|
|
161
|
+
const command = agent.request({
|
|
162
|
+
text: prompt,
|
|
163
|
+
...(continuation ? { continuation } : {}),
|
|
164
|
+
});
|
|
165
|
+
const result = await lease.invoke({
|
|
166
|
+
...command,
|
|
167
|
+
signal,
|
|
168
|
+
deadlineMs: options.deadlineMs ?? 3_600_000,
|
|
169
|
+
observe(channel, chunk) {
|
|
170
|
+
if (channel === "stdout") {
|
|
171
|
+
rawTail = (rawTail + chunk).slice(-65_536);
|
|
172
|
+
pending += chunk;
|
|
173
|
+
let end;
|
|
174
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
175
|
+
consume(pending.slice(0, end));
|
|
176
|
+
pending = pending.slice(end + 1);
|
|
177
|
+
}
|
|
178
|
+
if (pending.length > 16_777_216)
|
|
179
|
+
controller.abort(new OutpostError("process", "Agent emitted an oversized event"));
|
|
180
|
+
}
|
|
181
|
+
refresh();
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
status = result.status;
|
|
185
|
+
if (pending)
|
|
186
|
+
consume(pending);
|
|
187
|
+
if (status !== 0)
|
|
188
|
+
throw new OutpostError("process", `Agent exited with status ${status}`, {
|
|
189
|
+
...result,
|
|
190
|
+
conversation,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (cause) {
|
|
194
|
+
options.signal?.throwIfAborted();
|
|
195
|
+
if (controller.signal.reason !== "completion")
|
|
196
|
+
throw controller.signal.reason instanceof OutpostError
|
|
197
|
+
? controller.signal.reason
|
|
198
|
+
: cause;
|
|
199
|
+
notify(options.warn, "Agent remained active after completion; its command was stopped and trailing output retained");
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
clearTimeout(idle);
|
|
203
|
+
clearTimeout(settle);
|
|
204
|
+
clearInterval(warnings);
|
|
205
|
+
}
|
|
206
|
+
if (failure)
|
|
207
|
+
throw new OutpostError("process", failure, { conversation });
|
|
208
|
+
return {
|
|
209
|
+
text: finalText ?? (text || rawTail),
|
|
210
|
+
status,
|
|
211
|
+
usage,
|
|
212
|
+
durationMs: Date.now() - start,
|
|
213
|
+
...(conversation ? { conversation } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export async function execute(workspace, lease, agent, host, options, afterTurn) {
|
|
217
|
+
validateDispatch(options);
|
|
218
|
+
const markers = options.until === undefined
|
|
219
|
+
? ["<outpost>done</outpost>"]
|
|
220
|
+
: typeof options.until === "string"
|
|
221
|
+
? [options.until]
|
|
222
|
+
: options.until;
|
|
223
|
+
const turns = [];
|
|
224
|
+
let value = undefined, completed = false, continuation = options.continuation;
|
|
225
|
+
let repair;
|
|
226
|
+
const attempts = options.response
|
|
227
|
+
? 1 + options.response.repairs
|
|
228
|
+
: (options.passes ?? 1);
|
|
229
|
+
for (let index = 0; index < attempts; index++) {
|
|
230
|
+
notify(options.observe, {
|
|
231
|
+
kind: "phase",
|
|
232
|
+
name: "preparing prompt",
|
|
233
|
+
agent: agent.name,
|
|
234
|
+
branch: workspace.branch,
|
|
235
|
+
directory: workspace.directory,
|
|
236
|
+
pass: index + 1,
|
|
237
|
+
at: new Date().toISOString(),
|
|
238
|
+
});
|
|
239
|
+
const prompt = repair ??
|
|
240
|
+
(await renderBrief(options.brief, workspace, lease, host, options));
|
|
241
|
+
if (options.response && !repair)
|
|
242
|
+
invariant(prompt.includes(`<${options.response.tag}>`), `Brief must request an opening <${options.response.tag}> tag`);
|
|
243
|
+
notify(options.observe, {
|
|
244
|
+
kind: "prompt",
|
|
245
|
+
text: prompt,
|
|
246
|
+
pass: index + 1,
|
|
247
|
+
at: new Date().toISOString(),
|
|
248
|
+
});
|
|
249
|
+
notify(options.observe, {
|
|
250
|
+
kind: "phase",
|
|
251
|
+
name: "running",
|
|
252
|
+
agent: agent.name,
|
|
253
|
+
branch: workspace.branch,
|
|
254
|
+
pass: index + 1,
|
|
255
|
+
at: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
const finished = await turn(lease, agent, prompt, options, continuation, markers, index + 1);
|
|
258
|
+
const current = afterTurn ? await afterTurn(finished) : finished;
|
|
259
|
+
turns.push(current);
|
|
260
|
+
notify(options.observe, {
|
|
261
|
+
kind: "summary",
|
|
262
|
+
durationMs: current.durationMs,
|
|
263
|
+
status: current.status,
|
|
264
|
+
tokens: current.usage,
|
|
265
|
+
pass: index + 1,
|
|
266
|
+
at: new Date().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
completed = markers.some((marker) => current.text.includes(marker));
|
|
269
|
+
if (options.response) {
|
|
270
|
+
try {
|
|
271
|
+
value = await options.response.read(current.text);
|
|
272
|
+
completed = true;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!(error instanceof ResponseError))
|
|
277
|
+
throw error;
|
|
278
|
+
error.recovery = {
|
|
279
|
+
conversation: current.conversation,
|
|
280
|
+
branch: workspace.branch,
|
|
281
|
+
directory: workspace.directory,
|
|
282
|
+
turns,
|
|
283
|
+
};
|
|
284
|
+
if (index + 1 >= attempts || !current.conversation)
|
|
285
|
+
throw error;
|
|
286
|
+
continuation = { id: current.conversation };
|
|
287
|
+
repair = [
|
|
288
|
+
`Correct the response inside <${options.response.tag}> and </${options.response.tag}>.`,
|
|
289
|
+
`Validation failure: ${error.message}`,
|
|
290
|
+
`Previous content: ${error.raw ?? "No complete tagged response was returned."}`,
|
|
291
|
+
`Cause: ${error.cause instanceof Error ? error.cause.message : (JSON.stringify(error.cause) ?? "unspecified")}`,
|
|
292
|
+
`Further repair attempts after this one: ${attempts - index - 2}.`,
|
|
293
|
+
"Do not edit files, run commands or continue implementation. Only return the corrected response.",
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (completed)
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
const conversation = turns.at(-1)?.conversation;
|
|
301
|
+
const completion = markers.find((marker) => turns.at(-1)?.text.includes(marker));
|
|
302
|
+
return {
|
|
303
|
+
text: turns.map((item) => item.text).join("\n"),
|
|
304
|
+
turns,
|
|
305
|
+
value,
|
|
306
|
+
completed,
|
|
307
|
+
...(completion ? { completion } : {}),
|
|
308
|
+
...(conversation ? { conversation } : {}),
|
|
309
|
+
usage: turns.reduce((sum, item) => ({
|
|
310
|
+
input: sum.input + item.usage.input,
|
|
311
|
+
cached: sum.cached + item.usage.cached,
|
|
312
|
+
output: sum.output + item.usage.output,
|
|
313
|
+
...(sum.cacheCreated !== undefined ||
|
|
314
|
+
item.usage.cacheCreated !== undefined
|
|
315
|
+
? {
|
|
316
|
+
cacheCreated: (sum.cacheCreated ?? 0) + (item.usage.cacheCreated ?? 0),
|
|
317
|
+
}
|
|
318
|
+
: {}),
|
|
319
|
+
}), { input: 0, cached: 0, output: 0 }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=execution.js.map
|