@saadnvd1/agent-os 0.3.509 → 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/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
package/README.md
CHANGED
|
@@ -485,6 +485,17 @@ and the same actions in each row's menu; a row becomes the task's own row
|
|
|
485
485
|
when it starts. Why it works
|
|
486
486
|
this way: [docs/decisions/2026-10-10-queued-tasks.md](docs/decisions/2026-10-10-queued-tasks.md).
|
|
487
487
|
|
|
488
|
+
A paired device's token can run the building loop through read-only routes:
|
|
489
|
+
`GET /api/start-options?projectId=` (agents and models, the models a task can
|
|
490
|
+
run on, machines, branches, whether a task plans first, in one request),
|
|
491
|
+
`GET /api/tasks/<id>/diff` (the task's whole change, base to head: a file list,
|
|
492
|
+
then `?file=<path>` for one file's patch; capped, with `truncated` when a cap
|
|
493
|
+
is hit; read from the worktree, else the PR), and `GET /api/tasks/<id>/checks`
|
|
494
|
+
(the PR head's checks, the tail of a failing job's log, and the review gate's
|
|
495
|
+
verdict). A task's `plan` in `GET /api/tasks` carries `status` (`writing`,
|
|
496
|
+
`ready` or `approved`), a title and capped text. Their shapes are in
|
|
497
|
+
`lib/tasks/loop-types.ts`, which has no server imports so a client can share it.
|
|
498
|
+
|
|
488
499
|
## Model routing
|
|
489
500
|
|
|
490
501
|
Each kind of work AgentOS launches an agent for runs on its own model, set in
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
isGitRepo,
|
|
4
|
-
getBranches,
|
|
5
|
-
getDefaultBranch,
|
|
6
|
-
getCurrentBranch,
|
|
7
|
-
} from "@/lib/git";
|
|
2
|
+
import { gitCheck } from "@/lib/git";
|
|
8
3
|
|
|
9
4
|
/**
|
|
10
5
|
* POST /api/git/check
|
|
@@ -19,31 +14,7 @@ export async function POST(request: NextRequest) {
|
|
|
19
14
|
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
const isRepo = await isGitRepo(dirPath);
|
|
24
|
-
|
|
25
|
-
if (!isRepo) {
|
|
26
|
-
return NextResponse.json({
|
|
27
|
-
isGitRepo: false,
|
|
28
|
-
branches: [],
|
|
29
|
-
defaultBranch: null,
|
|
30
|
-
currentBranch: null,
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Get branch info
|
|
35
|
-
const [branches, defaultBranch, currentBranch] = await Promise.all([
|
|
36
|
-
getBranches(dirPath),
|
|
37
|
-
getDefaultBranch(dirPath),
|
|
38
|
-
getCurrentBranch(dirPath),
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
|
-
return NextResponse.json({
|
|
42
|
-
isGitRepo: true,
|
|
43
|
-
branches,
|
|
44
|
-
defaultBranch,
|
|
45
|
-
currentBranch,
|
|
46
|
-
});
|
|
17
|
+
return NextResponse.json(await gitCheck(dirPath));
|
|
47
18
|
} catch (error) {
|
|
48
19
|
console.error("Error checking git repo:", error);
|
|
49
20
|
return NextResponse.json(
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { getProject } from "@/lib/projects";
|
|
3
|
-
import {
|
|
4
|
-
import { projectOnPeers } from "@/lib/hosts/peer-actions";
|
|
5
|
-
import { linkedHostIds } from "@/lib/hosts/remote-api";
|
|
3
|
+
import { projectMachines } from "@/lib/hosts/project-machines";
|
|
6
4
|
|
|
7
5
|
// Which linked machines can take a session for this project, and why not
|
|
8
6
|
// where one can't: { machines: { [hostId]: reason | null } }.
|
|
@@ -13,19 +11,5 @@ export async function GET(
|
|
|
13
11
|
const project = getProject((await params).id);
|
|
14
12
|
if (!project || project.is_uncategorized)
|
|
15
13
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
|
16
|
-
|
|
17
|
-
return NextResponse.json({ machines: {} });
|
|
18
|
-
try {
|
|
19
|
-
return NextResponse.json({
|
|
20
|
-
machines: await projectOnPeers(await projectRef(project)),
|
|
21
|
-
});
|
|
22
|
-
} catch {
|
|
23
|
-
// A project outside the home folder has no place on another machine.
|
|
24
|
-
const reason = "Its folder is outside your home folder";
|
|
25
|
-
return NextResponse.json({
|
|
26
|
-
machines: Object.fromEntries(
|
|
27
|
-
[...linkedHostIds()].map((id) => [id, reason])
|
|
28
|
-
),
|
|
29
|
-
});
|
|
30
|
-
}
|
|
14
|
+
return NextResponse.json({ machines: await projectMachines(project) });
|
|
31
15
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { startOptions, UnknownProjectError } from "@/lib/start-options";
|
|
3
|
+
|
|
4
|
+
// GET /api/start-options?projectId= - everything the start form needs in
|
|
5
|
+
// one request: agents and their models, the models a task can run on, the
|
|
6
|
+
// machines, the project's branches and whether a task plans first. Without
|
|
7
|
+
// a project, the options for a scratch chat.
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
try {
|
|
10
|
+
return NextResponse.json(
|
|
11
|
+
await startOptions(request.nextUrl.searchParams.get("projectId") || null)
|
|
12
|
+
);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof UnknownProjectError)
|
|
15
|
+
return NextResponse.json({ error: error.message }, { status: 404 });
|
|
16
|
+
console.error("Reading start options:", error);
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{ error: "Couldn't read the start options" },
|
|
19
|
+
{ status: 500 }
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { db, queries, type Session } from "@/lib/db";
|
|
3
|
+
import { taskChecks } from "@/lib/tasks/checks-view";
|
|
4
|
+
import { LoopError, localTask } from "@/lib/tasks/loop-error";
|
|
5
|
+
import { isMirror } from "@/lib/tasks/remote";
|
|
6
|
+
|
|
7
|
+
// GET /api/tasks/:id/checks - the PR head's checks, the tail of a failing
|
|
8
|
+
// job's log, and the review gate's verdict on the head. ?log=0 leaves the
|
|
9
|
+
// logs out. Log and verdict text is untrusted: show it, never run it.
|
|
10
|
+
export async function GET(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
13
|
+
) {
|
|
14
|
+
try {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
const session = localTask(
|
|
17
|
+
queries.getSession(db).get(id) as Session | undefined,
|
|
18
|
+
isMirror
|
|
19
|
+
);
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
await taskChecks(session, {
|
|
22
|
+
logs: request.nextUrl.searchParams.get("log") !== "0",
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof LoopError)
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: error.message },
|
|
29
|
+
{ status: error.status }
|
|
30
|
+
);
|
|
31
|
+
console.error("Reading a task's checks:", error);
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: "Couldn't read the task's checks" },
|
|
34
|
+
{ status: 500 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { db, queries, type Session } from "@/lib/db";
|
|
3
|
+
import { taskDiff } from "@/lib/tasks/diff";
|
|
4
|
+
import { LoopError, localTask } from "@/lib/tasks/loop-error";
|
|
5
|
+
import { isMirror } from "@/lib/tasks/remote";
|
|
6
|
+
|
|
7
|
+
// GET /api/tasks/:id/diff - the task's whole change, base to head: a file
|
|
8
|
+
// list with status and line counts; with ?file=<path>, that file's patch.
|
|
9
|
+
// Sizes are capped and say "truncated" when a cap was hit.
|
|
10
|
+
export async function GET(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
13
|
+
) {
|
|
14
|
+
try {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
const session = localTask(
|
|
17
|
+
queries.getSession(db).get(id) as Session | undefined,
|
|
18
|
+
isMirror
|
|
19
|
+
);
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
await taskDiff(session, request.nextUrl.searchParams.get("file"))
|
|
22
|
+
);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error instanceof LoopError)
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
{ error: error.message },
|
|
27
|
+
{ status: error.status }
|
|
28
|
+
);
|
|
29
|
+
console.error("Reading a task's diff:", error);
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Couldn't read the task's change" },
|
|
32
|
+
{ status: 500 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/data/models/index.ts
CHANGED
|
@@ -112,14 +112,4 @@ export function useCompareQuery(days: number, enabled = true) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// Every model a role can be set to: each usable provider's, as references.
|
|
115
|
-
export
|
|
116
|
-
return (effective?.providers ?? [])
|
|
117
|
-
.filter((p) => p.enabled)
|
|
118
|
-
.flatMap((p) =>
|
|
119
|
-
p.models.map((m) => ({
|
|
120
|
-
ref: `${p.id}/${m}`,
|
|
121
|
-
label: m,
|
|
122
|
-
provider: p.label,
|
|
123
|
-
}))
|
|
124
|
-
);
|
|
125
|
-
}
|
|
115
|
+
export { modelChoices } from "@/lib/models/choices";
|
package/lib/git.ts
CHANGED
|
@@ -148,3 +148,27 @@ export async function renameBranch(
|
|
|
148
148
|
{ timeout: 10000 }
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
|
+
|
|
152
|
+
export interface GitCheck {
|
|
153
|
+
isGitRepo: boolean;
|
|
154
|
+
branches: string[];
|
|
155
|
+
defaultBranch: string | null;
|
|
156
|
+
currentBranch: string | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether a path is a repository, and its branches. */
|
|
160
|
+
export async function gitCheck(dirPath: string): Promise<GitCheck> {
|
|
161
|
+
if (!(await isGitRepo(dirPath)))
|
|
162
|
+
return {
|
|
163
|
+
isGitRepo: false,
|
|
164
|
+
branches: [],
|
|
165
|
+
defaultBranch: null,
|
|
166
|
+
currentBranch: null,
|
|
167
|
+
};
|
|
168
|
+
const [branches, defaultBranch, currentBranch] = await Promise.all([
|
|
169
|
+
getBranches(dirPath),
|
|
170
|
+
getDefaultBranch(dirPath),
|
|
171
|
+
getCurrentBranch(dirPath),
|
|
172
|
+
]);
|
|
173
|
+
return { isGitRepo: true, branches, defaultBranch, currentBranch };
|
|
174
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Project } from "../db";
|
|
2
|
+
import { projectRef } from "../tasks/project-ref";
|
|
3
|
+
import { projectOnPeers } from "./peer-actions";
|
|
4
|
+
import { linkedHostIds } from "./remote-api";
|
|
5
|
+
|
|
6
|
+
// Which linked machines can take a session for this project, and why not
|
|
7
|
+
// where one can't: { [hostId]: reason | null }.
|
|
8
|
+
export async function projectMachines(
|
|
9
|
+
project: Project
|
|
10
|
+
): Promise<Record<string, string | null>> {
|
|
11
|
+
if ((project.host_id || "local") !== "local") return {};
|
|
12
|
+
try {
|
|
13
|
+
return await projectOnPeers(await projectRef(project));
|
|
14
|
+
} catch {
|
|
15
|
+
// A project outside the home folder has no place on another machine.
|
|
16
|
+
const reason = "Its folder is outside your home folder";
|
|
17
|
+
return Object.fromEntries([...linkedHostIds()].map((id) => [id, reason]));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EffectiveModels } from "./config";
|
|
2
|
+
|
|
3
|
+
// The "provider/model" refs a task can run on: every model of each enabled
|
|
4
|
+
// provider.
|
|
5
|
+
export function modelChoices(
|
|
6
|
+
effective: Pick<EffectiveModels, "providers"> | undefined
|
|
7
|
+
) {
|
|
8
|
+
return (effective?.providers ?? [])
|
|
9
|
+
.filter((p) => p.enabled)
|
|
10
|
+
.flatMap((p) =>
|
|
11
|
+
p.models.map((m) => ({
|
|
12
|
+
ref: `${p.id}/${m}`,
|
|
13
|
+
label: m,
|
|
14
|
+
provider: p.label,
|
|
15
|
+
}))
|
|
16
|
+
);
|
|
17
|
+
}
|
package/lib/orchestrator/repo.ts
CHANGED
|
@@ -43,7 +43,7 @@ const short = (sha: string) => sha.slice(0, 7);
|
|
|
43
43
|
|
|
44
44
|
// The heads AgentOS's own restack, or its automatic rebase onto the base,
|
|
45
45
|
// moved the task's branch between.
|
|
46
|
-
function restackedOf(task: Session) {
|
|
46
|
+
export function restackedOf(task: Session) {
|
|
47
47
|
const item = stackQueries.itemForSession(db, task.id);
|
|
48
48
|
if (item) return { from: item.restacked_from, to: item.restacked_to };
|
|
49
49
|
return task.rebased_to
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the start form needs in one request: the agents and their models, the
|
|
3
|
+
* models a task can run on, the machines, the default branch and whether a
|
|
4
|
+
* task plans first. Built from the helpers the web form already uses; never
|
|
5
|
+
* a provider's address or key reference, nor a machine's ssh target.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { AGENT_OPTIONS } from "./agent-options";
|
|
9
|
+
import { probeAgents } from "./agents/probe";
|
|
10
|
+
import { gitCheck } from "./git";
|
|
11
|
+
import { listHosts } from "./hosts";
|
|
12
|
+
import { projectMachines } from "./hosts/project-machines";
|
|
13
|
+
import { getModelOptions, getDefaultModelForAgent } from "./model-catalog";
|
|
14
|
+
import { effectiveModels } from "./models/config";
|
|
15
|
+
import { modelChoices } from "./models/choices";
|
|
16
|
+
import { resolveRole } from "./models/resolve";
|
|
17
|
+
import { getProject } from "./projects";
|
|
18
|
+
import { expandHome } from "./tasks/session";
|
|
19
|
+
import { taskRouting } from "./tasks";
|
|
20
|
+
import type { StartOptions } from "./tasks/loop-types";
|
|
21
|
+
|
|
22
|
+
const BRANCHES_MAX = 50;
|
|
23
|
+
|
|
24
|
+
export class UnknownProjectError extends Error {}
|
|
25
|
+
|
|
26
|
+
export async function startOptions(
|
|
27
|
+
projectId: string | null
|
|
28
|
+
): Promise<StartOptions> {
|
|
29
|
+
const project = projectId ? getProject(projectId) : undefined;
|
|
30
|
+
if (projectId && (!project || project.is_uncategorized))
|
|
31
|
+
throw new UnknownProjectError("Project not found");
|
|
32
|
+
const here = !project || (project.host_id || "local") === "local";
|
|
33
|
+
const models = effectiveModels();
|
|
34
|
+
|
|
35
|
+
let planDefault = false;
|
|
36
|
+
let planError: string | null = null;
|
|
37
|
+
const roles = { plan: "", implement: "" };
|
|
38
|
+
try {
|
|
39
|
+
const opts = { workspaceId: project?.workspace_id ?? null };
|
|
40
|
+
roles.plan = resolveRole("plan", opts, models).ref;
|
|
41
|
+
roles.implement = resolveRole("implement", opts, models).ref;
|
|
42
|
+
planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
planError = error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [probes, git, machines] = await Promise.all([
|
|
48
|
+
here ? probeAgents() : Promise.resolve(null),
|
|
49
|
+
project && here
|
|
50
|
+
? gitCheck(expandHome(project.working_directory))
|
|
51
|
+
: Promise.resolve(null),
|
|
52
|
+
project
|
|
53
|
+
? projectMachines(project)
|
|
54
|
+
: Promise.resolve<Record<string, string | null>>({}),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
project: project
|
|
59
|
+
? {
|
|
60
|
+
id: project.id,
|
|
61
|
+
name: project.name,
|
|
62
|
+
agentType: project.agent_type,
|
|
63
|
+
defaultModel: project.default_model,
|
|
64
|
+
hostId: project.host_id || "local",
|
|
65
|
+
}
|
|
66
|
+
: null,
|
|
67
|
+
agents: AGENT_OPTIONS.map((a) => ({
|
|
68
|
+
value: a.value,
|
|
69
|
+
label: a.label,
|
|
70
|
+
installed: probes?.[a.value]?.installed ?? null,
|
|
71
|
+
auth: probes?.[a.value]?.auth ?? null,
|
|
72
|
+
hint: probes?.[a.value]?.hint ?? null,
|
|
73
|
+
models: getModelOptions(a.value),
|
|
74
|
+
defaultModel: getDefaultModelForAgent(a.value),
|
|
75
|
+
})),
|
|
76
|
+
taskModels: modelChoices(models),
|
|
77
|
+
roles,
|
|
78
|
+
planDefault,
|
|
79
|
+
planError,
|
|
80
|
+
hosts: listHosts().map((h) => ({
|
|
81
|
+
id: h.id,
|
|
82
|
+
name: h.name,
|
|
83
|
+
linked: h.id === "local" || Boolean(h.linked),
|
|
84
|
+
unavailable: machines[h.id] ?? null,
|
|
85
|
+
})),
|
|
86
|
+
git: git && {
|
|
87
|
+
isGitRepo: git.isGitRepo,
|
|
88
|
+
defaultBranch: git.defaultBranch,
|
|
89
|
+
currentBranch: git.currentBranch,
|
|
90
|
+
branches: git.branches.slice(0, BRANCHES_MAX),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes the building-loop endpoints answer with (start options, a
|
|
3
|
+
* task's diff, its checks and review verdict, its plan), plus their caps.
|
|
4
|
+
* Pure data, no imports, so the native app can sync this file on its own.
|
|
5
|
+
* Every string that came from a log, a review or a plan is untrusted text
|
|
6
|
+
* to display, never to run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DIFF_MAX_FILES = 300;
|
|
10
|
+
export const DIFF_PATCH_MAX_BYTES = 200_000;
|
|
11
|
+
export const CHECK_LOG_MAX_BYTES = 8_000;
|
|
12
|
+
export const CHECK_LOGS_MAX = 3;
|
|
13
|
+
export const REVIEW_DETAIL_MAX = 20_000;
|
|
14
|
+
export const REVIEW_FINDINGS_MAX = 50;
|
|
15
|
+
export const PLAN_TEXT_MAX = 4_000;
|
|
16
|
+
|
|
17
|
+
// -- Start options ---------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface StartAgent {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
// On this machine; null when the project runs on another machine.
|
|
23
|
+
installed: boolean | null;
|
|
24
|
+
auth: string | null;
|
|
25
|
+
hint: string | null;
|
|
26
|
+
models: { value: string; label: string }[];
|
|
27
|
+
defaultModel: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StartHost {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
linked: boolean;
|
|
34
|
+
// Why a project here can't go to this machine, when it can't.
|
|
35
|
+
unavailable: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskModelChoice {
|
|
39
|
+
ref: string;
|
|
40
|
+
label: string;
|
|
41
|
+
provider: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StartOptions {
|
|
45
|
+
project: {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
agentType: string;
|
|
49
|
+
defaultModel: string;
|
|
50
|
+
hostId: string;
|
|
51
|
+
} | null;
|
|
52
|
+
agents: StartAgent[];
|
|
53
|
+
// "provider/model" refs a task can run on, and the role defaults.
|
|
54
|
+
taskModels: TaskModelChoice[];
|
|
55
|
+
roles: { plan: string; implement: string };
|
|
56
|
+
planDefault: boolean;
|
|
57
|
+
planError: string | null;
|
|
58
|
+
hosts: StartHost[];
|
|
59
|
+
git: {
|
|
60
|
+
isGitRepo: boolean;
|
|
61
|
+
defaultBranch: string | null;
|
|
62
|
+
currentBranch: string | null;
|
|
63
|
+
branches: string[];
|
|
64
|
+
} | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -- A task's whole change -------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface TaskDiffFile {
|
|
70
|
+
path: string;
|
|
71
|
+
// git's letters: A added, M modified, D deleted, R renamed, C copied,
|
|
72
|
+
// T type changed.
|
|
73
|
+
status: string;
|
|
74
|
+
// null for a binary file.
|
|
75
|
+
additions: number | null;
|
|
76
|
+
deletions: number | null;
|
|
77
|
+
binary: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TaskDiffList {
|
|
81
|
+
source: "worktree" | "pr";
|
|
82
|
+
base: string;
|
|
83
|
+
head: string;
|
|
84
|
+
files: TaskDiffFile[];
|
|
85
|
+
totalFiles: number;
|
|
86
|
+
additions: number;
|
|
87
|
+
deletions: number;
|
|
88
|
+
truncated: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TaskDiffPatch {
|
|
92
|
+
source: "worktree" | "pr";
|
|
93
|
+
path: string;
|
|
94
|
+
patch: string | null;
|
|
95
|
+
truncated: boolean;
|
|
96
|
+
// No patch to show: binary, or too big for git or GitHub to give.
|
|
97
|
+
tooLarge: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// -- Checks and the review verdict ----------------------------------------
|
|
101
|
+
|
|
102
|
+
export type CheckState =
|
|
103
|
+
| "pass"
|
|
104
|
+
| "fail"
|
|
105
|
+
| "pending"
|
|
106
|
+
| "skipped"
|
|
107
|
+
| "cancelled"
|
|
108
|
+
| "neutral";
|
|
109
|
+
|
|
110
|
+
export interface TaskCheck {
|
|
111
|
+
name: string;
|
|
112
|
+
workflow: string | null;
|
|
113
|
+
state: CheckState;
|
|
114
|
+
conclusion: string | null;
|
|
115
|
+
url: string | null;
|
|
116
|
+
startedAt: string | null;
|
|
117
|
+
completedAt: string | null;
|
|
118
|
+
durationSec: number | null;
|
|
119
|
+
// The failed step's log around its error, for a failing Actions job.
|
|
120
|
+
log?: { step: string | null; tail: string; truncated: boolean };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ReviewFinding {
|
|
124
|
+
severity: string;
|
|
125
|
+
file: string | null;
|
|
126
|
+
line: number | null;
|
|
127
|
+
summary: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TaskReviewVerdict {
|
|
131
|
+
sha: string;
|
|
132
|
+
status: "running" | "pass" | "block" | "error";
|
|
133
|
+
summary: string;
|
|
134
|
+
findings: ReviewFinding[];
|
|
135
|
+
at: string;
|
|
136
|
+
truncated: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface TaskChecks {
|
|
140
|
+
pr: {
|
|
141
|
+
number: number;
|
|
142
|
+
url: string;
|
|
143
|
+
state: string;
|
|
144
|
+
head: string | null;
|
|
145
|
+
} | null;
|
|
146
|
+
checks: TaskCheck[];
|
|
147
|
+
review: TaskReviewVerdict | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -- A task's plan ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export interface TaskPlan {
|
|
153
|
+
// As the starter decided (null reason when unstated).
|
|
154
|
+
planned: boolean;
|
|
155
|
+
reason: string | null;
|
|
156
|
+
// null: no plan document yet.
|
|
157
|
+
status: "writing" | "ready" | "approved" | null;
|
|
158
|
+
source: "step" | "chat" | null;
|
|
159
|
+
title: string | null;
|
|
160
|
+
// Relative to the task's worktree.
|
|
161
|
+
path: string | null;
|
|
162
|
+
text: string | null;
|
|
163
|
+
truncated: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Splits a stored review detail (lib/orchestrator/review-prompt.ts writes
|
|
167
|
+
// "summary" then "- [severity] file:line: text" lines) back into a summary and
|
|
168
|
+
// findings. A line that isn't a finding stays in the summary.
|
|
169
|
+
const FINDING = /^- \[([^\]\n]{1,40})\](?: ([^\s:]+)(?::(\d+))?)?: (.*)$/;
|
|
170
|
+
|
|
171
|
+
export function parseReviewDetail(detail: string | null | undefined): {
|
|
172
|
+
summary: string;
|
|
173
|
+
findings: ReviewFinding[];
|
|
174
|
+
truncated: boolean;
|
|
175
|
+
} {
|
|
176
|
+
let truncated = false;
|
|
177
|
+
let text = detail ?? "";
|
|
178
|
+
if (text.length > REVIEW_DETAIL_MAX) {
|
|
179
|
+
text = text.slice(0, REVIEW_DETAIL_MAX);
|
|
180
|
+
truncated = true;
|
|
181
|
+
}
|
|
182
|
+
const summary: string[] = [];
|
|
183
|
+
const findings: ReviewFinding[] = [];
|
|
184
|
+
for (const line of text.split("\n")) {
|
|
185
|
+
const m = FINDING.exec(line);
|
|
186
|
+
if (!m) {
|
|
187
|
+
summary.push(line);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (findings.length >= REVIEW_FINDINGS_MAX) {
|
|
191
|
+
truncated = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
findings.push({
|
|
195
|
+
severity: m[1],
|
|
196
|
+
file: m[2] ?? null,
|
|
197
|
+
line: m[3] ? Number(m[3]) : null,
|
|
198
|
+
summary: m[4],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return { summary: summary.join("\n").trim(), findings, truncated };
|
|
202
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's plan as the list shows it: whether one is being written, waits for
|
|
3
|
+
* a decision, or was approved, with its title and a capped piece of text.
|
|
4
|
+
* Two sources: a chat in plan mode proposes one (a "plan" item, carried out
|
|
5
|
+
* when approved), and the plan step writes one to a file in the worktree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { PLAN_FILE, planSummary } from "../models/plan-step";
|
|
12
|
+
import { PLAN_TEXT_MAX, type TaskPlan } from "./loop-types";
|
|
13
|
+
import { expandHome } from "./session";
|
|
14
|
+
|
|
15
|
+
const TITLE_MAX = 120;
|
|
16
|
+
|
|
17
|
+
export function planTitle(text: string): string | null {
|
|
18
|
+
const heading = /^#{1,6}\s+(.+)$/m.exec(text)?.[1]?.trim();
|
|
19
|
+
const title = heading ?? planSummary(text, 1)[0] ?? null;
|
|
20
|
+
if (!title) return null;
|
|
21
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}…` : title;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function chatPlan(
|
|
25
|
+
sessionId: string
|
|
26
|
+
): { text: string; carried: boolean } | null {
|
|
27
|
+
const row = db
|
|
28
|
+
.prepare(
|
|
29
|
+
`SELECT data FROM chat_items WHERE session_id = ?
|
|
30
|
+
AND json_extract(data, '$.kind') = 'plan' ORDER BY seq DESC LIMIT 1`
|
|
31
|
+
)
|
|
32
|
+
.get(sessionId) as { data: string } | undefined;
|
|
33
|
+
if (!row) return null;
|
|
34
|
+
try {
|
|
35
|
+
const item = JSON.parse(row.data) as { plan?: unknown; carried?: boolean };
|
|
36
|
+
return typeof item.plan === "string"
|
|
37
|
+
? { text: item.plan, carried: !!item.carried }
|
|
38
|
+
: null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only the first `max` bytes, not the whole file.
|
|
45
|
+
function readHead(file: string, max: number): string | null {
|
|
46
|
+
let fd: number | undefined;
|
|
47
|
+
try {
|
|
48
|
+
fd = fs.openSync(file, "r");
|
|
49
|
+
const buf = Buffer.alloc(max + 1);
|
|
50
|
+
const n = fs.readSync(fd, buf, 0, max + 1, 0);
|
|
51
|
+
return buf.subarray(0, n).toString("utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
} finally {
|
|
55
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function capped(text: string) {
|
|
60
|
+
const over = text.length > PLAN_TEXT_MAX;
|
|
61
|
+
return { text: over ? text.slice(0, PLAN_TEXT_MAX) : text, truncated: over };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function taskPlan(session: Session): TaskPlan | null {
|
|
65
|
+
const decided = session.plan_decided ?? null;
|
|
66
|
+
const phase = session.plan_phase ?? null;
|
|
67
|
+
// Only a chat in plan mode is asked: listing tasks must stay cheap.
|
|
68
|
+
const maybeChat = !!session.chat_plan;
|
|
69
|
+
if (decided === null && !phase && !maybeChat) return null;
|
|
70
|
+
|
|
71
|
+
const base = {
|
|
72
|
+
planned: decided === null ? false : !!decided,
|
|
73
|
+
reason: session.plan_reason ?? null,
|
|
74
|
+
};
|
|
75
|
+
const none = {
|
|
76
|
+
...base,
|
|
77
|
+
status: null,
|
|
78
|
+
source: null,
|
|
79
|
+
title: null,
|
|
80
|
+
path: null,
|
|
81
|
+
text: null,
|
|
82
|
+
truncated: false,
|
|
83
|
+
} satisfies TaskPlan;
|
|
84
|
+
|
|
85
|
+
const chat = maybeChat ? chatPlan(session.id) : null;
|
|
86
|
+
if (chat && !chat.carried)
|
|
87
|
+
return {
|
|
88
|
+
...base,
|
|
89
|
+
planned: true,
|
|
90
|
+
status: "ready",
|
|
91
|
+
source: "chat",
|
|
92
|
+
title: planTitle(chat.text),
|
|
93
|
+
path: null,
|
|
94
|
+
...capped(chat.text),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (phase && session.worktree_path) {
|
|
98
|
+
const file = path.join(expandHome(session.worktree_path), PLAN_FILE);
|
|
99
|
+
const text = readHead(file, PLAN_TEXT_MAX);
|
|
100
|
+
if (text !== null || phase === "planning") {
|
|
101
|
+
const body = text ?? "";
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
planned: true,
|
|
105
|
+
status: phase === "planning" ? "writing" : "approved",
|
|
106
|
+
source: "step",
|
|
107
|
+
title: planTitle(body),
|
|
108
|
+
path: text !== null ? PLAN_FILE : null,
|
|
109
|
+
...capped(body),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (chat)
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
planned: true,
|
|
118
|
+
status: "approved",
|
|
119
|
+
source: "chat",
|
|
120
|
+
title: planTitle(chat.text),
|
|
121
|
+
path: null,
|
|
122
|
+
...capped(chat.text),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (decided === null) return null;
|
|
126
|
+
return none;
|
|
127
|
+
}
|
package/lib/tasks/session.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function projectPathFor(session: Session): string | null {
|
|
|
37
37
|
|
|
38
38
|
// The task's worktree, when it's here to look at: null when it's gone or on
|
|
39
39
|
// another host.
|
|
40
|
-
function localWorktree(session: Session): string | null {
|
|
40
|
+
export function localWorktree(session: Session): string | null {
|
|
41
41
|
if (!session.worktree_path) return null;
|
|
42
42
|
if (session.host_id && session.host_id !== "local") return null;
|
|
43
43
|
const dir = expandHome(session.worktree_path);
|