@saadnvd1/agent-os 0.3.671 → 0.3.673
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/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/projects/[id]/route.ts +26 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Projects/ProjectSettingsDialog.tsx +7 -0
- package/components/Projects/ProjectTierField.tsx +39 -0
- package/components/Terminal/index.tsx +6 -5
- package/components/Workspaces/ProjectTiersForm.tsx +33 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/projects/queries.ts +4 -0
- package/data/sessions/queries.ts +2 -1
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +7 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/types.ts +4 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/armed.test-helpers.ts +4 -0
- package/lib/orchestrator/armed.ts +14 -1
- package/lib/orchestrator/brief.ts +9 -2
- package/lib/orchestrator/fast-lane-state.ts +69 -0
- package/lib/orchestrator/fast-lane.ts +96 -0
- package/lib/orchestrator/gates.ts +8 -2
- package/lib/orchestrator/project-tier-tool.ts +33 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -1
- package/lib/orchestrator/signoff.test-helpers.ts +3 -0
- package/lib/orchestrator/signoff.ts +36 -19
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +8 -1
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/watcher.ts +5 -0
- 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/sessions/launch.ts +12 -6
- package/lib/start-options.ts +8 -1
- package/lib/tasks/finish.ts +2 -1
- package/lib/tasks/index.ts +10 -5
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
- package/components/Chat/composer/useImageDrop.ts +0 -52
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T2130_project_tier",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A project's tier: "production" tasks pass every merge gate, "fast"
|
|
7
|
+
// ones merge as soon as their PR opens (orchestrator/fast-lane.ts). The
|
|
8
|
+
// LumifyHub and Workstak projects start production, in the same step as
|
|
9
|
+
// the column, so running this again never overwrites a tier Saad changed.
|
|
10
|
+
const cols = db.prepare(`PRAGMA table_info(projects)`).all() as {
|
|
11
|
+
name: string;
|
|
12
|
+
}[];
|
|
13
|
+
if (!cols.some((c) => c.name === "tier")) {
|
|
14
|
+
db.exec(
|
|
15
|
+
`ALTER TABLE projects ADD COLUMN tier TEXT NOT NULL DEFAULT 'fast'`
|
|
16
|
+
);
|
|
17
|
+
db.exec(
|
|
18
|
+
`UPDATE projects SET tier = 'production'
|
|
19
|
+
WHERE lower(name) LIKE 'lumifyhub%' OR lower(name) LIKE 'workstak%'
|
|
20
|
+
OR workspace_id IN (
|
|
21
|
+
SELECT id FROM workspaces WHERE lower(name) IN ('lumifyhub', 'workstak')
|
|
22
|
+
)`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
// What the fast lane has done to a task: the PR it armed (so a disarm
|
|
26
|
+
// sticks) and the merge whose CI it still watches.
|
|
27
|
+
db.exec(`CREATE TABLE IF NOT EXISTS fast_lane_tasks (
|
|
28
|
+
session_id TEXT PRIMARY KEY,
|
|
29
|
+
workspace_id TEXT NOT NULL,
|
|
30
|
+
auto_armed_pr INTEGER,
|
|
31
|
+
merged_sha TEXT,
|
|
32
|
+
merged_pr INTEGER,
|
|
33
|
+
merged_at TEXT,
|
|
34
|
+
ci_checked_at TEXT,
|
|
35
|
+
ci_done_at TEXT
|
|
36
|
+
)`);
|
|
37
|
+
},
|
|
38
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261019T1200_chat_queue_files",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Files attached to a queued message, as a JSON list of references to
|
|
7
|
+
// the session's attachments folder (never the bytes).
|
|
8
|
+
const cols = db.prepare(`PRAGMA table_info(chat_queue)`).all() as {
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
if (!cols.some((c) => c.name === "files"))
|
|
12
|
+
db.exec(`ALTER TABLE chat_queue ADD COLUMN files TEXT`);
|
|
13
|
+
},
|
|
14
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -175,6 +175,9 @@ export interface Group {
|
|
|
175
175
|
created_at: string;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// production: every merge gate applies. fast: a task's PR merges when it opens.
|
|
179
|
+
export type ProjectTier = "production" | "fast";
|
|
180
|
+
|
|
178
181
|
export interface Project {
|
|
179
182
|
id: string;
|
|
180
183
|
name: string;
|
|
@@ -190,6 +193,7 @@ export interface Project {
|
|
|
190
193
|
lh_board_id: string | null;
|
|
191
194
|
lh_board_name: string | null;
|
|
192
195
|
lh_board_page_id: string | null;
|
|
196
|
+
tier: ProjectTier;
|
|
193
197
|
// What its tasks need from a machine (JSON array), and where that came from.
|
|
194
198
|
machine_needs?: string | null;
|
|
195
199
|
machine_needs_source?: string | null;
|
package/lib/file-upload.ts
CHANGED
|
@@ -1,34 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Converts the file to base64 and POSTs to /api/files/upload-temp.
|
|
4
|
-
*
|
|
5
|
-
* @param file - The file to upload
|
|
6
|
-
* @returns The path to the uploaded file, or null if upload failed
|
|
7
|
-
*/
|
|
8
|
-
export async function uploadFileToTemp(file: File): Promise<string | null> {
|
|
9
|
-
const buffer = await file.arrayBuffer();
|
|
10
|
-
const base64 = btoa(
|
|
11
|
-
new Uint8Array(buffer).reduce(
|
|
12
|
-
(data, byte) => data + String.fromCharCode(byte),
|
|
13
|
-
""
|
|
14
|
-
)
|
|
15
|
-
);
|
|
1
|
+
import type { AttachmentKind } from "./attachments/policy";
|
|
2
|
+
import type { ChatFile } from "./attachments/types";
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
4
|
+
export type UploadResult =
|
|
5
|
+
| { ok: true; file: ChatFile; kind: AttachmentKind }
|
|
6
|
+
| { ok: false; error: string };
|
|
7
|
+
|
|
8
|
+
function toBase64(file: File): Promise<string> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const reader = new FileReader();
|
|
11
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
12
|
+
reader.onerror = () => reject(reader.error);
|
|
13
|
+
reader.readAsDataURL(file);
|
|
25
14
|
});
|
|
15
|
+
}
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Upload a file through /api/files/upload-temp. With a `sessionId` (or
|
|
19
|
+
* "draft") the server checks it against its attachment limits and keeps it in
|
|
20
|
+
* that session's attachments folder; without one it goes to temp storage.
|
|
21
|
+
* The error is the server's own message, ready to show.
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadFile(
|
|
24
|
+
file: File,
|
|
25
|
+
sessionId?: string
|
|
26
|
+
): Promise<UploadResult> {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch("/api/files/upload-temp", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
filename: file.name || `file-${Date.now()}`,
|
|
33
|
+
base64: await toBase64(file),
|
|
34
|
+
mimeType: file.type || "application/octet-stream",
|
|
35
|
+
sessionId,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
const data = await res.json().catch(() => ({}));
|
|
39
|
+
if (res.ok && data.path)
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
kind: data.kind,
|
|
43
|
+
file: {
|
|
44
|
+
path: data.path,
|
|
45
|
+
name: data.name,
|
|
46
|
+
size: data.size,
|
|
47
|
+
mediaType: data.mediaType,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: data.error ?? `Couldn't upload ${file.name} (${res.status})`,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return { ok: false, error: `Couldn't upload ${file.name}` };
|
|
30
56
|
}
|
|
57
|
+
}
|
|
31
58
|
|
|
32
|
-
|
|
59
|
+
/** The saved path, or null when the upload failed (terminal and picker). */
|
|
60
|
+
export async function uploadFileToTemp(
|
|
61
|
+
file: File,
|
|
62
|
+
sessionId?: string
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const r = await uploadFile(file, sessionId);
|
|
65
|
+
if (r.ok) return r.file.path;
|
|
66
|
+
console.error("Upload failed:", r.error);
|
|
33
67
|
return null;
|
|
34
68
|
}
|
package/lib/intake/intake.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getWorkspace } from "../workspaces";
|
|
|
8
8
|
import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
|
|
9
9
|
import { buildIntakeMessage, type IntakeMeta } from "./message";
|
|
10
10
|
import { sniff } from "./sniff";
|
|
11
|
+
import { safeName } from "../attachments/store";
|
|
11
12
|
|
|
12
13
|
export class IntakeError extends Error {
|
|
13
14
|
constructor(
|
|
@@ -39,23 +40,7 @@ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
|
|
|
39
40
|
const workspaceRoot = (workspaceId: string) =>
|
|
40
41
|
path.join(dataRoot(), "intake", segment(workspaceId));
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
export function safeName(name: string, taken: Set<string>, n: number): string {
|
|
44
|
-
let base = path.basename(name.replace(/\\/g, "/"));
|
|
45
|
-
base = base
|
|
46
|
-
.replace(/[^a-zA-Z0-9.-]/g, "_")
|
|
47
|
-
.replace(/^\.+/, "")
|
|
48
|
-
.slice(0, 80);
|
|
49
|
-
if (!base) base = `file-${n}`;
|
|
50
|
-
let out = base;
|
|
51
|
-
for (let i = 2; taken.has(out); i++) {
|
|
52
|
-
const dot = base.lastIndexOf(".");
|
|
53
|
-
out =
|
|
54
|
-
dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
|
|
55
|
-
}
|
|
56
|
-
taken.add(out);
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
43
|
+
export { safeName };
|
|
59
44
|
|
|
60
45
|
function checkMeta(meta: IntakeMeta) {
|
|
61
46
|
if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
|
package/lib/intake/sniff.ts
CHANGED
|
@@ -9,7 +9,7 @@ const BLOCKED_NAME =
|
|
|
9
9
|
const starts = (b: Buffer, sig: number[], at = 0) =>
|
|
10
10
|
sig.every((v, i) => b[at + i] === v);
|
|
11
11
|
|
|
12
|
-
function isText(b: Buffer): boolean {
|
|
12
|
+
export function isText(b: Buffer): boolean {
|
|
13
13
|
const head = b.subarray(0, 8192);
|
|
14
14
|
if (head.includes(0)) return false;
|
|
15
15
|
try {
|
|
@@ -125,6 +125,10 @@ export function setup() {
|
|
|
125
125
|
workingDirectory: repo,
|
|
126
126
|
});
|
|
127
127
|
setProjectWorkspace(project.id, workspace.id);
|
|
128
|
+
// These tests judge today's gates; fast-lane.test.ts flips it.
|
|
129
|
+
db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
|
|
130
|
+
project.id
|
|
131
|
+
);
|
|
128
132
|
ensureOrchestrator(workspace.id);
|
|
129
133
|
const task = seedSession({
|
|
130
134
|
projectId: project.id,
|
|
@@ -26,6 +26,7 @@ import { getOrchestrator } from "./home";
|
|
|
26
26
|
import { isPaused } from "./pause";
|
|
27
27
|
import { review } from "./review";
|
|
28
28
|
import { judge, mergeJudged } from "./signoff";
|
|
29
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
29
30
|
import { workspaceTask } from "./targets";
|
|
30
31
|
import { markStatusDirty } from "../status/dirty";
|
|
31
32
|
import { setPr } from "../db/session-parts";
|
|
@@ -88,9 +89,10 @@ export const anyArmed = (workspaceId: string) =>
|
|
|
88
89
|
armedTasks(workspaceId).length > 0;
|
|
89
90
|
|
|
90
91
|
// The orchestrator's `arm` and `disarm`.
|
|
91
|
-
export function arm(workspaceId: string, ref: string): string {
|
|
92
|
+
export async function arm(workspaceId: string, ref: string): Promise<string> {
|
|
92
93
|
const task = workspaceTask(workspaceId, ref);
|
|
93
94
|
armTask(task);
|
|
95
|
+
if (isFast(task)) return armedNow(workspaceId, task);
|
|
94
96
|
return `Armed ${task.name}: AgentOS merges its PR the moment every sign_off gate passes at its head, and tells you with one event. A gate failure also comes as one event; it stays armed for the fix, unless the failure goes to Saad. No need to call sign_off.`;
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -124,6 +126,17 @@ export function armTask(task: Session): void {
|
|
|
124
126
|
setArmed(task.id, true);
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
// A fast task merges the moment it's armed, if its PR is open.
|
|
130
|
+
async function armedNow(workspaceId: string, task: Session): Promise<string> {
|
|
131
|
+
const verdict = await judge(workspaceId, task, false, { count: false });
|
|
132
|
+
if (!verdict.ok)
|
|
133
|
+
return `Armed ${task.name} (fast project); not merged yet.\n${verdict.text}\nIt stays armed and merges once that is fixed.`;
|
|
134
|
+
await merge(workspaceId, task, verdict, defaults);
|
|
135
|
+
return stillArmed(workspaceId, task.id)
|
|
136
|
+
? `Armed ${task.name} (fast project); the merge was refused, see the event. It stays armed.`
|
|
137
|
+
: `Merged ${task.name} (fast project): PR #${verdict.pr} at ${short(verdict.sha)}, no CI wait or review.`;
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
export function disarm(workspaceId: string, ref: string): string {
|
|
128
141
|
const task = workspaceTask(workspaceId, ref);
|
|
129
142
|
setArmed(task.id, false);
|
|
@@ -28,6 +28,7 @@ export interface BriefProject {
|
|
|
28
28
|
path: string;
|
|
29
29
|
board: string | null;
|
|
30
30
|
defaultBranch: string;
|
|
31
|
+
tier?: "production" | "fast";
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Read when the brief is built, so a switch flipped in Settings reaches the
|
|
@@ -121,7 +122,7 @@ Reading:
|
|
|
121
122
|
Acting:
|
|
122
123
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
123
124
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
124
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
125
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
125
126
|
- \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
|
|
126
127
|
- \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
|
|
127
128
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
@@ -145,6 +146,7 @@ Acting:
|
|
|
145
146
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
146
147
|
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
147
148
|
- \`${TOOL_NAMES.resurrect}\` (session, reason): bring a dropped, done or archived session back with its conversation and worktree, in the state it had (a plan-only task waits for approval again). Use it when a drop or done was a mistake, or when Saad asks; refused for a merged PR, a missing conversation or a removed project. A restored task counts against the running limit and the brakes like a start.
|
|
149
|
+
- \`${TOOL_NAMES.project_tier}\` (project?, tier?): read the workspace's projects with their tiers, or one's; with tier (production or fast) set it. Only when Saad says to.
|
|
148
150
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
149
151
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
150
152
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|
|
@@ -166,11 +168,15 @@ Act through these tools, not the shell: they're scoped to this workspace and bra
|
|
|
166
168
|
|
|
167
169
|
## Untrusted text
|
|
168
170
|
|
|
171
|
+
### Tiers
|
|
172
|
+
|
|
173
|
+
Every project has a tier (shown on its line above; \`project_tier\` reads or sets it, and you set it only when Saad says to). production: everything above applies. fast (the default, for Saad's internal tools and AgentOS itself): a task's PR merges as soon as it opens or is pushed, so rigorous testing and CI don't hold it. AgentOS arms a fast task by itself when its PR opens, and \`arm\` on one merges it right away. Only these still apply: base, stack order, no AI attribution in the PR body, Saad's hold (BLOCKED, an open ask, a draft), the rules on the diff (no secrets, nothing outside the repository, not only lockfiles), merge approvals for sensitive files, and the hard lines. There is no ci or review gate, no card scope check and no loop guard, \`review\` doesn't start a reviewer, and a fast task skips the plan step unless you pass plan true. If CI fails after a fast merge you get one event; fix it forward with a task.
|
|
174
|
+
|
|
169
175
|
${UNTRUSTED_RULE}`;
|
|
170
176
|
|
|
171
177
|
function projectLine(p: BriefProject): string {
|
|
172
178
|
const board = p.board ? `, LumifyHub board "${p.board}"` : "";
|
|
173
|
-
return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board})`;
|
|
179
|
+
return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board}, tier ${p.tier ?? "production"})`;
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
export function orchestratorBrief(input: {
|
|
@@ -217,6 +223,7 @@ export async function loadOrchestratorBrief(session: Session): Promise<string> {
|
|
|
217
223
|
defaultBranch: await getDefaultBranch(p.working_directory).catch(
|
|
218
224
|
() => "main"
|
|
219
225
|
),
|
|
226
|
+
tier: p.tier,
|
|
220
227
|
}))
|
|
221
228
|
);
|
|
222
229
|
return orchestratorBrief({
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
|
|
3
|
+
const now = () => new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
4
|
+
|
|
5
|
+
export interface FastLaneRow {
|
|
6
|
+
session_id: string;
|
|
7
|
+
workspace_id: string;
|
|
8
|
+
auto_armed_pr: number | null;
|
|
9
|
+
merged_sha: string | null;
|
|
10
|
+
merged_pr: number | null;
|
|
11
|
+
merged_at: string | null;
|
|
12
|
+
ci_checked_at: string | null;
|
|
13
|
+
ci_done_at: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fastLaneRow(sessionId: string): FastLaneRow | undefined {
|
|
17
|
+
return db
|
|
18
|
+
.prepare(`SELECT * FROM fast_lane_tasks WHERE session_id = ?`)
|
|
19
|
+
.get(sessionId) as FastLaneRow | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function noteAutoArmed(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
workspaceId: string,
|
|
25
|
+
pr: number
|
|
26
|
+
): void {
|
|
27
|
+
db.prepare(
|
|
28
|
+
`INSERT INTO fast_lane_tasks (session_id, workspace_id, auto_armed_pr)
|
|
29
|
+
VALUES (?, ?, ?)
|
|
30
|
+
ON CONFLICT(session_id) DO UPDATE SET auto_armed_pr = excluded.auto_armed_pr`
|
|
31
|
+
).run(sessionId, workspaceId, pr);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A fast merge: the PR whose CI is watched for a while afterwards.
|
|
35
|
+
export function recordFastMerge(
|
|
36
|
+
sessionId: string,
|
|
37
|
+
workspaceId: string,
|
|
38
|
+
pr: number,
|
|
39
|
+
sha: string
|
|
40
|
+
): void {
|
|
41
|
+
db.prepare(
|
|
42
|
+
`INSERT INTO fast_lane_tasks (session_id, workspace_id, merged_sha, merged_pr, merged_at)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?)
|
|
44
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
45
|
+
merged_sha = excluded.merged_sha, merged_pr = excluded.merged_pr,
|
|
46
|
+
merged_at = excluded.merged_at, ci_checked_at = NULL, ci_done_at = NULL`
|
|
47
|
+
).run(sessionId, workspaceId, sha, pr, now());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function markCiChecked(sessionId: string, done: boolean): void {
|
|
51
|
+
db.prepare(
|
|
52
|
+
`UPDATE fast_lane_tasks SET ci_checked_at = ?, ci_done_at = CASE WHEN ? THEN ? ELSE ci_done_at END
|
|
53
|
+
WHERE session_id = ?`
|
|
54
|
+
).run(now(), done ? 1 : 0, now(), sessionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Merges still inside their CI watch.
|
|
58
|
+
export function watchedMerges(
|
|
59
|
+
workspaceId: string,
|
|
60
|
+
sinceHours: number
|
|
61
|
+
): FastLaneRow[] {
|
|
62
|
+
return db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT * FROM fast_lane_tasks
|
|
65
|
+
WHERE workspace_id = ? AND merged_at IS NOT NULL AND ci_done_at IS NULL
|
|
66
|
+
AND merged_at > datetime('now', ?)`
|
|
67
|
+
)
|
|
68
|
+
.all(workspaceId, `-${sinceHours} hours`) as FastLaneRow[];
|
|
69
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fast lane: a project whose tier is "fast" (project-tier) has no CI
|
|
3
|
+
* wait, independent review or scope check on a task's PR. Its gates are
|
|
4
|
+
* trimmed in gates.ts and signoff.ts; this arms each fast task once when its
|
|
5
|
+
* PR opens, so armed.ts merges it on the same pass, and watches the CI of
|
|
6
|
+
* what it merged for a while, telling the orchestrator once if that fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { run } from "../tasks/gh";
|
|
11
|
+
import { checksVerdict, failingCheck, type RollupEntry } from "../tasks/state";
|
|
12
|
+
import { armTask } from "./armed";
|
|
13
|
+
import { queueEventOnce } from "./events";
|
|
14
|
+
import type { SessionFacts } from "./facts";
|
|
15
|
+
import { isPaused } from "./pause";
|
|
16
|
+
import { repoOf } from "./repo";
|
|
17
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
18
|
+
import {
|
|
19
|
+
fastLaneRow,
|
|
20
|
+
markCiChecked,
|
|
21
|
+
noteAutoArmed,
|
|
22
|
+
watchedMerges,
|
|
23
|
+
} from "./fast-lane-state";
|
|
24
|
+
|
|
25
|
+
const CI_WATCH_HOURS = 3;
|
|
26
|
+
const CI_RECHECK_MS = 2 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
export async function autoArmFast(
|
|
29
|
+
workspaceId: string,
|
|
30
|
+
facts: SessionFacts[]
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (isPaused(workspaceId)) return;
|
|
33
|
+
for (const f of facts) {
|
|
34
|
+
const pr = f.task?.pr;
|
|
35
|
+
if (!pr || pr.state !== "OPEN") continue;
|
|
36
|
+
const task = db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT * FROM session_rows WHERE id = ? AND task_status = 'running'
|
|
39
|
+
AND merge_armed_at IS NULL`
|
|
40
|
+
)
|
|
41
|
+
.get(f.id) as Session | undefined;
|
|
42
|
+
if (!task || !isFast(task)) continue;
|
|
43
|
+
if (fastLaneRow(task.id)?.auto_armed_pr === pr.number) continue;
|
|
44
|
+
try {
|
|
45
|
+
armTask(task);
|
|
46
|
+
noteAutoArmed(task.id, workspaceId, pr.number);
|
|
47
|
+
} catch {
|
|
48
|
+
// With Saad, or no orchestrator to tell: retried next pass.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ghRollup(task: Session, pr: number): Promise<RollupEntry[]> {
|
|
54
|
+
const out = await run(
|
|
55
|
+
"gh",
|
|
56
|
+
["pr", "view", String(pr), "--json", "statusCheckRollup"],
|
|
57
|
+
repoOf(task),
|
|
58
|
+
15000
|
|
59
|
+
);
|
|
60
|
+
return (JSON.parse(out).statusCheckRollup ?? []) as RollupEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function followFastMerges(
|
|
64
|
+
workspaceId: string,
|
|
65
|
+
rollupOf: typeof ghRollup = ghRollup
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
for (const row of watchedMerges(workspaceId, CI_WATCH_HOURS)) {
|
|
69
|
+
const checked = row.ci_checked_at
|
|
70
|
+
? Date.parse(row.ci_checked_at.replace(" ", "T") + "Z")
|
|
71
|
+
: 0;
|
|
72
|
+
if (now - checked < CI_RECHECK_MS) continue;
|
|
73
|
+
const task = db
|
|
74
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
75
|
+
.get(row.session_id) as Session | undefined;
|
|
76
|
+
if (!task || !row.merged_pr) {
|
|
77
|
+
markCiChecked(row.session_id, true);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const rollup = await rollupOf(task, row.merged_pr);
|
|
82
|
+
const verdict = checksVerdict(rollup);
|
|
83
|
+
if (verdict === "fail") {
|
|
84
|
+
queueEventOnce(
|
|
85
|
+
workspaceId,
|
|
86
|
+
`fast-ci:${row.session_id}:${row.merged_sha}`,
|
|
87
|
+
row.session_id,
|
|
88
|
+
`task ${task.name}: merged on the fast lane (PR #${row.merged_pr}), but CI failed after the merge${failingCheck(rollup) ? ` (${failingCheck(rollup)})` : ""}. Fix forward with a task.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
markCiChecked(row.session_id, verdict !== "pending");
|
|
92
|
+
} catch {
|
|
93
|
+
markCiChecked(row.session_id, false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -165,7 +165,12 @@ export function checkGate(
|
|
|
165
165
|
return { gate, state: "pass" };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
// `fast`: the fast lane keeps Saad's hold, the rules on the diff, the stack
|
|
169
|
+
// and the PR body's attribution; no CI, review or card scope check.
|
|
170
|
+
export function evaluateGates(
|
|
171
|
+
i: GateInput,
|
|
172
|
+
opts: { fast?: boolean } = {}
|
|
173
|
+
): GateOutcome[] {
|
|
169
174
|
const blocked: GateOutcome = i.prBlocked
|
|
170
175
|
? { gate: "blocked", state: "fail", reason: i.prBlocked }
|
|
171
176
|
: i.prDraft
|
|
@@ -185,7 +190,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
|
|
|
185
190
|
: { gate: "blocked", state: "pass" };
|
|
186
191
|
const scope: GateOutcome = i.ruleBreaks.length
|
|
187
192
|
? { gate: "scope", state: "fail", reason: i.ruleBreaks.join("; ") }
|
|
188
|
-
: i.fromCard
|
|
193
|
+
: i.fromCard && !opts.fast
|
|
189
194
|
? checkGate("scope", i.scope, i.sha)
|
|
190
195
|
: { gate: "scope", state: "pass" };
|
|
191
196
|
const stack: GateOutcome = i.stackRefusal
|
|
@@ -197,6 +202,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
|
|
|
197
202
|
const codeReview: GateOutcome = i.codeReview.refusal
|
|
198
203
|
? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
|
|
199
204
|
: { gate: "code-review", state: "pass" };
|
|
205
|
+
if (opts.fast) return [codeReview, blocked, scope, stack];
|
|
200
206
|
return [
|
|
201
207
|
ciGate(i),
|
|
202
208
|
checkGate("review", i.review, i.sha),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROJECT_TIERS,
|
|
3
|
+
setProjectTier,
|
|
4
|
+
type ProjectTier,
|
|
5
|
+
} from "../project-tier";
|
|
6
|
+
import { addNote } from "./notes";
|
|
7
|
+
import { workspaceProjects } from "./brief";
|
|
8
|
+
import { workspaceProject } from "./targets";
|
|
9
|
+
|
|
10
|
+
const line = (name: string, tier: ProjectTier) =>
|
|
11
|
+
`- ${name}: ${tier}${tier === "fast" ? " (merges when its PR opens)" : " (CI, review and scope gate its merges)"}`;
|
|
12
|
+
|
|
13
|
+
// project_tier: read one project's tier, or all of the workspace's; with a
|
|
14
|
+
// tier, set it (Saad's call: only when he says so).
|
|
15
|
+
export function projectTierTool(
|
|
16
|
+
workspaceId: string,
|
|
17
|
+
projectRef?: string,
|
|
18
|
+
tier?: (typeof PROJECT_TIERS)[number]
|
|
19
|
+
): string {
|
|
20
|
+
if (!projectRef) {
|
|
21
|
+
if (tier) throw new Error("Say which project to set");
|
|
22
|
+
const all = workspaceProjects(workspaceId);
|
|
23
|
+
return all.length
|
|
24
|
+
? all.map((p) => line(p.name, p.tier)).join("\n")
|
|
25
|
+
: "No projects in this workspace.";
|
|
26
|
+
}
|
|
27
|
+
const project = workspaceProject(workspaceId, projectRef);
|
|
28
|
+
if (!tier) return line(project.name, project.tier);
|
|
29
|
+
if (tier === project.tier) return line(project.name, project.tier);
|
|
30
|
+
setProjectTier(project.id, tier);
|
|
31
|
+
addNote(workspaceId, `Set ${project.name} to ${tier}.`);
|
|
32
|
+
return `Set ${project.name} to ${tier}.\n${line(project.name, tier)}`;
|
|
33
|
+
}
|
|
@@ -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,6 +26,7 @@ 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";
|
|
@@ -237,6 +238,10 @@ export async function runTool(
|
|
|
237
238
|
const a = p(tool, raw);
|
|
238
239
|
return addProject(w, a.path, a.name);
|
|
239
240
|
}
|
|
241
|
+
case "project_tier": {
|
|
242
|
+
const a = p(tool, raw);
|
|
243
|
+
return projectTierTool(w, a.project, a.tier);
|
|
244
|
+
}
|
|
240
245
|
case "note":
|
|
241
246
|
addNote(w, p(tool, raw).text);
|
|
242
247
|
return "Noted.";
|
|
@@ -277,7 +282,7 @@ export async function runTool(
|
|
|
277
282
|
return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
|
|
278
283
|
}
|
|
279
284
|
case "arm":
|
|
280
|
-
return arm(w, p(tool, raw).task);
|
|
285
|
+
return await arm(w, p(tool, raw).task);
|
|
281
286
|
case "disarm":
|
|
282
287
|
return disarm(w, p(tool, raw).task);
|
|
283
288
|
case "ask_saad":
|
|
@@ -231,6 +231,9 @@ export function setup(
|
|
|
231
231
|
workingDirectory: repo,
|
|
232
232
|
});
|
|
233
233
|
setProjectWorkspace(project.id, workspace.id);
|
|
234
|
+
db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
|
|
235
|
+
project.id
|
|
236
|
+
);
|
|
234
237
|
ensureOrchestrator(workspace.id);
|
|
235
238
|
const task = seedSession({
|
|
236
239
|
projectId: project.id,
|