@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
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";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the Connect service this machine is alive: one signed beat when
|
|
3
|
+
* Connect starts, then one a minute. The answer can switch the machine off
|
|
4
|
+
* (the account disabled it) or say it is gone (removed from the account).
|
|
5
|
+
* Nothing here may throw: a rejection would take AgentOS down.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { ApiError, clean } from "./control-api";
|
|
11
|
+
|
|
12
|
+
export const HEARTBEAT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
export interface HeartbeatOptions {
|
|
15
|
+
beat: () => Promise<{ enabled: boolean }>;
|
|
16
|
+
/** The account disabled this machine: stop the tunnel. */
|
|
17
|
+
onDisabled: () => void;
|
|
18
|
+
/** The service no longer knows this machine. */
|
|
19
|
+
onGone: () => void;
|
|
20
|
+
log?: (line: string) => void;
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function startHeartbeat(opts: HeartbeatOptions) {
|
|
25
|
+
const log = opts.log ?? (() => {});
|
|
26
|
+
const every = opts.intervalMs ?? HEARTBEAT_MS;
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let failing = false;
|
|
29
|
+
let timer: NodeJS.Timeout | null = null;
|
|
30
|
+
|
|
31
|
+
const stop = () => {
|
|
32
|
+
stopped = true;
|
|
33
|
+
if (timer) clearTimeout(timer);
|
|
34
|
+
timer = null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const next = () => {
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
timer = setTimeout(tick, every);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const tick = async () => {
|
|
44
|
+
timer = null;
|
|
45
|
+
try {
|
|
46
|
+
const { enabled } = await opts.beat();
|
|
47
|
+
if (stopped) return;
|
|
48
|
+
failing = false;
|
|
49
|
+
if (enabled === false) {
|
|
50
|
+
stop();
|
|
51
|
+
log("connect: this machine was disabled in your account; tunnel off");
|
|
52
|
+
opts.onDisabled();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (stopped) return;
|
|
57
|
+
const status = err instanceof ApiError ? err.status : 0;
|
|
58
|
+
if (status === 401 || status === 404) {
|
|
59
|
+
stop();
|
|
60
|
+
log("connect: machine no longer linked, run agent-os connect");
|
|
61
|
+
opts.onGone();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!failing) {
|
|
65
|
+
failing = true;
|
|
66
|
+
log(
|
|
67
|
+
`connect: heartbeat failed: ${clean((err as Error)?.message ?? String(err))}; retrying`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
next();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
void tick();
|
|
75
|
+
return { stop };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The running AgentOS version, from package.json. */
|
|
79
|
+
export function appVersion(cwd = process.cwd()): string {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(
|
|
82
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf8")
|
|
83
|
+
) as { version?: string };
|
|
84
|
+
return pkg.version ?? "unknown";
|
|
85
|
+
} catch {
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
}
|
package/lib/connect/serve.ts
CHANGED
|
@@ -28,7 +28,13 @@ import {
|
|
|
28
28
|
type ConnectConfig,
|
|
29
29
|
} from "./config";
|
|
30
30
|
import { shouldStartConnect, takeConnectLock } from "./guard";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
checkApiUrl,
|
|
33
|
+
heartbeat as sendHeartbeat,
|
|
34
|
+
requestTunnel,
|
|
35
|
+
type Tunnel,
|
|
36
|
+
} from "./control-api";
|
|
37
|
+
import { appVersion, startHeartbeat } from "./heartbeat";
|
|
32
38
|
import { ensureCloudflared } from "./cloudflared";
|
|
33
39
|
import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
|
|
34
40
|
|
|
@@ -93,12 +99,15 @@ export interface ConnectDeps {
|
|
|
93
99
|
) => Promise<Tunnel>;
|
|
94
100
|
spawn?: Spawn;
|
|
95
101
|
delays?: Parameters<typeof superviseTunnel>[0]["delays"];
|
|
102
|
+
heartbeat?: typeof sendHeartbeat;
|
|
103
|
+
heartbeatMs?: number;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
interface Running {
|
|
99
107
|
server: http.Server;
|
|
100
108
|
sockets: Set<Socket>;
|
|
101
109
|
supervisor: ReturnType<typeof superviseTunnel>;
|
|
110
|
+
heartbeat: ReturnType<typeof startHeartbeat>;
|
|
102
111
|
release: () => void;
|
|
103
112
|
}
|
|
104
113
|
|
|
@@ -115,6 +124,9 @@ export function startConnect(
|
|
|
115
124
|
let base: ConnectStatus = { state: "off" };
|
|
116
125
|
let hostname = "";
|
|
117
126
|
let running: Running | null = null;
|
|
127
|
+
// Set when the service disabled or dropped this machine; sync() leaves it
|
|
128
|
+
// off until connect.json is turned off or relinked.
|
|
129
|
+
let halted: string | null = null;
|
|
118
130
|
// Set while the tunnel listener can't bind; it keeps trying.
|
|
119
131
|
let bindError: string | undefined;
|
|
120
132
|
g.__agentosConnect = () => {
|
|
@@ -140,6 +152,7 @@ export function startConnect(
|
|
|
140
152
|
const down = (why: string) => {
|
|
141
153
|
if (!running) return;
|
|
142
154
|
bindError = undefined;
|
|
155
|
+
running.heartbeat.stop();
|
|
143
156
|
running.supervisor.stop();
|
|
144
157
|
running.sockets.forEach((s) => s.destroy());
|
|
145
158
|
running.server.close();
|
|
@@ -162,6 +175,14 @@ export function startConnect(
|
|
|
162
175
|
hostname = config.hostname;
|
|
163
176
|
base = { state: "off", enrolled: true, hostname: hostname || undefined };
|
|
164
177
|
if (!read.enabled) return;
|
|
178
|
+
if (halted) {
|
|
179
|
+
base = {
|
|
180
|
+
...base,
|
|
181
|
+
state: "off",
|
|
182
|
+
reason: "stopped by the Connect service",
|
|
183
|
+
};
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
165
186
|
try {
|
|
166
187
|
checkApiUrl(config.apiUrl, env.AGENTOS_CONNECT_OPERATOR === "1");
|
|
167
188
|
} catch (err) {
|
|
@@ -225,7 +246,22 @@ export function startConnect(
|
|
|
225
246
|
return { bin, token: tunnel.token };
|
|
226
247
|
},
|
|
227
248
|
});
|
|
228
|
-
|
|
249
|
+
const beat = deps.heartbeat ?? sendHeartbeat;
|
|
250
|
+
const version = appVersion();
|
|
251
|
+
const heartbeat = startHeartbeat({
|
|
252
|
+
beat: () => beat(config, machineKey, version),
|
|
253
|
+
log,
|
|
254
|
+
intervalMs: deps.heartbeatMs,
|
|
255
|
+
onDisabled: () => {
|
|
256
|
+
halted = config.machineId;
|
|
257
|
+
down("disabled in your account");
|
|
258
|
+
},
|
|
259
|
+
onGone: () => {
|
|
260
|
+
halted = config.machineId;
|
|
261
|
+
down("machine no longer linked");
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
running = { server, sockets, supervisor, heartbeat, release: lock.release };
|
|
229
265
|
server.listen(tunnelPort, "127.0.0.1");
|
|
230
266
|
};
|
|
231
267
|
|
|
@@ -233,7 +269,12 @@ export function startConnect(
|
|
|
233
269
|
const sync = () => {
|
|
234
270
|
try {
|
|
235
271
|
const read = readConnect(dir);
|
|
236
|
-
|
|
272
|
+
if (
|
|
273
|
+
halted &&
|
|
274
|
+
(!read.ok || !read.enabled || read.files.config.machineId !== halted)
|
|
275
|
+
)
|
|
276
|
+
halted = null;
|
|
277
|
+
const want = read.ok && read.enabled && !halted;
|
|
237
278
|
if (running && !want) down(read.ok ? "turned off" : read.reason);
|
|
238
279
|
else if (!running && want) up();
|
|
239
280
|
else if (!running) up(); // refreshes what the status shows
|
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
|
+
}
|