@saadnvd1/agent-os 0.3.664 → 0.3.672
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/app/api/audits/[kind]/[id]/route.test.ts +2 -2
- package/app/api/mcp-auth/callback/route.test.ts +42 -0
- package/app/api/mcp-auth/callback/route.ts +56 -0
- package/app/api/projects/[id]/route.ts +26 -0
- package/app/api/projects/init/route.test.ts +2 -4
- package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
- package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
- package/app/api/shares/route.test.ts +3 -4
- package/app/api/workspaces/[id]/route.ts +9 -1
- package/app/api/worktree-sweep/route.test.ts +21 -0
- package/app/api/worktree-sweep/route.ts +24 -4
- package/components/Chat/ChatPanel.tsx +6 -3
- package/components/Chat/McpServers.tsx +61 -24
- package/components/Chat/McpSignIn.tsx +122 -0
- package/components/Chat/session.ts +8 -0
- package/components/Health/SweepRuns.tsx +1 -0
- package/components/Projects/ProjectSettingsDialog.tsx +7 -0
- package/components/Projects/ProjectTierField.tsx +39 -0
- package/components/Workspaces/ProjectTiersForm.tsx +33 -0
- package/components/Workspaces/WorkspaceSettings.tsx +8 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/projects/queries.ts +4 -0
- package/data/workspaces/index.ts +6 -0
- package/lib/chat/driver.ts +24 -2
- package/lib/chat/drivers/claude-approvals.ts +7 -0
- package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
- package/lib/chat/drivers/claude.ts +7 -0
- package/lib/chat/events.ts +12 -0
- package/lib/chat/mcp-auth.ts +193 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +18 -0
- package/lib/chat/worker/client.ts +35 -1
- package/lib/chat/worker/host.ts +9 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/mcp-auth.ts +119 -0
- package/lib/chat/worker/protocol.ts +19 -0
- package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
- package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
- package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +2 -8
- package/lib/orchestrator/armed.test-helpers.ts +7 -2
- package/lib/orchestrator/armed.ts +14 -1
- package/lib/orchestrator/brief.ts +23 -11
- package/lib/orchestrator/external-gates.ts +62 -1
- package/lib/orchestrator/fast-lane-state.ts +69 -0
- package/lib/orchestrator/fast-lane.ts +96 -0
- package/lib/orchestrator/full-access.ts +21 -0
- package/lib/orchestrator/gates.ts +8 -2
- package/lib/orchestrator/project-tier-tool.ts +33 -0
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +17 -3
- package/lib/orchestrator/shell-guard.ts +23 -1
- package/lib/orchestrator/signoff.test-helpers.ts +6 -2
- package/lib/orchestrator/signoff.ts +36 -19
- package/lib/orchestrator/sweep-arm.ts +180 -0
- package/lib/orchestrator/tool-names.ts +5 -1
- package/lib/orchestrator/tool-schemas.ts +41 -2
- package/lib/orchestrator/tools.ts +8 -2
- package/lib/orchestrator/watcher.ts +5 -0
- package/lib/orchestrator/write-guard.ts +34 -7
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +6 -0
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +9 -6
- package/lib/project-tier/getProjectTier.ts +18 -0
- package/lib/project-tier/index.ts +3 -0
- package/lib/project-tier/setProjectTier.ts +14 -0
- package/lib/project-tier/types.ts +3 -0
- package/lib/security/net.ts +7 -4
- package/lib/start-options.ts +8 -1
- package/lib/tasks/finish.ts +2 -1
- package/lib/tasks/index.ts +10 -5
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/state.ts +7 -1
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-sweep/caches.ts +6 -0
- package/lib/worktree-sweep/extras.ts +16 -8
- package/lib/worktree-sweep/report.ts +1 -0
- package/lib/worktree-sweep/settings.ts +2 -2
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +2 -0
- package/package.json +1 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/hooks/banned-terms.test.ts +3 -2
- package/scripts/hooks/staged-secrets.test.ts +2 -2
- package/scripts/lib/build-swap.test.ts +2 -3
- package/scripts/lib/wsl.test.ts +2 -3
- package/scripts/update-run.test.ts +2 -2
|
@@ -25,13 +25,16 @@ import { expandHome, forgetPR, prFor, projectPathFor } from "../tasks/session";
|
|
|
25
25
|
import { inBackground } from "../lumifyhub/task-cards";
|
|
26
26
|
import type { CheckRow } from "./checks";
|
|
27
27
|
import { getOrchestrator } from "./home";
|
|
28
|
+
import { fullAccessOn } from "./full-access";
|
|
29
|
+
import { setupWorktree } from "../env-setup";
|
|
28
30
|
import { addNote } from "./notes";
|
|
29
31
|
import { quickWorktrees } from "./quick-store";
|
|
30
32
|
import { findWorkspaceSession } from "./read";
|
|
31
33
|
import { workspaceProject } from "./targets";
|
|
32
34
|
import { recordPr, setTask } from "../db/session-parts";
|
|
33
35
|
|
|
34
|
-
// Open quick changes one orchestrator may hold at once
|
|
36
|
+
// Open quick changes one orchestrator may hold at once, unless its workspace
|
|
37
|
+
// has Orchestrator full access on (the default).
|
|
35
38
|
export const MAX_OPEN_QUICK = 5;
|
|
36
39
|
|
|
37
40
|
// Git with no hooks: a hook is a script in the project (or one it calls,
|
|
@@ -62,7 +65,13 @@ export async function startQuickChange(
|
|
|
62
65
|
workspaceId: string,
|
|
63
66
|
projectRef: string,
|
|
64
67
|
name: string
|
|
65
|
-
): Promise<{
|
|
68
|
+
): Promise<{
|
|
69
|
+
id: string;
|
|
70
|
+
worktreePath: string;
|
|
71
|
+
text: string;
|
|
72
|
+
// Dependencies and the project's setup, running in the background.
|
|
73
|
+
setup: Promise<void>;
|
|
74
|
+
}> {
|
|
66
75
|
const owner = ownerOf(workspaceId);
|
|
67
76
|
const project = workspaceProject(workspaceId, projectRef);
|
|
68
77
|
if (isRemoteHost(project.host_id))
|
|
@@ -70,7 +79,7 @@ export async function startQuickChange(
|
|
|
70
79
|
`${project.name} runs on another machine; quick changes are made on this one only`
|
|
71
80
|
);
|
|
72
81
|
const open = quickWorktrees(owner.id);
|
|
73
|
-
if (open.length >= MAX_OPEN_QUICK)
|
|
82
|
+
if (!fullAccessOn(workspaceId) && open.length >= MAX_OPEN_QUICK)
|
|
74
83
|
throw new Error(
|
|
75
84
|
`You have ${open.length} quick changes open (${MAX_OPEN_QUICK} at most): finish one with quick_pr, or drop it, first`
|
|
76
85
|
);
|
|
@@ -122,13 +131,47 @@ export async function startQuickChange(
|
|
|
122
131
|
workspaceId,
|
|
123
132
|
`Quick change "${label}" in ${project.name} on ${wt.branchName}.`
|
|
124
133
|
);
|
|
134
|
+
const full = fullAccessOn(workspaceId);
|
|
135
|
+
const setup = installDependencies(
|
|
136
|
+
workspaceId,
|
|
137
|
+
label,
|
|
138
|
+
wt.worktreePath,
|
|
139
|
+
projectPath
|
|
140
|
+
);
|
|
141
|
+
inBackground(`setup of quick change ${id}`, () => setup);
|
|
125
142
|
return {
|
|
126
143
|
id,
|
|
127
144
|
worktreePath: wt.worktreePath,
|
|
128
|
-
|
|
145
|
+
setup,
|
|
146
|
+
text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Dependencies are installing in the background (a note says when they're ready). Edit files there with Write and Edit; ${full ? "run any git there" : "git there only as `git -C " + wt.worktreePath + " <status|diff|add|...>`"}. Then quick_pr commits, pushes and opens its PR.`,
|
|
129
147
|
};
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
// The project's own worktree setup (dependencies, env files), so tests and
|
|
151
|
+
// the build run in the quick change. One note when it ends.
|
|
152
|
+
async function installDependencies(
|
|
153
|
+
workspaceId: string,
|
|
154
|
+
label: string,
|
|
155
|
+
worktreePath: string,
|
|
156
|
+
projectPath: string
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
try {
|
|
159
|
+
const r = await setupWorktree({ worktreePath, sourcePath: projectPath });
|
|
160
|
+
const failed = r.steps.find((s) => !s.success);
|
|
161
|
+
addNote(
|
|
162
|
+
workspaceId,
|
|
163
|
+
failed
|
|
164
|
+
? `Quick change "${label}": setup failed at ${failed.name}: ${failed.error ?? "see its worktree"}.`
|
|
165
|
+
: `Quick change "${label}": dependencies installed, ready to build and test.`
|
|
166
|
+
);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
addNote(
|
|
169
|
+
workspaceId,
|
|
170
|
+
`Quick change "${label}": setup failed: ${(e as Error).message}.`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
// A quick change of this workspace's orchestrator, by name, id or PR.
|
|
133
176
|
export function workspaceQuick(workspaceId: string, ref: string): Session {
|
|
134
177
|
const owner = ownerOf(workspaceId);
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { noteQuickReview } from "./quick";
|
|
56
56
|
import { workspaceTask } from "./targets";
|
|
57
57
|
import { untrusted } from "./untrusted";
|
|
58
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
58
59
|
|
|
59
60
|
const short = (sha: string) => sha.slice(0, 7);
|
|
60
61
|
// The most diff one reviewer is given, and the most parts a change too big
|
|
@@ -267,6 +268,8 @@ export async function reviewAt(
|
|
|
267
268
|
sha: string,
|
|
268
269
|
opts: ReviewOpts = {}
|
|
269
270
|
): Promise<string> {
|
|
271
|
+
if (target.task && isFast(target.task))
|
|
272
|
+
return `${target.task.name} is in a fast project: no independent review; it merges through the fast gates.`;
|
|
270
273
|
const known = getCheck(target.id, sha, "review");
|
|
271
274
|
// A card task's scope check runs after its review; one never stored (a
|
|
272
275
|
// restart between the two) means running the job again.
|
|
@@ -26,11 +26,13 @@ import { sessionFacts } from "./facts";
|
|
|
26
26
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
27
27
|
import { resolveStaleAsks } from "./ask-settle";
|
|
28
28
|
import { addNote } from "./notes";
|
|
29
|
+
import { projectTierTool } from "./project-tier-tool";
|
|
29
30
|
import { addProject } from "./add-project";
|
|
30
31
|
import { addRule, removeRule } from "./rules";
|
|
31
32
|
import { askSaad } from "./ask-tool";
|
|
32
33
|
import { askQuestion, resolveQuestion } from "./questions";
|
|
33
34
|
import { listOrchestratorPeers, messageOrchestrator } from "./cross";
|
|
35
|
+
import { sweepArm, sweepDryRun } from "./sweep-arm";
|
|
34
36
|
import { PAUSED_REFUSAL } from "./pause";
|
|
35
37
|
import { readSession } from "./read";
|
|
36
38
|
import { reviewTarget, signOffTarget } from "./external-gates";
|
|
@@ -216,6 +218,12 @@ export async function runTool(
|
|
|
216
218
|
const a = p(tool, raw);
|
|
217
219
|
return moveTaskTo(w, a.task, a.machine);
|
|
218
220
|
}
|
|
221
|
+
case "sweep_dry_run":
|
|
222
|
+
return sweepDryRun(p(tool, raw).machine);
|
|
223
|
+
case "sweep_arm": {
|
|
224
|
+
const a = p(tool, raw);
|
|
225
|
+
return sweepArm(w, a.machine, a.on, a.reason);
|
|
226
|
+
}
|
|
219
227
|
case "stop":
|
|
220
228
|
return stop(w, p(tool, raw).session);
|
|
221
229
|
case "done":
|
|
@@ -230,6 +238,10 @@ export async function runTool(
|
|
|
230
238
|
const a = p(tool, raw);
|
|
231
239
|
return addProject(w, a.path, a.name);
|
|
232
240
|
}
|
|
241
|
+
case "project_tier": {
|
|
242
|
+
const a = p(tool, raw);
|
|
243
|
+
return projectTierTool(w, a.project, a.tier);
|
|
244
|
+
}
|
|
233
245
|
case "note":
|
|
234
246
|
addNote(w, p(tool, raw).text);
|
|
235
247
|
return "Noted.";
|
|
@@ -265,10 +277,12 @@ export async function runTool(
|
|
|
265
277
|
const a = p(tool, raw);
|
|
266
278
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
267
279
|
}
|
|
268
|
-
case "sign_off":
|
|
269
|
-
|
|
280
|
+
case "sign_off": {
|
|
281
|
+
const a = p(tool, raw);
|
|
282
|
+
return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
|
|
283
|
+
}
|
|
270
284
|
case "arm":
|
|
271
|
-
return arm(w, p(tool, raw).task);
|
|
285
|
+
return await arm(w, p(tool, raw).task);
|
|
272
286
|
case "disarm":
|
|
273
287
|
return disarm(w, p(tool, raw).task);
|
|
274
288
|
case "ask_saad":
|
|
@@ -230,7 +230,7 @@ function aosRefusal(args: string[]): string | null {
|
|
|
230
230
|
stop: T.stop,
|
|
231
231
|
};
|
|
232
232
|
if (sub === "sweep" && args.includes("--arm"))
|
|
233
|
-
return
|
|
233
|
+
return `Refused: \`aos sweep --arm\` turns on removing files and databases, which is Saad's call: Health, Clean-up, Turn on. When he has said to, use ${T.sweep_arm} (after ${T.sweep_dry_run}). \`--dry-run\` and \`--disarm\` are fine.`;
|
|
234
234
|
if (sub && tool[sub])
|
|
235
235
|
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
236
236
|
return null;
|
|
@@ -617,3 +617,25 @@ export async function shellRefusal(
|
|
|
617
617
|
}
|
|
618
618
|
return null;
|
|
619
619
|
}
|
|
620
|
+
|
|
621
|
+
// The one thing full access still flags: a command that looks like it stops
|
|
622
|
+
// or restarts AgentOS or its tmux, which ends every session it runs, this one
|
|
623
|
+
// included. A warning, never a refusal; null for anything else.
|
|
624
|
+
export async function shellWarning(
|
|
625
|
+
command: unknown,
|
|
626
|
+
ctx: ShellContext
|
|
627
|
+
): Promise<string | null> {
|
|
628
|
+
if (typeof command !== "string") return null;
|
|
629
|
+
const parsed = parseShell(command);
|
|
630
|
+
if (typeof parsed === "string") return null;
|
|
631
|
+
for (const cmd of parsed) {
|
|
632
|
+
const invs = invocations(cmd);
|
|
633
|
+
if (typeof invs === "string") continue;
|
|
634
|
+
for (const inv of invs) {
|
|
635
|
+
const why = await stopRefusal(inv, ctx).catch(() => null);
|
|
636
|
+
if (why)
|
|
637
|
+
return `Warning: ${why.replace(/^Refused: /, "")} It will run anyway.`;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
@@ -6,6 +6,7 @@ import { beforeAll, beforeEach, expect, vi } from "vitest";
|
|
|
6
6
|
import type { TaskPR } from "@/lib/tasks/state";
|
|
7
7
|
import type { ClaudeRun } from "./claude-cli";
|
|
8
8
|
import type { SessionFacts } from "./facts";
|
|
9
|
+
import { tempDir } from "@/lib/test-temp";
|
|
9
10
|
|
|
10
11
|
// Shared fixtures for the signoff*.test.ts files: a real repository with a
|
|
11
12
|
// local bare remote, gh faked around it. Import this before anything it mocks.
|
|
@@ -162,7 +163,7 @@ export const askUp = async (
|
|
|
162
163
|
export const POLL = { timeout: 20000, interval: 20 };
|
|
163
164
|
|
|
164
165
|
beforeAll(() => {
|
|
165
|
-
const home =
|
|
166
|
+
const home = tempDir("aos-orch-signoff-");
|
|
166
167
|
vi.spyOn(os, "homedir").mockReturnValue(home);
|
|
167
168
|
});
|
|
168
169
|
beforeEach(() => {
|
|
@@ -212,7 +213,7 @@ export function setup(
|
|
|
212
213
|
} = {}
|
|
213
214
|
) {
|
|
214
215
|
setMergeApprovals(opts.approvals ?? false);
|
|
215
|
-
const root =
|
|
216
|
+
const root = tempDir("aos-orch-repo-");
|
|
216
217
|
const remote = path.join(root, "remote.git");
|
|
217
218
|
const repo = path.join(root, "repo");
|
|
218
219
|
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
@@ -230,6 +231,9 @@ export function setup(
|
|
|
230
231
|
workingDirectory: repo,
|
|
231
232
|
});
|
|
232
233
|
setProjectWorkspace(project.id, workspace.id);
|
|
234
|
+
db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
|
|
235
|
+
project.id
|
|
236
|
+
);
|
|
233
237
|
ensureOrchestrator(workspace.id);
|
|
234
238
|
const task = seedSession({
|
|
235
239
|
projectId: project.id,
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
reviewSignature,
|
|
42
42
|
} from "./loop-guard";
|
|
43
43
|
import { addNote } from "./notes";
|
|
44
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
45
|
+
import { recordFastMerge } from "./fast-lane-state";
|
|
44
46
|
import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
|
|
45
47
|
import { workSubject } from "./asks";
|
|
46
48
|
import { carryApproval } from "./approval-carry";
|
|
@@ -71,7 +73,7 @@ export function restackedOf(task: Session) {
|
|
|
71
73
|
|
|
72
74
|
// `approval`: Saad approved this merge on his asks list, for this commit.
|
|
73
75
|
export type Verdict =
|
|
74
|
-
| { ok: true; sha: string; pr: number; approval?: number }
|
|
76
|
+
| { ok: true; sha: string; pr: number; approval?: number; fast?: true }
|
|
75
77
|
| { ok: false; wait: boolean; text: string };
|
|
76
78
|
|
|
77
79
|
// Whether the task may merge now, and at which commit. Landing merges a
|
|
@@ -107,13 +109,15 @@ export async function judge(
|
|
|
107
109
|
// A PR quick_pr opened (by the row AgentOS wrote, never the body): its
|
|
108
110
|
// code review is the independent review of this head, and until that
|
|
109
111
|
// passes it waits, before any gate is judged or counted.
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
const fast = isFast(task);
|
|
113
|
+
const quick =
|
|
114
|
+
isQuick(task) && !fast
|
|
115
|
+
? quickCodeReview(
|
|
116
|
+
sha,
|
|
117
|
+
getCheck(task.id, sha, "review"),
|
|
118
|
+
restackedOf(task)?.to === sha
|
|
119
|
+
)
|
|
120
|
+
: null;
|
|
117
121
|
// Saad's approval of this head stands in for that review (gateVerdict).
|
|
118
122
|
if (
|
|
119
123
|
quick?.wait &&
|
|
@@ -157,7 +161,7 @@ export async function judge(
|
|
|
157
161
|
return quick ? { refusal: gate.refusal, wait: null } : gate;
|
|
158
162
|
})(),
|
|
159
163
|
}),
|
|
160
|
-
opts
|
|
164
|
+
{ ...opts, fast }
|
|
161
165
|
);
|
|
162
166
|
}
|
|
163
167
|
|
|
@@ -178,8 +182,9 @@ export async function gateVerdict(
|
|
|
178
182
|
workspaceId: string,
|
|
179
183
|
work: GateWork,
|
|
180
184
|
inputs: () => Promise<Omit<GateInput, "sha" | "ruleBreaks">>,
|
|
181
|
-
opts: { count?: boolean } = {}
|
|
185
|
+
opts: { count?: boolean; fast?: boolean } = {}
|
|
182
186
|
): Promise<Verdict> {
|
|
187
|
+
const fast = !!opts.fast;
|
|
183
188
|
const no = (text: string, wait = false): Verdict => ({
|
|
184
189
|
ok: false,
|
|
185
190
|
wait,
|
|
@@ -216,7 +221,9 @@ export async function gateVerdict(
|
|
|
216
221
|
: null;
|
|
217
222
|
const refusal = i.codeReview.refusal ?? stack;
|
|
218
223
|
if (refusal) return no(`${approved} ${refusal}`);
|
|
219
|
-
const ci =
|
|
224
|
+
const ci = fast
|
|
225
|
+
? { state: "pass" as const }
|
|
226
|
+
: ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
|
|
220
227
|
if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
|
|
221
228
|
if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
|
|
222
229
|
// "escalate" is a repository with no CI: Saad is who that goes to.
|
|
@@ -245,7 +252,7 @@ export async function gateVerdict(
|
|
|
245
252
|
}
|
|
246
253
|
// Here too, not only in the review: one reviewed in parts while approvals
|
|
247
254
|
// were off goes to Saad once they're on.
|
|
248
|
-
if (mergeApprovalsOn()) {
|
|
255
|
+
if (mergeApprovalsOn() && !fast) {
|
|
249
256
|
const size = (await fullDiff(repo, base, sha)).length;
|
|
250
257
|
if (size > DIFF_CAP) {
|
|
251
258
|
const why = `PR #${pr.number}'s diff at ${short(sha)} is ${size} characters, too big to review whole`;
|
|
@@ -255,8 +262,11 @@ export async function gateVerdict(
|
|
|
255
262
|
|
|
256
263
|
const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
|
|
257
264
|
const live = await inputs();
|
|
258
|
-
const repoCI = await repoCIFor(repo, base, live);
|
|
259
|
-
const outcomes = evaluateGates(
|
|
265
|
+
const repoCI = fast ? undefined : await repoCIFor(repo, base, live);
|
|
266
|
+
const outcomes = evaluateGates(
|
|
267
|
+
{ sha, ruleBreaks: ruled, ...live, repoCI },
|
|
268
|
+
{ fast }
|
|
269
|
+
);
|
|
260
270
|
const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
|
|
261
271
|
const toSaad = outcomes.find((o) => o.state === "escalate");
|
|
262
272
|
if (toSaad)
|
|
@@ -266,9 +276,12 @@ export async function gateVerdict(
|
|
|
266
276
|
const failed = outcomes.filter((o) => o.state === "fail");
|
|
267
277
|
const waiting = outcomes.filter((o) => o.state === "wait");
|
|
268
278
|
if (!failed.length && !waiting.length)
|
|
269
|
-
return
|
|
279
|
+
return fast
|
|
280
|
+
? { ok: true, sha, pr: pr.number, fast: true }
|
|
281
|
+
: { ok: true, sha, pr: pr.number };
|
|
270
282
|
const lines: string[] = [];
|
|
271
|
-
|
|
283
|
+
// The fast lane has no loop guard: a failure is told, never counted.
|
|
284
|
+
if (opts.count === false || fast)
|
|
272
285
|
lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
|
|
273
286
|
else {
|
|
274
287
|
for (const o of outcomes)
|
|
@@ -346,11 +359,15 @@ export async function mergeJudged(
|
|
|
346
359
|
await refundIfRefused(task.id, verdict.approval, async () => {
|
|
347
360
|
method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
|
|
348
361
|
});
|
|
362
|
+
if (verdict.fast)
|
|
363
|
+
recordFastMerge(task.id, workspaceId, verdict.pr, verdict.sha);
|
|
349
364
|
addNote(
|
|
350
365
|
workspaceId,
|
|
351
|
-
verdict.
|
|
352
|
-
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on
|
|
353
|
-
:
|
|
366
|
+
verdict.fast
|
|
367
|
+
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
|
|
368
|
+
: verdict.approval
|
|
369
|
+
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
|
|
370
|
+
: `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
|
|
354
371
|
);
|
|
355
372
|
return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
|
|
356
373
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator's way to turn the full disk clean-up on or off, for this
|
|
3
|
+
* machine or a linked one. Turning it on is Saad's call, so the tool says it's
|
|
4
|
+
* for when he has said to, wants the latest dry run of that machine from the
|
|
5
|
+
* last hour (what it would remove is what he agreed to), and logs who and
|
|
6
|
+
* the dry run's totals. A linked machine is armed over ssh to its own
|
|
7
|
+
* loopback, where its AgentOS trusts the caller: its arm endpoint refuses a
|
|
8
|
+
* device token on purpose.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getHost, hostExec, hostIdNamed, isRemoteHost } from "../hosts";
|
|
12
|
+
import { remotePort } from "../hosts/link";
|
|
13
|
+
import {
|
|
14
|
+
cleanRemoteText,
|
|
15
|
+
hostApi,
|
|
16
|
+
requireHostLink,
|
|
17
|
+
type HostLink,
|
|
18
|
+
} from "../hosts/remote-api";
|
|
19
|
+
import { fromSqliteTime } from "../session-meta";
|
|
20
|
+
import { setReaperArmed } from "../worktree-sweep/settings";
|
|
21
|
+
import { sweepNow } from "../worktree-sweep/start";
|
|
22
|
+
import { sweepState, type SweepState } from "../worktree-sweep/state";
|
|
23
|
+
import type { SweepRun } from "../worktree-sweep/types";
|
|
24
|
+
import { addNote } from "./notes";
|
|
25
|
+
import { PAUSED_REFUSAL } from "./pause";
|
|
26
|
+
import { getWorkspace } from "../workspaces";
|
|
27
|
+
|
|
28
|
+
export const DRY_RUN_FRESH_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const TOOL_DRY = "sweep_dry_run";
|
|
31
|
+
const GB = (b: number) => (b / 1024 ** 3).toFixed(2) + " GB";
|
|
32
|
+
|
|
33
|
+
type Verdict = { ok: true; run: SweepRun } | { ok: false; why: string };
|
|
34
|
+
|
|
35
|
+
// The newest dry run decides: an older fresh one doesn't stand in for it.
|
|
36
|
+
export function dryRunVerdict(
|
|
37
|
+
runs: SweepRun[],
|
|
38
|
+
where: string,
|
|
39
|
+
now = Date.now()
|
|
40
|
+
): Verdict {
|
|
41
|
+
const next = `Run ${TOOL_DRY} for ${where} first (it takes a few minutes), then call sweep_arm again.`;
|
|
42
|
+
const run = runs.find((r) => r.dryRun);
|
|
43
|
+
if (!run) return { ok: false, why: `${where} has no dry run. ${next}` };
|
|
44
|
+
if (!run.finishedAt)
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
why: `The dry run on ${where} is still going. Wait for it, then call sweep_arm again.`,
|
|
48
|
+
};
|
|
49
|
+
if (run.error)
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
why: `The latest dry run on ${where} failed (${run.error}). ${next}`,
|
|
53
|
+
};
|
|
54
|
+
const age = now - fromSqliteTime(run.finishedAt).getTime();
|
|
55
|
+
if (age > DRY_RUN_FRESH_MS)
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
why: `The latest dry run on ${where} is ${Math.round(age / 60000)} minutes old; it must be from the last hour. ${next}`,
|
|
59
|
+
};
|
|
60
|
+
return { ok: true, run };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function totalsLine(run: SweepRun, now = Date.now()): string {
|
|
64
|
+
const d = run.details;
|
|
65
|
+
const mins = run.finishedAt
|
|
66
|
+
? Math.max(
|
|
67
|
+
0,
|
|
68
|
+
Math.round((now - fromSqliteTime(run.finishedAt).getTime()) / 60000)
|
|
69
|
+
)
|
|
70
|
+
: 0;
|
|
71
|
+
return (
|
|
72
|
+
`dry run #${run.id} (${mins} min ago): would remove ${run.removed} (${GB(run.freedBytes)}), ` +
|
|
73
|
+
`${d?.pendingTotal?.count ?? 0} (${GB(d?.pendingTotal?.bytes ?? 0)}) waiting for the full clean-up, ${run.skipped} kept` +
|
|
74
|
+
(d?.report ? `; list: ${cleanRemoteText(d.report)}` : "")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Machine {
|
|
79
|
+
hostId: string;
|
|
80
|
+
name: string;
|
|
81
|
+
link: HostLink | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function machineOf(ref: string): Machine {
|
|
85
|
+
const hostId = hostIdNamed(ref);
|
|
86
|
+
if (!isRemoteHost(hostId))
|
|
87
|
+
return { hostId, name: "this machine", link: null };
|
|
88
|
+
return {
|
|
89
|
+
hostId,
|
|
90
|
+
name: getHost(hostId)?.name ?? ref,
|
|
91
|
+
link: requireHostLink(hostId),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Another machine's numbers, read defensively.
|
|
96
|
+
function clean(state: SweepState): SweepState {
|
|
97
|
+
const num = (n: unknown) => (Number.isFinite(Number(n)) ? Number(n) : 0);
|
|
98
|
+
return {
|
|
99
|
+
...state,
|
|
100
|
+
armed: state.armed === true,
|
|
101
|
+
runs: (Array.isArray(state.runs) ? state.runs : []).map((r) => ({
|
|
102
|
+
...r,
|
|
103
|
+
id: num(r.id),
|
|
104
|
+
dryRun: r.dryRun === true,
|
|
105
|
+
removed: num(r.removed),
|
|
106
|
+
freedBytes: num(r.freedBytes),
|
|
107
|
+
skipped: num(r.skipped),
|
|
108
|
+
error: r.error ? cleanRemoteText(r.error) : null,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const readState = async (m: Machine): Promise<SweepState> =>
|
|
114
|
+
m.link
|
|
115
|
+
? clean(await hostApi<SweepState>(m.link, "/api/worktree-sweep"))
|
|
116
|
+
: sweepState();
|
|
117
|
+
|
|
118
|
+
async function setArmed(m: Machine, armed: boolean): Promise<void> {
|
|
119
|
+
if (!m.link) return setReaperArmed(armed);
|
|
120
|
+
if (!armed) {
|
|
121
|
+
await hostApi(m.link, "/api/worktree-sweep", {
|
|
122
|
+
method: "PUT",
|
|
123
|
+
body: { armed: false },
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const port = remotePort(m.link.url);
|
|
128
|
+
await hostExec(
|
|
129
|
+
m.hostId,
|
|
130
|
+
`curl -fsS -m 10 -X PUT -H 'Content-Type: application/json' -d '{"armed":true}' http://127.0.0.1:${port}/api/worktree-sweep`,
|
|
131
|
+
20000
|
|
132
|
+
).catch((err) => {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Couldn't turn it on for ${m.name}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
if (!(await readState(m)).armed)
|
|
138
|
+
throw new Error(`${m.name} did not turn the full clean-up on`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function sweepArm(
|
|
142
|
+
workspaceId: string,
|
|
143
|
+
machine: string,
|
|
144
|
+
on: boolean,
|
|
145
|
+
reason: string
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const m = machineOf(machine);
|
|
148
|
+
const state = await readState(m);
|
|
149
|
+
const latest = state.runs.find((r) => r.dryRun && r.finishedAt && !r.error);
|
|
150
|
+
if (state.armed === on)
|
|
151
|
+
return `The full clean-up is already ${on ? "on" : "off"} for ${m.name}.`;
|
|
152
|
+
if (on) {
|
|
153
|
+
// Turning it off only makes things safer, so Pause doesn't hold it.
|
|
154
|
+
if (getWorkspace(workspaceId)?.orch_paused_at)
|
|
155
|
+
throw new Error(PAUSED_REFUSAL);
|
|
156
|
+
const verdict = dryRunVerdict(state.runs, m.name);
|
|
157
|
+
if (!verdict.ok) throw new Error(verdict.why);
|
|
158
|
+
}
|
|
159
|
+
await setArmed(m, on);
|
|
160
|
+
const totals = latest ? ` Latest ${totalsLine(latest)}.` : "";
|
|
161
|
+
const line = `Orchestrator turned the full disk clean-up ${on ? "on" : "off"} for ${m.name}: ${reason}.${totals}`;
|
|
162
|
+
addNote(workspaceId, line);
|
|
163
|
+
return line;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function sweepDryRun(machine: string): Promise<string> {
|
|
167
|
+
const m = machineOf(machine);
|
|
168
|
+
const started = `Dry run started on ${m.name}; it takes a few minutes and removes nothing. When Saad has said to turn the full clean-up on, call sweep_arm: it reads this run.`;
|
|
169
|
+
if (m.link) {
|
|
170
|
+
await hostApi(m.link, "/api/worktree-sweep/run", {
|
|
171
|
+
body: { dryRun: true },
|
|
172
|
+
});
|
|
173
|
+
return started;
|
|
174
|
+
}
|
|
175
|
+
await new Promise<void>((resolve, reject) => {
|
|
176
|
+
sweepNow(true).then(() => resolve(), reject);
|
|
177
|
+
setTimeout(resolve, 250);
|
|
178
|
+
});
|
|
179
|
+
return started;
|
|
180
|
+
}
|
|
@@ -29,6 +29,8 @@ export const TOOL_NAMES = {
|
|
|
29
29
|
plan_pieces: name("plan_pieces"),
|
|
30
30
|
drop: name("drop"),
|
|
31
31
|
move: name("move"),
|
|
32
|
+
sweep_dry_run: name("sweep_dry_run"),
|
|
33
|
+
sweep_arm: name("sweep_arm"),
|
|
32
34
|
set_priority: name("set_priority"),
|
|
33
35
|
start_now: name("start_now"),
|
|
34
36
|
pause_task: name("pause_task"),
|
|
@@ -38,6 +40,7 @@ export const TOOL_NAMES = {
|
|
|
38
40
|
archive: name("archive"),
|
|
39
41
|
resurrect: name("resurrect"),
|
|
40
42
|
add_project: name("add_project"),
|
|
43
|
+
project_tier: name("project_tier"),
|
|
41
44
|
note: name("note"),
|
|
42
45
|
question: name("question"),
|
|
43
46
|
resolve_question: name("resolve_question"),
|
|
@@ -67,7 +70,8 @@ export const TOOL_NAMES = {
|
|
|
67
70
|
// task worktree other than its own quick changes, writing AgentOS's state,
|
|
68
71
|
// stopping AgentOS or its tmux, and reading secrets. That catches honest
|
|
69
72
|
// mistakes, not a determined bypass. Its file writes are checked the same
|
|
70
|
-
// way, against a longer list (write-guard.ts).
|
|
73
|
+
// way, against a longer list (write-guard.ts). All of that applies only with
|
|
74
|
+
// the workspace's Orchestrator full access off (full-access.ts).
|
|
71
75
|
export const ORCHESTRATOR_PERMISSIONS: {
|
|
72
76
|
permissionMode: PermissionMode;
|
|
73
77
|
allowedTools: string[];
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { BRANCH_NAME } from "../git";
|
|
6
6
|
import { RAISED_KINDS } from "./asks";
|
|
7
7
|
import { MAX_RULE_CHARS } from "./rules";
|
|
8
|
+
import { PROJECT_TIERS } from "../project-tier/types";
|
|
8
9
|
import { PROJECT_NAME } from "../project-add/name";
|
|
9
10
|
import { TASK_ROLES, type ModelRole } from "../models/roles";
|
|
10
11
|
import { PRIORITIES } from "../tasks/schedule-types";
|
|
@@ -21,6 +22,13 @@ const view = z
|
|
|
21
22
|
"How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
const sweepMachine = z
|
|
26
|
+
.string()
|
|
27
|
+
.trim()
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(200)
|
|
30
|
+
.describe('A linked machine\'s name (see Machines), or "here" for this one');
|
|
31
|
+
|
|
24
32
|
const modelRef = z
|
|
25
33
|
.string()
|
|
26
34
|
.trim()
|
|
@@ -153,8 +161,9 @@ export const TOOL_SHAPES = {
|
|
|
153
161
|
model: modelOverride,
|
|
154
162
|
plan: z
|
|
155
163
|
.boolean()
|
|
164
|
+
.optional()
|
|
156
165
|
.describe(
|
|
157
|
-
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
|
|
166
|
+
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself. Default: false in a fast project, the workspace's setting in a production one"
|
|
158
167
|
),
|
|
159
168
|
plan_only: z
|
|
160
169
|
.boolean()
|
|
@@ -167,6 +176,7 @@ export const TOOL_SHAPES = {
|
|
|
167
176
|
.trim()
|
|
168
177
|
.min(3)
|
|
169
178
|
.max(200)
|
|
179
|
+
.optional()
|
|
170
180
|
.describe(
|
|
171
181
|
'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
|
|
172
182
|
),
|
|
@@ -268,6 +278,17 @@ export const TOOL_SHAPES = {
|
|
|
268
278
|
'A linked machine\'s name (see Machines), or "here" to bring the task back'
|
|
269
279
|
),
|
|
270
280
|
},
|
|
281
|
+
sweep_dry_run: { machine: sweepMachine },
|
|
282
|
+
sweep_arm: {
|
|
283
|
+
machine: sweepMachine,
|
|
284
|
+
on: z.boolean().describe("true turns the full clean-up on, false off"),
|
|
285
|
+
reason: z
|
|
286
|
+
.string()
|
|
287
|
+
.trim()
|
|
288
|
+
.min(1)
|
|
289
|
+
.max(500)
|
|
290
|
+
.describe("What Saad said, and when; it goes in the decision log"),
|
|
291
|
+
},
|
|
271
292
|
set_priority: {
|
|
272
293
|
task: ref("A queued or running task: name, project/name or id"),
|
|
273
294
|
priority: z.enum(PRIORITIES),
|
|
@@ -320,6 +341,10 @@ export const TOOL_SHAPES = {
|
|
|
320
341
|
.optional()
|
|
321
342
|
.describe("A new project's name (default: the folder's)"),
|
|
322
343
|
},
|
|
344
|
+
project_tier: {
|
|
345
|
+
project: z.string().trim().min(1).max(200).optional(),
|
|
346
|
+
tier: z.enum(PROJECT_TIERS).optional(),
|
|
347
|
+
},
|
|
323
348
|
note: { text: z.string().trim().min(1).max(2000) },
|
|
324
349
|
question: {
|
|
325
350
|
text: z
|
|
@@ -409,7 +434,21 @@ export const TOOL_SHAPES = {
|
|
|
409
434
|
.optional()
|
|
410
435
|
.describe("Run it again even if this commit has a verdict"),
|
|
411
436
|
},
|
|
412
|
-
sign_off: {
|
|
437
|
+
sign_off: {
|
|
438
|
+
task,
|
|
439
|
+
direct: z
|
|
440
|
+
.boolean()
|
|
441
|
+
.optional()
|
|
442
|
+
.describe(
|
|
443
|
+
"Merge now at the PR's head without the gates; needs Orchestrator full access and a reason"
|
|
444
|
+
),
|
|
445
|
+
reason: z
|
|
446
|
+
.string()
|
|
447
|
+
.trim()
|
|
448
|
+
.max(300)
|
|
449
|
+
.optional()
|
|
450
|
+
.describe("Why the gates are skipped (required with direct)"),
|
|
451
|
+
},
|
|
413
452
|
arm: { task },
|
|
414
453
|
disarm: { task },
|
|
415
454
|
ask_saad: {
|