@saadnvd1/agent-os 0.3.508 → 0.3.510
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/app/api/git/check/route.ts +2 -31
- package/app/api/projects/[id]/machines/route.ts +2 -18
- package/app/api/start-options/route.ts +22 -0
- package/app/api/tasks/[id]/checks/route.ts +37 -0
- package/app/api/tasks/[id]/diff/route.ts +35 -0
- package/data/models/index.ts +1 -11
- package/lib/connect/heartbeat.ts +88 -0
- package/lib/connect/serve.ts +44 -3
- package/lib/git.ts +24 -0
- package/lib/hosts/project-machines.ts +19 -0
- package/lib/models/choices.ts +17 -0
- package/lib/orchestrator/repo.ts +1 -1
- package/lib/orchestrator/signoff.ts +1 -1
- package/lib/start-options.ts +93 -0
- package/lib/tasks/checks-view.ts +192 -0
- package/lib/tasks/diff.ts +259 -0
- package/lib/tasks/gh.ts +1 -1
- package/lib/tasks/index.ts +5 -8
- package/lib/tasks/loop-error.ts +21 -0
- package/lib/tasks/loop-types.ts +202 -0
- package/lib/tasks/plan-view.ts +127 -0
- package/lib/tasks/session.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's PR checks for the phone: each check's name, state, link and
|
|
3
|
+
* duration, the tail of a failing Actions job's log (the same one AgentOS
|
|
4
|
+
* sends a task when its CI fails), and the review gate's verdict on the head.
|
|
5
|
+
* Log and verdict text is untrusted: displayed, never run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getCheck } from "../orchestrator/checks";
|
|
9
|
+
import { restackedOf } from "../orchestrator/signoff";
|
|
10
|
+
import { run, settledRollup } from "./gh";
|
|
11
|
+
import { failedLog, repoOfPR, type FailedLog } from "./pr-activity";
|
|
12
|
+
import { prFor, projectPathFor, storedPR } from "./session";
|
|
13
|
+
import type { RollupEntry } from "./state";
|
|
14
|
+
import {
|
|
15
|
+
CHECK_LOGS_MAX,
|
|
16
|
+
CHECK_LOG_MAX_BYTES,
|
|
17
|
+
parseReviewDetail,
|
|
18
|
+
type CheckState,
|
|
19
|
+
type TaskCheck,
|
|
20
|
+
type TaskChecks,
|
|
21
|
+
type TaskReviewVerdict,
|
|
22
|
+
} from "./loop-types";
|
|
23
|
+
import type { Session } from "../db";
|
|
24
|
+
|
|
25
|
+
const outcome = (c: RollupEntry) =>
|
|
26
|
+
(c.conclusion || c.state || c.status || "").toUpperCase();
|
|
27
|
+
|
|
28
|
+
export function checkState(c: RollupEntry): CheckState {
|
|
29
|
+
switch (outcome(c)) {
|
|
30
|
+
case "SUCCESS":
|
|
31
|
+
return "pass";
|
|
32
|
+
case "FAILURE":
|
|
33
|
+
case "ERROR":
|
|
34
|
+
case "TIMED_OUT":
|
|
35
|
+
case "ACTION_REQUIRED":
|
|
36
|
+
return "fail";
|
|
37
|
+
case "SKIPPED":
|
|
38
|
+
return "skipped";
|
|
39
|
+
case "CANCELLED":
|
|
40
|
+
return "cancelled";
|
|
41
|
+
case "NEUTRAL":
|
|
42
|
+
case "STALE":
|
|
43
|
+
return "neutral";
|
|
44
|
+
default:
|
|
45
|
+
return "pending";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// gh gives a run not finished yet a zero completedAt (0001-01-01).
|
|
50
|
+
const time = (t?: string | null) => {
|
|
51
|
+
const ms = Date.parse(t || "");
|
|
52
|
+
return ms > 0 ? ms : null;
|
|
53
|
+
};
|
|
54
|
+
const iso = (t?: string | null) => {
|
|
55
|
+
const ms = time(t);
|
|
56
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// A status context can carry any URL; only a web one is a link.
|
|
60
|
+
const httpsUrl = (u?: string | null) =>
|
|
61
|
+
u && /^https:\/\/[^\s]+$/.test(u) ? u : null;
|
|
62
|
+
|
|
63
|
+
export function toTaskChecks(rollup: RollupEntry[]): TaskCheck[] {
|
|
64
|
+
return rollup.map((c) => {
|
|
65
|
+
const start = time(c.startedAt);
|
|
66
|
+
const end = time(c.completedAt);
|
|
67
|
+
return {
|
|
68
|
+
name: c.name ?? c.context ?? "(unnamed)",
|
|
69
|
+
workflow: c.workflowName || null,
|
|
70
|
+
state: checkState(c),
|
|
71
|
+
conclusion: (c.conclusion || c.state || null)?.toLowerCase() ?? null,
|
|
72
|
+
url: httpsUrl(c.detailsUrl),
|
|
73
|
+
startedAt: iso(c.startedAt),
|
|
74
|
+
completedAt: iso(c.completedAt),
|
|
75
|
+
durationSec:
|
|
76
|
+
start !== null && end !== null && end >= start
|
|
77
|
+
? Math.round((end - start) / 1000)
|
|
78
|
+
: null,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A finished job's log never changes.
|
|
84
|
+
const logCache = new Map<string, FailedLog | null>();
|
|
85
|
+
const LOG_CACHE_MAX = 200;
|
|
86
|
+
export const resetLogCache = () => logCache.clear();
|
|
87
|
+
|
|
88
|
+
export interface ChecksDeps {
|
|
89
|
+
failedLog: typeof failedLog;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function taskChecks(
|
|
93
|
+
session: Session,
|
|
94
|
+
opts: { logs?: boolean; deps?: ChecksDeps } = {}
|
|
95
|
+
): Promise<TaskChecks> {
|
|
96
|
+
const deps = opts.deps ?? { failedLog };
|
|
97
|
+
const dir = projectPathFor(session);
|
|
98
|
+
const live = session.task_status === "running";
|
|
99
|
+
const pr = dir
|
|
100
|
+
? live
|
|
101
|
+
? await prFor(session).catch(() => null)
|
|
102
|
+
: storedPR(session)
|
|
103
|
+
: null;
|
|
104
|
+
if (!pr || !dir) return { pr: null, checks: [], review: null };
|
|
105
|
+
const head = pr.head ?? null;
|
|
106
|
+
|
|
107
|
+
let checks: TaskCheck[] = [];
|
|
108
|
+
let rollup: RollupEntry[] = [];
|
|
109
|
+
try {
|
|
110
|
+
const out = JSON.parse(
|
|
111
|
+
await run(
|
|
112
|
+
"gh",
|
|
113
|
+
[
|
|
114
|
+
"pr",
|
|
115
|
+
"view",
|
|
116
|
+
String(pr.number),
|
|
117
|
+
"--json",
|
|
118
|
+
"headRefOid,statusCheckRollup",
|
|
119
|
+
],
|
|
120
|
+
dir,
|
|
121
|
+
20000
|
|
122
|
+
)
|
|
123
|
+
) as { headRefOid?: string; statusCheckRollup?: RollupEntry[] | null };
|
|
124
|
+
rollup = out.statusCheckRollup ?? [];
|
|
125
|
+
if (pr.state === "OPEN")
|
|
126
|
+
rollup = await settledRollup(dir, pr.url, out.headRefOid, rollup);
|
|
127
|
+
checks = toTaskChecks(rollup);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`Reading ${session.name}'s checks:`, error);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const repo = repoOfPR(pr.url);
|
|
133
|
+
if (opts.logs !== false && repo) {
|
|
134
|
+
const failing = checks
|
|
135
|
+
.map((c, i) => ({ c, url: c.state === "fail" ? c.url : null, i }))
|
|
136
|
+
.filter((x) => x.url)
|
|
137
|
+
.slice(0, CHECK_LOGS_MAX);
|
|
138
|
+
await Promise.all(
|
|
139
|
+
failing.map(async ({ c, url }) => {
|
|
140
|
+
let log = logCache.get(url!);
|
|
141
|
+
if (log === undefined) {
|
|
142
|
+
log = await deps.failedLog(dir, repo, url).catch((error) => {
|
|
143
|
+
console.error(`Reading ${session.name}'s CI log:`, error);
|
|
144
|
+
return null;
|
|
145
|
+
});
|
|
146
|
+
if (log) {
|
|
147
|
+
if (logCache.size >= LOG_CACHE_MAX) logCache.clear();
|
|
148
|
+
logCache.set(url!, log);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (log) {
|
|
152
|
+
const tail = log.tail.slice(-CHECK_LOG_MAX_BYTES);
|
|
153
|
+
c.log = {
|
|
154
|
+
step: log.step,
|
|
155
|
+
tail,
|
|
156
|
+
truncated: tail.length < log.tail.length,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
pr: { number: pr.number, url: pr.url, state: pr.state, head },
|
|
165
|
+
checks,
|
|
166
|
+
review: head ? reviewVerdict(session, head) : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The review covering the head: its own, or the one of the head the task
|
|
171
|
+
// pushed while the head is exactly the one AgentOS restacked it to.
|
|
172
|
+
export function reviewVerdict(
|
|
173
|
+
session: Session,
|
|
174
|
+
head: string
|
|
175
|
+
): TaskReviewVerdict | null {
|
|
176
|
+
const restacked = restackedOf(session);
|
|
177
|
+
const row =
|
|
178
|
+
getCheck(session.id, head, "review") ??
|
|
179
|
+
(restacked?.to === head && restacked.from
|
|
180
|
+
? getCheck(session.id, restacked.from, "review")
|
|
181
|
+
: null);
|
|
182
|
+
if (!row) return null;
|
|
183
|
+
const { summary, findings, truncated } = parseReviewDetail(row.detail);
|
|
184
|
+
return {
|
|
185
|
+
sha: row.sha,
|
|
186
|
+
status: row.status,
|
|
187
|
+
summary,
|
|
188
|
+
findings,
|
|
189
|
+
at: row.created_at,
|
|
190
|
+
truncated,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's whole change, base branch to head (merge-base), for the phone to
|
|
3
|
+
* review: a file list with status and line counts, then one file's patch.
|
|
4
|
+
* Read from the task's worktree when it's there, else from its PR on GitHub.
|
|
5
|
+
* Sizes are capped and say so; a file is only ever one the change lists.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { git, isGitError, resolveRef } from "../git-exec";
|
|
9
|
+
import { getDefaultBranch, isGitRepo, isBranchName } from "../git";
|
|
10
|
+
import { existingRef } from "../orchestrator/repo";
|
|
11
|
+
import { run } from "./gh";
|
|
12
|
+
import { repoOfPR } from "./pr-activity";
|
|
13
|
+
import { localWorktree, projectPathFor } from "./session";
|
|
14
|
+
import { LoopError } from "./loop-error";
|
|
15
|
+
import {
|
|
16
|
+
DIFF_MAX_FILES,
|
|
17
|
+
DIFF_PATCH_MAX_BYTES,
|
|
18
|
+
type TaskDiffFile,
|
|
19
|
+
type TaskDiffList,
|
|
20
|
+
type TaskDiffPatch,
|
|
21
|
+
} from "./loop-types";
|
|
22
|
+
import type { Session } from "../db";
|
|
23
|
+
|
|
24
|
+
export interface DiffCaps {
|
|
25
|
+
files: number;
|
|
26
|
+
patchBytes: number;
|
|
27
|
+
}
|
|
28
|
+
const CAPS: DiffCaps = {
|
|
29
|
+
files: DIFF_MAX_FILES,
|
|
30
|
+
patchBytes: DIFF_PATCH_MAX_BYTES,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_PATH_BYTES = 1024;
|
|
34
|
+
|
|
35
|
+
/** A path the change could list: relative, inside the repo, no tricks. */
|
|
36
|
+
export function validDiffPath(file: unknown): file is string {
|
|
37
|
+
if (typeof file !== "string" || !file) return false;
|
|
38
|
+
if (Buffer.byteLength(file) > MAX_PATH_BYTES) return false;
|
|
39
|
+
if (file.includes("\0") || file.includes("\\")) return false;
|
|
40
|
+
if (file.startsWith("/") || file.startsWith("-")) return false;
|
|
41
|
+
return !file.split("/").some((part) => part === ".." || part === "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATUS_OF_GITHUB: Record<string, string> = {
|
|
45
|
+
added: "A",
|
|
46
|
+
removed: "D",
|
|
47
|
+
modified: "M",
|
|
48
|
+
changed: "M",
|
|
49
|
+
renamed: "R",
|
|
50
|
+
copied: "C",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
interface Source {
|
|
54
|
+
kind: "worktree" | "pr";
|
|
55
|
+
base: string;
|
|
56
|
+
head: string;
|
|
57
|
+
list: () => Promise<TaskDiffFile[]>;
|
|
58
|
+
patch: (file: string) => Promise<{ patch: string | null; tooLarge: boolean }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `git diff --numstat -z` and `--name-status -z`, joined by path.
|
|
62
|
+
function parseGit(numstat: string, nameStatus: string): TaskDiffFile[] {
|
|
63
|
+
const status = new Map<string, string>();
|
|
64
|
+
const parts = nameStatus.split("\0");
|
|
65
|
+
for (let i = 0; i + 1 < parts.length; i += 2)
|
|
66
|
+
status.set(parts[i + 1], parts[i][0] || "M");
|
|
67
|
+
const files: TaskDiffFile[] = [];
|
|
68
|
+
for (const row of numstat.split("\0")) {
|
|
69
|
+
const m = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(row);
|
|
70
|
+
if (!m) continue;
|
|
71
|
+
const binary = m[1] === "-";
|
|
72
|
+
files.push({
|
|
73
|
+
path: m[3],
|
|
74
|
+
status: status.get(m[3]) ?? "M",
|
|
75
|
+
additions: binary ? null : Number(m[1]),
|
|
76
|
+
deletions: binary ? null : Number(m[2]),
|
|
77
|
+
binary,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function worktreeSource(
|
|
84
|
+
session: Session,
|
|
85
|
+
dir: string,
|
|
86
|
+
caps: DiffCaps
|
|
87
|
+
): Promise<Source> {
|
|
88
|
+
if (!(await isGitRepo(dir))) throw new LoopError("No repository", 404);
|
|
89
|
+
const recorded = session.base_branch || (await getDefaultBranch(dir));
|
|
90
|
+
if (!isBranchName(recorded)) throw new LoopError("Unknown base branch", 404);
|
|
91
|
+
const base = await existingRef(dir, recorded);
|
|
92
|
+
if (!base) throw new LoopError("The base branch isn't here", 404);
|
|
93
|
+
const head = (await resolveRef(dir, "HEAD")) ?? "";
|
|
94
|
+
const range = `${base}...HEAD`;
|
|
95
|
+
const flags = ["--no-renames", "--no-ext-diff", "--no-textconv"];
|
|
96
|
+
return {
|
|
97
|
+
kind: "worktree",
|
|
98
|
+
base: recorded,
|
|
99
|
+
head,
|
|
100
|
+
list: async () => {
|
|
101
|
+
const [numstat, names] = await Promise.all([
|
|
102
|
+
git(dir, ["diff", "--numstat", "-z", ...flags, range, "--"]),
|
|
103
|
+
git(dir, ["diff", "--name-status", "-z", ...flags, range, "--"]),
|
|
104
|
+
]);
|
|
105
|
+
return parseGit(numstat, names);
|
|
106
|
+
},
|
|
107
|
+
patch: async (file) => {
|
|
108
|
+
try {
|
|
109
|
+
const out = await git(
|
|
110
|
+
dir,
|
|
111
|
+
["diff", "--no-color", ...flags, range, "--", file],
|
|
112
|
+
{ literalPathspecs: true, maxBuffer: caps.patchBytes * 4 }
|
|
113
|
+
);
|
|
114
|
+
return { patch: out, tooLarge: false };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (
|
|
117
|
+
isGitError(error) &&
|
|
118
|
+
error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
119
|
+
)
|
|
120
|
+
return { patch: null, tooLarge: true };
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface GithubFile {
|
|
128
|
+
filename?: string;
|
|
129
|
+
status?: string;
|
|
130
|
+
additions?: number;
|
|
131
|
+
deletions?: number;
|
|
132
|
+
patch?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function prSource(session: Session): Promise<Source> {
|
|
136
|
+
const repo = session.pr_url ? repoOfPR(session.pr_url) : null;
|
|
137
|
+
const dir = projectPathFor(session);
|
|
138
|
+
if (!repo || !session.pr_number || !dir)
|
|
139
|
+
throw new LoopError("The task has no worktree and no pull request", 404);
|
|
140
|
+
const slug = `${repo.owner}/${repo.name}`;
|
|
141
|
+
const meta = JSON.parse(
|
|
142
|
+
await run(
|
|
143
|
+
"gh",
|
|
144
|
+
[
|
|
145
|
+
"pr",
|
|
146
|
+
"view",
|
|
147
|
+
String(session.pr_number),
|
|
148
|
+
"--repo",
|
|
149
|
+
slug,
|
|
150
|
+
"--json",
|
|
151
|
+
"baseRefName,headRefOid",
|
|
152
|
+
],
|
|
153
|
+
dir,
|
|
154
|
+
15000
|
|
155
|
+
)
|
|
156
|
+
) as { baseRefName?: string; headRefOid?: string };
|
|
157
|
+
const pages = async (): Promise<GithubFile[]> => {
|
|
158
|
+
const all: GithubFile[] = [];
|
|
159
|
+
for (let page = 1; page <= 3; page++) {
|
|
160
|
+
const rows = JSON.parse(
|
|
161
|
+
await run(
|
|
162
|
+
"gh",
|
|
163
|
+
[
|
|
164
|
+
"api",
|
|
165
|
+
`repos/${slug}/pulls/${session.pr_number}/files?per_page=100&page=${page}`,
|
|
166
|
+
],
|
|
167
|
+
dir,
|
|
168
|
+
30000
|
|
169
|
+
)
|
|
170
|
+
) as GithubFile[];
|
|
171
|
+
all.push(...rows);
|
|
172
|
+
if (rows.length < 100) break;
|
|
173
|
+
}
|
|
174
|
+
return all;
|
|
175
|
+
};
|
|
176
|
+
let cached: GithubFile[] | null = null;
|
|
177
|
+
const rows = async () => (cached ??= await pages());
|
|
178
|
+
return {
|
|
179
|
+
kind: "pr",
|
|
180
|
+
base: meta.baseRefName ?? "",
|
|
181
|
+
head: meta.headRefOid ?? "",
|
|
182
|
+
list: async () =>
|
|
183
|
+
(await rows())
|
|
184
|
+
.filter((f) => typeof f.filename === "string")
|
|
185
|
+
.map((f) => ({
|
|
186
|
+
path: f.filename!,
|
|
187
|
+
status: STATUS_OF_GITHUB[f.status ?? ""] ?? "M",
|
|
188
|
+
additions: f.additions ?? 0,
|
|
189
|
+
deletions: f.deletions ?? 0,
|
|
190
|
+
binary: false,
|
|
191
|
+
})),
|
|
192
|
+
patch: async (file) => {
|
|
193
|
+
const hit = (await rows()).find((f) => f.filename === file);
|
|
194
|
+
return typeof hit?.patch === "string"
|
|
195
|
+
? { patch: hit.patch, tooLarge: false }
|
|
196
|
+
: { patch: null, tooLarge: true };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function sourceFor(session: Session, caps: DiffCaps): Promise<Source> {
|
|
202
|
+
const dir = localWorktree(session);
|
|
203
|
+
if (dir) return worktreeSource(session, dir, caps);
|
|
204
|
+
return prSource(session);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Cut at a line break at or before `max` bytes.
|
|
208
|
+
export function capPatch(
|
|
209
|
+
patch: string,
|
|
210
|
+
max: number
|
|
211
|
+
): { patch: string; truncated: boolean } {
|
|
212
|
+
if (Buffer.byteLength(patch) <= max) return { patch, truncated: false };
|
|
213
|
+
const cut = Buffer.from(patch).subarray(0, max).toString("utf8");
|
|
214
|
+
const nl = cut.lastIndexOf("\n");
|
|
215
|
+
return { patch: nl > 0 ? cut.slice(0, nl) : cut, truncated: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The file list, or with `file` that one file's patch. */
|
|
219
|
+
export async function taskDiff(
|
|
220
|
+
session: Session,
|
|
221
|
+
file?: string | null,
|
|
222
|
+
caps: DiffCaps = CAPS
|
|
223
|
+
): Promise<TaskDiffList | TaskDiffPatch> {
|
|
224
|
+
if (file != null && !validDiffPath(file))
|
|
225
|
+
throw new LoopError("Not a path in the change", 400);
|
|
226
|
+
const source = await sourceFor(session, caps);
|
|
227
|
+
const all = await source.list();
|
|
228
|
+
if (file != null) {
|
|
229
|
+
if (!all.some((f) => f.path === file))
|
|
230
|
+
throw new LoopError("That file isn't in the change", 404);
|
|
231
|
+
const { patch, tooLarge } = await source.patch(file);
|
|
232
|
+
if (patch === null)
|
|
233
|
+
return {
|
|
234
|
+
source: source.kind,
|
|
235
|
+
path: file,
|
|
236
|
+
patch: null,
|
|
237
|
+
truncated: true,
|
|
238
|
+
tooLarge: true,
|
|
239
|
+
};
|
|
240
|
+
const capped = capPatch(patch, caps.patchBytes);
|
|
241
|
+
return {
|
|
242
|
+
source: source.kind,
|
|
243
|
+
path: file,
|
|
244
|
+
patch: capped.patch,
|
|
245
|
+
truncated: capped.truncated,
|
|
246
|
+
tooLarge,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
source: source.kind,
|
|
251
|
+
base: source.base,
|
|
252
|
+
head: source.head,
|
|
253
|
+
files: all.slice(0, caps.files),
|
|
254
|
+
totalFiles: all.length,
|
|
255
|
+
additions: all.reduce((n, f) => n + (f.additions ?? 0), 0),
|
|
256
|
+
deletions: all.reduce((n, f) => n + (f.deletions ?? 0), 0),
|
|
257
|
+
truncated: all.length > caps.files,
|
|
258
|
+
};
|
|
259
|
+
}
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -133,7 +133,7 @@ const RUN_CACHE_MAX = 2000;
|
|
|
133
133
|
// The rollup with stale reruns dropped. Only a check that ran more than once
|
|
134
134
|
// costs an API call, one per workflow run attempt. A run outside the PR's
|
|
135
135
|
// repo, or one the API can't describe, leaves its check unsettled.
|
|
136
|
-
async function settledRollup(
|
|
136
|
+
export async function settledRollup(
|
|
137
137
|
repoDir: string,
|
|
138
138
|
prUrl: string,
|
|
139
139
|
head: string | undefined,
|
package/lib/tasks/index.ts
CHANGED
|
@@ -47,6 +47,8 @@ import { expandHome, prFor, storedPR, taskSessions } from "./session";
|
|
|
47
47
|
import { InProgressError } from "./move-bundle";
|
|
48
48
|
import { nameFor } from "../session-titles";
|
|
49
49
|
import { taskSetupOf, type TaskSetup } from "./setup";
|
|
50
|
+
import { taskPlan } from "./plan-view";
|
|
51
|
+
import type { TaskPlan } from "./loop-types";
|
|
50
52
|
import { finishTaskStart } from "./start";
|
|
51
53
|
import { isRemoteHost } from "../hosts";
|
|
52
54
|
import { DEFAULT_START_VIEW, type StartView } from "../sessions/launch";
|
|
@@ -98,7 +100,8 @@ export interface TaskView {
|
|
|
98
100
|
model?: string;
|
|
99
101
|
planning?: boolean;
|
|
100
102
|
// What its starter decided about a plan step, and why; null if unstated.
|
|
101
|
-
|
|
103
|
+
// Its plan: ready to read or approve, with a title and capped text.
|
|
104
|
+
plan?: TaskPlan | null;
|
|
102
105
|
// A quick change: the orchestrator session that makes it (no agent).
|
|
103
106
|
quickOf?: string | null;
|
|
104
107
|
// What it has cost so far in weighted tokens (lib/usage/weights).
|
|
@@ -389,13 +392,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
389
392
|
armed: !!session.merge_armed_at,
|
|
390
393
|
model: sessionModelRef(session),
|
|
391
394
|
planning: session.plan_phase === "planning",
|
|
392
|
-
plan:
|
|
393
|
-
session.plan_decided == null
|
|
394
|
-
? null
|
|
395
|
-
: {
|
|
396
|
-
planned: !!session.plan_decided,
|
|
397
|
-
reason: session.plan_reason ?? null,
|
|
398
|
-
},
|
|
395
|
+
plan: taskPlan(session),
|
|
399
396
|
quickOf: session.quick_of ?? null,
|
|
400
397
|
};
|
|
401
398
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A refusal the loop routes turn into a status code.
|
|
2
|
+
export class LoopError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
message: string,
|
|
5
|
+
readonly status: 400 | 404 | 409
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "LoopError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The task a route names: unknown, not a task, or another machine's. */
|
|
13
|
+
export function localTask<T extends { task_status?: string | null }>(
|
|
14
|
+
session: T | undefined,
|
|
15
|
+
mirror: (s: T) => boolean
|
|
16
|
+
): T {
|
|
17
|
+
if (!session?.task_status) throw new LoopError("Task not found", 404);
|
|
18
|
+
if (mirror(session))
|
|
19
|
+
throw new LoopError("That task runs on another machine", 409);
|
|
20
|
+
return session;
|
|
21
|
+
}
|