mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
5
foundry/packages/backend/src/actors/task/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/task/db/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const taskDb = db({ schema, migrations });
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/task/db/drizzle",
|
||||
schema: "./src/actors/task/db/schema.ts",
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
CREATE TABLE `task` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`auto_committed` integer DEFAULT 0,
|
||||
`pushed` integer DEFAULT 0,
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`needs_push` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_runtime` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`sandbox_id` text,
|
||||
`session_id` text,
|
||||
`switch_target` text,
|
||||
`status_message` text,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE `task` DROP COLUMN `auto_committed`;--> statement-breakpoint
|
||||
ALTER TABLE `task` DROP COLUMN `pushed`;--> statement-breakpoint
|
||||
ALTER TABLE `task` DROP COLUMN `needs_push`;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
ALTER TABLE `task_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE `task_sandboxes` (
|
||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`switch_target` text NOT NULL,
|
||||
`cwd` text,
|
||||
`status_message` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` ADD `active_cwd` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `task_sandboxes` (
|
||||
`sandbox_id`,
|
||||
`provider_id`,
|
||||
`switch_target`,
|
||||
`cwd`,
|
||||
`status_message`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
r.`active_sandbox_id`,
|
||||
(SELECT h.`provider_id` FROM `task` h WHERE h.`id` = 1),
|
||||
r.`active_switch_target`,
|
||||
r.`active_cwd`,
|
||||
r.`status_message`,
|
||||
COALESCE((SELECT h.`created_at` FROM `task` h WHERE h.`id` = 1), r.`updated_at`),
|
||||
r.`updated_at`
|
||||
FROM `task_runtime` r
|
||||
WHERE
|
||||
r.`id` = 1
|
||||
AND r.`active_sandbox_id` IS NOT NULL
|
||||
AND r.`active_switch_target` IS NOT NULL
|
||||
ON CONFLICT(`sandbox_id`) DO NOTHING;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
-- Allow tasks to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE `task__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO `task__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `task`;
|
||||
|
||||
DROP TABLE `task`;
|
||||
ALTER TABLE `task__new` RENAME TO `task`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS `task__new`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `task__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO `task__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE `task__new` RENAME TO `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE `task_workbench_sessions` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`session_name` text NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`unread` integer DEFAULT 0 NOT NULL,
|
||||
`draft_text` text DEFAULT '' NOT NULL,
|
||||
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
|
||||
`draft_updated_at` integer,
|
||||
`created` integer DEFAULT 1 NOT NULL,
|
||||
`closed` integer DEFAULT 0 NOT NULL,
|
||||
`thinking_since_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"task": {
|
||||
"name": "task",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"auto_committed": {
|
||||
"name": "auto_committed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pushed": {
|
||||
"name": "pushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"needs_push": {
|
||||
"name": "needs_push",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0fca0f14-69df-4fca-bc52-29e902247909",
|
||||
"prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"tables": {
|
||||
"task": {
|
||||
"name": "task",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6",
|
||||
"prevId": "0fca0f14-69df-4fca-bc52-29e902247909",
|
||||
"tables": {
|
||||
"task": {
|
||||
"name": "task",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_sandbox_id": {
|
||||
"name": "active_sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_session_id": {
|
||||
"name": "active_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_switch_target": {
|
||||
"name": "active_switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_cwd": {
|
||||
"name": "active_cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_sandboxes": {
|
||||
"name": "task_sandboxes",
|
||||
"columns": {
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cwd": {
|
||||
"name": "cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {
|
||||
"\"task_runtime\".\"sandbox_id\"": "\"task_runtime\".\"active_sandbox_id\"",
|
||||
"\"task_runtime\".\"session_id\"": "\"task_runtime\".\"active_session_id\"",
|
||||
"\"task_runtime\".\"switch_target\"": "\"task_runtime\".\"active_switch_target\""
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924374665,
|
||||
"tag": "0000_condemned_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770947251055,
|
||||
"tag": "0001_rapid_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770948428907,
|
||||
"tag": "0002_lazy_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1771027535276,
|
||||
"tag": "0003_plucky_bran",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1771097651912,
|
||||
"tag": "0004_focused_shuri",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1771370000000,
|
||||
"tag": "0005_sandbox_actor_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
245
foundry/packages/backend/src/actors/task/db/migrations.ts
Normal file
245
foundry/packages/backend/src/actors/task/db/migrations.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
|
||||
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
|
||||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924374665,
|
||||
tag: "0000_condemned_maria_hill",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1770947251055,
|
||||
tag: "0001_rapid_eddie_brock",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1770948428907,
|
||||
tag: "0002_lazy_moira_mactaggert",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1771027535276,
|
||||
tag: "0003_plucky_bran",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 4,
|
||||
when: 1771097651912,
|
||||
tag: "0004_focused_shuri",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 5,
|
||||
when: 1771370000000,
|
||||
tag: "0005_sandbox_actor_id",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 6,
|
||||
when: 1773020000000,
|
||||
tag: "0006_workbench_sessions",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`task\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`auto_committed\` integer DEFAULT 0,
|
||||
\`pushed\` integer DEFAULT 0,
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`needs_push\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`sandbox_id\` text,
|
||||
\`session_id\` text,
|
||||
\`switch_target\` text,
|
||||
\`status_message\` text,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`,
|
||||
m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE \`task_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`switch_target\` text NOT NULL,
|
||||
\`cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` ADD \`active_cwd\` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO \`task_sandboxes\` (
|
||||
\`sandbox_id\`,
|
||||
\`provider_id\`,
|
||||
\`switch_target\`,
|
||||
\`cwd\`,
|
||||
\`status_message\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
r.\`active_sandbox_id\`,
|
||||
(SELECT h.\`provider_id\` FROM \`task\` h WHERE h.\`id\` = 1),
|
||||
r.\`active_switch_target\`,
|
||||
r.\`active_cwd\`,
|
||||
r.\`status_message\`,
|
||||
COALESCE((SELECT h.\`created_at\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
|
||||
r.\`updated_at\`
|
||||
FROM \`task_runtime\` r
|
||||
WHERE
|
||||
r.\`id\` = 1
|
||||
AND r.\`active_sandbox_id\` IS NOT NULL
|
||||
AND r.\`active_switch_target\` IS NOT NULL
|
||||
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
|
||||
`,
|
||||
m0003: `-- Allow tasks to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`task\`;
|
||||
|
||||
DROP TABLE \`task\`;
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
`,
|
||||
m0004: `-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS \`task__new\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
`,
|
||||
m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`,
|
||||
m0006: `CREATE TABLE \`task_workbench_sessions\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_name\` text NOT NULL,
|
||||
\`model\` text NOT NULL,
|
||||
\`unread\` integer DEFAULT 0 NOT NULL,
|
||||
\`draft_text\` text DEFAULT '' NOT NULL,
|
||||
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
|
||||
\`draft_updated_at\` integer,
|
||||
\`created\` integer DEFAULT 1 NOT NULL,
|
||||
\`closed\` integer DEFAULT 0 NOT NULL,
|
||||
\`thinking_since_ms\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
} as const,
|
||||
};
|
||||
51
foundry/packages/backend/src/actors/task/db/schema.ts
Normal file
51
foundry/packages/backend/src/actors/task/db/schema.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per task actor instance, so these tables only ever store one row (id=1).
|
||||
export const task = sqliteTable("task", {
|
||||
id: integer("id").primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
title: text("title"),
|
||||
task: text("task").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
agentType: text("agent_type").default("claude"),
|
||||
prSubmitted: integer("pr_submitted").default(0),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskRuntime = sqliteTable("task_runtime", {
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskSandboxes = sqliteTable("task_sandboxes", {
|
||||
sandboxId: text("sandbox_id").notNull().primaryKey(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
sandboxActorId: text("sandbox_actor_id"),
|
||||
switchTarget: text("switch_target").notNull(),
|
||||
cwd: text("cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sessionName: text("session_name").notNull(),
|
||||
model: text("model").notNull(),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
|
||||
draftUpdatedAt: integer("draft_updated_at"),
|
||||
created: integer("created").notNull().default(1),
|
||||
closed: integer("closed").notNull().default(0),
|
||||
thinkingSinceMs: integer("thinking_since_ms"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
385
foundry/packages/backend/src/actors/task/index.ts
Normal file
385
foundry/packages/backend/src/actors/task/index.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
TaskRecord,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
ProviderId,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfTask } from "../handles.js";
|
||||
import { taskDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getWorkbenchTask,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft,
|
||||
} from "./workbench.js";
|
||||
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
|
||||
|
||||
export interface TaskInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
repoRemote: string;
|
||||
repoLocalPath: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
providerId: ProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
initialPrompt: string | null;
|
||||
}
|
||||
|
||||
interface InitializeCommand {
|
||||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
interface TaskActionCommand {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface TaskTabCommand {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface TaskStatusSyncCommand {
|
||||
sessionId: string;
|
||||
status: "running" | "idle" | "error";
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchValueCommand {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionTitleCommand {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionUnreadCommand {
|
||||
sessionId: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchUpdateDraftCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchChangeModelCommand {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSendMessageCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const task = actor({
|
||||
db: taskDb,
|
||||
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: TaskInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
taskId: input.taskId,
|
||||
repoRemote: input.repoRemote,
|
||||
repoLocalPath: input.repoLocalPath,
|
||||
branchName: input.branchName,
|
||||
title: input.title,
|
||||
task: input.task,
|
||||
providerId: input.providerId,
|
||||
agentType: input.agentType,
|
||||
explicitTitle: input.explicitTitle,
|
||||
explicitBranchName: input.explicitBranchName,
|
||||
initialPrompt: input.initialPrompt,
|
||||
initialized: false,
|
||||
previousStatus: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
return expectQueueResponse<TaskRecord>(result);
|
||||
},
|
||||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
||||
},
|
||||
|
||||
async switch(c): Promise<{ switchTarget: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.switch"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
async push(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000,
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000,
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000,
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
void self
|
||||
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
c.log.warn({
|
||||
msg: "archive command failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async get(c): Promise<TaskRecord> {
|
||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
},
|
||||
|
||||
async getWorkbench(c) {
|
||||
return await getWorkbenchTask(c);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.mark_unread"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.create_session"),
|
||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ tabId: string }>(result);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.rename_session"),
|
||||
{ sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
|
||||
{ sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.update_draft"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchUpdateDraftCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.change_model"),
|
||||
{ sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.tabId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.publish_pr"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
},
|
||||
run: workflow(runTaskWorkflow),
|
||||
});
|
||||
|
||||
export { TASK_QUEUE_NAMES };
|
||||
818
foundry/packages/backend/src/actors/task/workbench.ts
Normal file
818
foundry/packages/backend/src/actors/task/workbench.ts
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
// @ts-nocheck
|
||||
import { basename } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
|
||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
|
||||
await c.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS task_workbench_sessions (
|
||||
session_id text PRIMARY KEY NOT NULL,
|
||||
session_name text NOT NULL,
|
||||
model text NOT NULL,
|
||||
unread integer DEFAULT 0 NOT NULL,
|
||||
draft_text text DEFAULT '' NOT NULL,
|
||||
draft_attachments_json text DEFAULT '[]' NOT NULL,
|
||||
draft_updated_at integer,
|
||||
created integer DEFAULT 1 NOT NULL,
|
||||
closed integer DEFAULT 0 NOT NULL,
|
||||
thinking_since_ms integer,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function defaultModelForAgent(agentType: string | null | undefined) {
|
||||
return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4";
|
||||
}
|
||||
|
||||
function agentKindForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
return "Codex";
|
||||
}
|
||||
return "Claude";
|
||||
}
|
||||
|
||||
export function agentTypeForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
function repoLabelFromRemote(remoteUrl: string): string {
|
||||
const trimmed = remoteUrl.trim();
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return basename(trimmed.replace(/\.git$/, ""));
|
||||
}
|
||||
|
||||
function parseDraftAttachments(value: string | null | undefined): Array<any> {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
|
||||
if (status === "running") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only mark unread when we observe the transition out of an active thinking state.
|
||||
// Repeated idle polls for an already-finished session must not flip unread back on.
|
||||
return Boolean(meta.thinkingSinceMs);
|
||||
}
|
||||
|
||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
|
||||
const mapped = rows.map((row: any) => ({
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
created: row.created === 1,
|
||||
closed: row.closed === 1,
|
||||
}));
|
||||
|
||||
if (options?.includeClosed === true) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return mapped.filter((row: any) => row.closed !== true);
|
||||
}
|
||||
|
||||
async function nextSessionName(c: any): Promise<string> {
|
||||
const rows = await listSessionMetaRows(c, { includeClosed: true });
|
||||
return `Session ${rows.length + 1}`;
|
||||
}
|
||||
|
||||
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
sessionId: row.sessionId,
|
||||
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
|
||||
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
|
||||
unread: row.unread === 1,
|
||||
created: row.created === 1,
|
||||
closed: row.closed === 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSessionMeta(
|
||||
c: any,
|
||||
params: {
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
sessionName?: string;
|
||||
unread?: boolean;
|
||||
},
|
||||
): Promise<any> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const existing = await readSessionMeta(c, params.sessionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sessionName = params.sessionName ?? (await nextSessionName(c));
|
||||
const model = params.model ?? defaultModelForAgent(c.state.agentType);
|
||||
const unread = params.unread ?? false;
|
||||
|
||||
await c.db
|
||||
.insert(taskWorkbenchSessions)
|
||||
.values({
|
||||
sessionId: params.sessionId,
|
||||
sessionName,
|
||||
model,
|
||||
unread: unread ? 1 : 0,
|
||||
draftText: "",
|
||||
draftAttachmentsJson: "[]",
|
||||
draftUpdatedAt: null,
|
||||
created: 1,
|
||||
closed: 0,
|
||||
thinkingSinceMs: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
return await readSessionMeta(c, params.sessionId);
|
||||
}
|
||||
|
||||
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
await c.db
|
||||
.update(taskWorkbenchSessions)
|
||||
.set({
|
||||
...values,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
|
||||
.run();
|
||||
return await readSessionMeta(c, sessionId);
|
||||
}
|
||||
|
||||
async function notifyWorkbenchUpdated(c: any): Promise<void> {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
|
||||
function shellFragment(parts: string[]): string {
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
||||
async function executeInSandbox(
|
||||
c: any,
|
||||
params: {
|
||||
sandboxId: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
label: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; result: string }> {
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(c.state.providerId);
|
||||
return await provider.executeCommand({
|
||||
workspaceId: c.state.workspaceId,
|
||||
sandboxId: params.sandboxId,
|
||||
command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`,
|
||||
label: params.label,
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const status = line.slice(0, 2).trim();
|
||||
const rawPath = line.slice(3).trim();
|
||||
const path = rawPath.includes(" -> ") ? (rawPath.split(" -> ").pop() ?? rawPath) : rawPath;
|
||||
const type = status.includes("D") ? "D" : status.includes("A") || status === "??" ? "A" : "M";
|
||||
return { path, type };
|
||||
});
|
||||
}
|
||||
|
||||
function parseNumstat(output: string): Map<string, { added: number; removed: number }> {
|
||||
const map = new Map<string, { added: number; removed: number }>();
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [addedRaw, removedRaw, ...pathParts] = trimmed.split("\t");
|
||||
const path = pathParts.join("\t").trim();
|
||||
if (!path) continue;
|
||||
map.set(path, {
|
||||
added: Number.parseInt(addedRaw ?? "0", 10) || 0,
|
||||
removed: Number.parseInt(removedRaw ?? "0", 10) || 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildFileTree(paths: string[]): Array<any> {
|
||||
const root = {
|
||||
children: new Map<string, any>(),
|
||||
};
|
||||
|
||||
for (const path of paths) {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index]!;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const isDir = index < parts.length - 1;
|
||||
let node = current.children.get(part);
|
||||
if (!node) {
|
||||
node = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isDir,
|
||||
children: isDir ? new Map<string, any>() : undefined,
|
||||
};
|
||||
current.children.set(part, node);
|
||||
} else if (isDir && !(node.children instanceof Map)) {
|
||||
node.children = new Map<string, any>();
|
||||
}
|
||||
current = node;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: Iterable<any>): Array<any> {
|
||||
return [...nodes]
|
||||
.map((node) =>
|
||||
node.isDir
|
||||
? {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
isDir: true,
|
||||
children: sortNodes(node.children?.values?.() ?? []),
|
||||
}
|
||||
: {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
isDir: false,
|
||||
},
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.isDir !== right.isDir) {
|
||||
return left.isDir ? -1 : 1;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
|
||||
return sortNodes(root.children.values());
|
||||
}
|
||||
|
||||
async function collectWorkbenchGitState(c: any, record: any) {
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!activeSandboxId || !cwd) {
|
||||
return {
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
};
|
||||
}
|
||||
|
||||
const statusResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git status --porcelain=v1 -uall",
|
||||
label: "git status",
|
||||
});
|
||||
if (statusResult.exitCode !== 0) {
|
||||
return {
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
};
|
||||
}
|
||||
|
||||
const statusRows = parseGitStatus(statusResult.result);
|
||||
const numstatResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git diff --numstat",
|
||||
label: "git diff numstat",
|
||||
});
|
||||
const numstat = parseNumstat(numstatResult.result);
|
||||
const diffs: Record<string, string> = {};
|
||||
|
||||
for (const row of statusRows) {
|
||||
const diffResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`,
|
||||
label: `git diff ${row.path}`,
|
||||
});
|
||||
diffs[row.path] = diffResult.result;
|
||||
}
|
||||
|
||||
const filesResult = await executeInSandbox(c, {
|
||||
sandboxId: activeSandboxId,
|
||||
cwd,
|
||||
command: "git ls-files --cached --others --exclude-standard",
|
||||
label: "git ls-files",
|
||||
});
|
||||
const allPaths = filesResult.result
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
fileChanges: statusRows.map((row) => {
|
||||
const counts = numstat.get(row.path) ?? { added: 0, removed: 0 };
|
||||
return {
|
||||
path: row.path,
|
||||
added: counts.added,
|
||||
removed: counts.removed,
|
||||
type: row.type,
|
||||
};
|
||||
}),
|
||||
diffs,
|
||||
fileTree: buildFileTree(allPaths),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
||||
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
|
||||
if (!sandboxId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
|
||||
const page = await sandbox.listSessionEvents({
|
||||
sessionId,
|
||||
limit: 500,
|
||||
});
|
||||
return page.items.map((event: any) => ({
|
||||
id: event.id,
|
||||
eventIndex: event.eventIndex,
|
||||
sessionId: event.sessionId,
|
||||
createdAt: event.createdAt,
|
||||
connectionId: event.connectionId,
|
||||
sender: event.sender,
|
||||
payload: event.payload,
|
||||
}));
|
||||
}
|
||||
|
||||
async function activeSessionStatus(c: any, record: any, sessionId: string) {
|
||||
if (record.activeSessionId !== sessionId || !record.activeSandboxId) {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const status = await sandbox.sessionStatus({ sessionId });
|
||||
return status.status;
|
||||
}
|
||||
|
||||
async function readPullRequestSummary(c: any, branchName: string | null) {
|
||||
if (!branchName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
return await project.getPullRequestForBranch({ branchName });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
||||
const record = await getCurrentRecord({ db: c.db, state: c.state });
|
||||
if (record.activeSessionId) {
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: record.activeSessionId,
|
||||
model: defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function getWorkbenchTask(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
const tabs = [];
|
||||
|
||||
for (const meta of sessions) {
|
||||
const status = await activeSessionStatus(c, record, meta.sessionId);
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && status !== "running") {
|
||||
thinkingSinceMs = null;
|
||||
unread = true;
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id: meta.id,
|
||||
sessionId: meta.sessionId,
|
||||
sessionName: meta.sessionName,
|
||||
agent: agentKindForModel(meta.model),
|
||||
model: meta.model,
|
||||
status,
|
||||
thinkingSinceMs: status === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created),
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
updatedAtMs: meta.draftUpdatedAtMs ?? null,
|
||||
},
|
||||
transcript: await readSessionTranscript(c, record, meta.sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: c.state.taskId,
|
||||
repoId: c.state.repoId,
|
||||
title: record.title ?? "New Task",
|
||||
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
|
||||
repoName: repoLabelFromRemote(c.state.repoRemote),
|
||||
updatedAtMs: record.updatedAt,
|
||||
branch: record.branchName,
|
||||
pullRequest: await readPullRequestSummary(c, record.branchName),
|
||||
tabs,
|
||||
fileChanges: gitState.fileChanges,
|
||||
diffs: gitState.diffs,
|
||||
fileTree: gitState.fileTree,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
|
||||
const nextTitle = value.trim();
|
||||
if (!nextTitle) {
|
||||
throw new Error("task title is required");
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
title: nextTitle,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
c.state.title = nextTitle;
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
|
||||
const nextBranch = value.trim();
|
||||
if (!nextBranch) {
|
||||
throw new Error("branch name is required");
|
||||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot rename branch before task branch exists");
|
||||
}
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot rename branch without an active sandbox");
|
||||
}
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot rename branch without a sandbox cwd");
|
||||
}
|
||||
|
||||
const renameResult = await executeInSandbox(c, {
|
||||
sandboxId: record.activeSandboxId,
|
||||
cwd: activeSandbox.cwd,
|
||||
command: [
|
||||
`git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`,
|
||||
`if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`,
|
||||
`git push origin ${JSON.stringify(nextBranch)}`,
|
||||
`git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`,
|
||||
].join(" && "),
|
||||
label: `git branch -m ${record.branchName} ${nextBranch}`,
|
||||
});
|
||||
if (renameResult.exitCode !== 0) {
|
||||
throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`);
|
||||
}
|
||||
|
||||
await c.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
branchName: nextBranch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
c.state.branchName = nextBranch;
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
await project.registerTaskBranch({
|
||||
taskId: c.state.taskId,
|
||||
branchName: nextBranch,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot create session without an active sandbox");
|
||||
}
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot create session without a sandbox cwd");
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const created = await sandbox.createSession({
|
||||
prompt: "",
|
||||
cwd,
|
||||
agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)),
|
||||
});
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox-agent session creation failed");
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, {
|
||||
sessionId: created.id,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
return { tabId: created.id };
|
||||
}
|
||||
|
||||
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("session title is required");
|
||||
}
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
sessionName: trimmed,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: unread ? 1 : 0,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
draftText: text,
|
||||
draftAttachmentsJson: JSON.stringify(attachments),
|
||||
draftUpdatedAt: Date.now(),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
model,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot send message without an active sandbox");
|
||||
}
|
||||
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!prompt) {
|
||||
throw new Error("message text is required");
|
||||
}
|
||||
|
||||
await sandbox.sendPrompt({
|
||||
sessionId,
|
||||
prompt,
|
||||
notification: true,
|
||||
});
|
||||
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: 0,
|
||||
created: 1,
|
||||
draftText: "",
|
||||
draftAttachmentsJson: "[]",
|
||||
draftUpdatedAt: Date.now(),
|
||||
thinkingSinceMs: Date.now(),
|
||||
});
|
||||
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSessionId: sessionId,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, sessionId, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: c.state.taskId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
});
|
||||
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.cancelSession({ sessionId });
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = await ensureSessionMeta(c, { sessionId });
|
||||
let changed = false;
|
||||
|
||||
if (record.activeSessionId === sessionId) {
|
||||
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
|
||||
if (record.status !== mappedStatus) {
|
||||
await c.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
status: mappedStatus,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const statusMessage = `session:${status}`;
|
||||
if (record.statusMessage !== statusMessage) {
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage,
|
||||
updatedAt: at,
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "running") {
|
||||
if (!meta.thinkingSinceMs) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: at,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (meta.thinkingSinceMs) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (!meta.unread && shouldMarkSessionUnreadForStatus(meta, status)) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: 1,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.destroySession({ sessionId });
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
closed: 1,
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
if (record.activeSessionId === sessionId) {
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSessionId: null,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function markWorkbenchUnread(c: any): Promise<void> {
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
const latest = sessions[sessions.length - 1];
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
await updateSessionMeta(c, latest.sessionId, {
|
||||
unread: 1,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function publishWorkbenchPr(c: any): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot publish PR without a branch");
|
||||
}
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task);
|
||||
await c.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
prSubmitted: 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot revert file without an active sandbox");
|
||||
}
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot revert file without a sandbox cwd");
|
||||
}
|
||||
|
||||
const result = await executeInSandbox(c, {
|
||||
sandboxId: record.activeSandboxId,
|
||||
cwd: activeSandbox.cwd,
|
||||
command: `if git ls-files --error-unmatch -- ${JSON.stringify(path)} >/dev/null 2>&1; then git restore --staged --worktree -- ${JSON.stringify(path)} || git checkout -- ${JSON.stringify(path)}; else rm -f ${JSON.stringify(path)}; fi`,
|
||||
label: `git restore ${path}`,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
175
foundry/packages/backend/src/actors/task/workflow/commands.ts
Normal file
175
foundry/packages/backend/src/actors/task/workflow/commands.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateTaskStatusSync } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.activeSandboxId ? (record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null) : null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const target = await provider.attachTarget({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId ?? "",
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "task.attach", {
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
|
||||
await msg.complete({
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db.select({ switchTarget: taskRuntime.activeSwitchTarget }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get();
|
||||
|
||||
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
|
||||
}
|
||||
|
||||
export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: msg.body?.reason ?? null,
|
||||
historyKind: "task.push",
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
await db.update(taskRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
|
||||
if (record.activeSandboxId && record.activeSessionId) {
|
||||
try {
|
||||
const sync = await getOrCreateTaskStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.taskId,
|
||||
record.activeSandboxId,
|
||||
record.activeSessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId: record.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
intervalMs: 2_000,
|
||||
},
|
||||
);
|
||||
await withTimeout(sync.stop(), 15_000, "task status sync stop");
|
||||
} catch (error) {
|
||||
logActorWarning("task.commands", "failed to stop status sync during archive", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const workspaceId = loopCtx.state.workspaceId;
|
||||
const repoId = loopCtx.state.repoId;
|
||||
const taskId = loopCtx.state.taskId;
|
||||
const sandboxId = record.activeSandboxId;
|
||||
|
||||
// Do not block archive finalization on provider stop. Some provider stop calls can
|
||||
// run longer than the synchronous archive UX budget.
|
||||
void withTimeout(
|
||||
provider.releaseSandbox({
|
||||
workspaceId,
|
||||
sandboxId,
|
||||
}),
|
||||
45_000,
|
||||
"provider releaseSandbox",
|
||||
).catch((error) => {
|
||||
logActorWarning("task.commands", "failed to release sandbox during archive", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await db.update(taskTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db.update(taskRuntime).set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
await provider.destroySandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
const db = loopCtx.db;
|
||||
await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleGetActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await msg.complete(await getCurrentRecord(loopCtx));
|
||||
}
|
||||
181
foundry/packages/backend/src/actors/task/workflow/common.ts
Normal file
181
foundry/packages/backend/src/actors/task/workflow/common.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { getOrCreateWorkspace } from "../../handles.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { historyKey } from "../../keys.js";
|
||||
|
||||
export const TASK_ROW_ID = 1;
|
||||
|
||||
export function collectErrorMessages(error: unknown): string[] {
|
||||
if (error == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
|
||||
while (current != null && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
|
||||
if (current instanceof Error) {
|
||||
const message = current.message?.trim();
|
||||
if (message) {
|
||||
out.push(message);
|
||||
}
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof current === "string") {
|
||||
const message = current.trim();
|
||||
if (message) {
|
||||
out.push(message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return out.filter((msg, index) => out.indexOf(msg) === index);
|
||||
}
|
||||
|
||||
export function resolveErrorDetail(error: unknown): string {
|
||||
const messages = collectErrorMessages(error);
|
||||
if (messages.length === 0) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
const nonWorkflowWrapper = messages.find((msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg));
|
||||
return nonWorkflowWrapper ?? messages[0]!;
|
||||
}
|
||||
|
||||
export function buildAgentPrompt(task: string): string {
|
||||
return task.trim();
|
||||
}
|
||||
|
||||
export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
const db = ctx.db;
|
||||
await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
if (statusMessage != null) {
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
|
||||
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
||||
const db = ctx.db;
|
||||
const row = await db
|
||||
.select({
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title,
|
||||
task: taskTable.task,
|
||||
providerId: taskTable.providerId,
|
||||
status: taskTable.status,
|
||||
statusMessage: taskRuntime.statusMessage,
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId,
|
||||
agentType: taskTable.agentType,
|
||||
prSubmitted: taskTable.prSubmitted,
|
||||
createdAt: taskTable.createdAt,
|
||||
updatedAt: taskTable.updatedAt,
|
||||
})
|
||||
.from(taskTable)
|
||||
.leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id))
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Task not found: ${ctx.state.taskId}`);
|
||||
}
|
||||
|
||||
const sandboxes = await db
|
||||
.select({
|
||||
sandboxId: taskSandboxes.sandboxId,
|
||||
providerId: taskSandboxes.providerId,
|
||||
sandboxActorId: taskSandboxes.sandboxActorId,
|
||||
switchTarget: taskSandboxes.switchTarget,
|
||||
cwd: taskSandboxes.cwd,
|
||||
createdAt: taskSandboxes.createdAt,
|
||||
updatedAt: taskSandboxes.updatedAt,
|
||||
})
|
||||
.from(taskSandboxes)
|
||||
.all();
|
||||
|
||||
return {
|
||||
workspaceId: ctx.state.workspaceId,
|
||||
repoId: ctx.state.repoId,
|
||||
repoRemote: ctx.state.repoRemote,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: row.branchName,
|
||||
title: row.title,
|
||||
task: row.task,
|
||||
providerId: row.providerId,
|
||||
status: row.status,
|
||||
statusMessage: row.statusMessage ?? null,
|
||||
activeSandboxId: row.activeSandboxId ?? null,
|
||||
activeSessionId: row.activeSessionId ?? null,
|
||||
sandboxes: sandboxes.map((sb) => ({
|
||||
sandboxId: sb.sandboxId,
|
||||
providerId: sb.providerId,
|
||||
sandboxActorId: sb.sandboxActorId ?? null,
|
||||
switchTarget: sb.switchTarget,
|
||||
cwd: sb.cwd ?? null,
|
||||
createdAt: sb.createdAt,
|
||||
updatedAt: sb.updatedAt,
|
||||
})),
|
||||
agentType: row.agentType ?? null,
|
||||
prSubmitted: Boolean(row.prSubmitted),
|
||||
diffStat: null,
|
||||
hasUnpushed: null,
|
||||
conflictsWithMain: null,
|
||||
parentBranch: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
} as TaskRecord;
|
||||
}
|
||||
|
||||
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const client = ctx.client();
|
||||
const history = await client.history.getOrCreate(historyKey(ctx.state.workspaceId, ctx.state.repoId), {
|
||||
createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId },
|
||||
});
|
||||
await history.append({
|
||||
kind,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: ctx.state.branchName,
|
||||
payload,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
270
foundry/packages/backend/src/actors/task/workflow/index.ts
Normal file
270
foundry/packages/backend/src/actors/task/workflow/index.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { getCurrentRecord } from "./common.js";
|
||||
import {
|
||||
initAssertNameActivity,
|
||||
initBootstrapDbActivity,
|
||||
initCompleteActivity,
|
||||
initCreateSandboxActivity,
|
||||
initCreateSessionActivity,
|
||||
initEnsureAgentActivity,
|
||||
initEnsureNameActivity,
|
||||
initExposeSandboxActivity,
|
||||
initFailedActivity,
|
||||
initStartSandboxInstanceActivity,
|
||||
initStartStatusSyncActivity,
|
||||
initWriteDbActivity,
|
||||
} from "./init.js";
|
||||
import {
|
||||
handleArchiveActivity,
|
||||
handleAttachActivity,
|
||||
handleGetActivity,
|
||||
handlePushActivity,
|
||||
handleSimpleCommandActivity,
|
||||
handleSwitchActivity,
|
||||
killDestroySandboxActivity,
|
||||
killWriteDbActivity,
|
||||
} from "./commands.js";
|
||||
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
|
||||
import { TASK_QUEUE_NAMES } from "./queue.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
syncWorkbenchSessionStatus,
|
||||
updateWorkbenchDraft,
|
||||
} from "../workbench.js";
|
||||
|
||||
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number];
|
||||
|
||||
type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
|
||||
|
||||
const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||
"task.command.initialize": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
|
||||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-enqueue-provision", "step");
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
|
||||
|
||||
try {
|
||||
await msg.complete(currentRecord);
|
||||
} catch (error) {
|
||||
logActorWarning("task.workflow", "initialize completion failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.provision": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
await loopCtx.removed("init-failed", "step");
|
||||
try {
|
||||
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
|
||||
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
|
||||
|
||||
const sandbox = await loopCtx.step({
|
||||
name: "init-create-sandbox",
|
||||
timeout: 180_000,
|
||||
run: async () => initCreateSandboxActivity(loopCtx, body),
|
||||
});
|
||||
const agent = await loopCtx.step({
|
||||
name: "init-ensure-agent",
|
||||
timeout: 180_000,
|
||||
run: async () => initEnsureAgentActivity(loopCtx, body, sandbox),
|
||||
});
|
||||
const sandboxInstanceReady = await loopCtx.step({
|
||||
name: "init-start-sandbox-instance",
|
||||
timeout: 60_000,
|
||||
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
|
||||
});
|
||||
await loopCtx.step("init-expose-sandbox", async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady));
|
||||
const session = await loopCtx.step({
|
||||
name: "init-create-session",
|
||||
timeout: 180_000,
|
||||
run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
|
||||
});
|
||||
|
||||
await loopCtx.step("init-write-db", async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady));
|
||||
await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
|
||||
await msg.complete({ ok: false });
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.attach": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.switch": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.push": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.sync": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync"));
|
||||
},
|
||||
|
||||
"task.command.merge": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge"));
|
||||
},
|
||||
|
||||
"task.command.archive": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.kill": async (loopCtx, msg) => {
|
||||
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
|
||||
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.get": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_task": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_branch": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-rename-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => renameWorkbenchBranch(loopCtx, msg.body.value),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await msg.complete(created);
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.set_session_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-set-session-unread", async () => setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.update_draft": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-update-draft", async () => updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.change_model": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-stop-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.sync_session_status": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-close-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.publish_pr": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-publish-pr",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => publishWorkbenchPr(loopCtx),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.revert_file": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-revert-file",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => revertWorkbenchFile(loopCtx, msg.body.path),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.status_sync.result": async (loopCtx, msg) => {
|
||||
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
|
||||
|
||||
if (transitionedToIdle) {
|
||||
const { config } = getActorRuntimeContext();
|
||||
if (config.auto_submit) {
|
||||
await loopCtx.step("idle-submit-pr", async () => idleSubmitPrActivity(loopCtx));
|
||||
}
|
||||
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export async function runTaskWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("task-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-command", {
|
||||
names: [...TASK_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
const handler = commandHandlers[msg.name as TaskQueueName];
|
||||
if (handler) {
|
||||
await handler(loopCtx, msg);
|
||||
}
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
607
foundry/packages/backend/src/actors/task/workflow/init.ts
Normal file
607
foundry/packages/backend/src/actors/task/workflow/init.ts
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
import { taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
||||
function getInitCreateSandboxActivityTimeoutMs(): number {
|
||||
const raw = process.env.HF_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
|
||||
loopCtx.log.debug({
|
||||
msg: message,
|
||||
scope: "task.init",
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
...(context ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
run(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
const detail = resolveErrorMessage(error);
|
||||
throw new Error(`task init bootstrap db failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
const self = selfTask(loopCtx);
|
||||
void self
|
||||
.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_ensure_name", "determining title and branch");
|
||||
const existing = await loopCtx.db
|
||||
.select({
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title,
|
||||
})
|
||||
.from(taskTable)
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (existing?.branchName && existing?.title) {
|
||||
loopCtx.state.branchName = existing.branchName;
|
||||
loopCtx.state.title = existing.title;
|
||||
return;
|
||||
}
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "fetch before naming failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName);
|
||||
|
||||
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: loopCtx.state.task,
|
||||
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
|
||||
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
|
||||
localBranches: remoteBranches,
|
||||
taskBranches: reservedBranches,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
branchName: resolved.branchName,
|
||||
title: resolved.title,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
loopCtx.state.branchName = resolved.branchName;
|
||||
loopCtx.state.title = resolved.title;
|
||||
loopCtx.state.explicitTitle = null;
|
||||
loopCtx.state.explicitBranchName = null;
|
||||
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: "provisioning",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await project.registerTaskBranch({
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: resolved.branchName,
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "task.named", {
|
||||
title: resolved.title,
|
||||
branchName: resolved.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_assert_name", "validating naming");
|
||||
if (!loopCtx.state.branchName) {
|
||||
throw new Error("task branchName is not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
const timeoutMs = getInitCreateSandboxActivityTimeoutMs();
|
||||
const startedAt = Date.now();
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox started", {
|
||||
providerId,
|
||||
timeoutMs,
|
||||
supportsSessionReuse: provider.capabilities().supportsSessionReuse,
|
||||
});
|
||||
|
||||
if (provider.capabilities().supportsSessionReuse) {
|
||||
const runtime = await loopCtx.db.select({ activeSandboxId: taskRuntime.activeSandboxId }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get();
|
||||
|
||||
const existing = await loopCtx.db
|
||||
.select({ sandboxId: taskSandboxes.sandboxId })
|
||||
.from(taskSandboxes)
|
||||
.where(eq(taskSandboxes.providerId, providerId))
|
||||
.orderBy(desc(taskSandboxes.updatedAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const sandboxId = runtime?.activeSandboxId ?? existing?.sandboxId ?? null;
|
||||
if (sandboxId) {
|
||||
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
|
||||
try {
|
||||
const resumed = await withActivityTimeout(timeoutMs, "resumeSandbox", async () =>
|
||||
provider.resumeSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId,
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox resume succeeded", {
|
||||
sandboxId: resumed.sandboxId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return resumed;
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "resume sandbox failed; creating a new sandbox", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
|
||||
branchName: loopCtx.state.branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
|
||||
provider.createSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
taskId: loopCtx.state.taskId,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context),
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox create succeeded", {
|
||||
sandboxId: sandbox.sandboxId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return sandbox;
|
||||
} catch (error) {
|
||||
debugInit(loopCtx, "init_create_sandbox failed", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
return await provider.ensureSandboxAgent({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
|
||||
await sandboxInstance.ensure({
|
||||
metadata: sandbox.metadata,
|
||||
status: "ready",
|
||||
agentEndpoint: agent.endpoint,
|
||||
agentToken: agent.token,
|
||||
});
|
||||
|
||||
const actorId = typeof (sandboxInstance as any).resolve === "function" ? await (sandboxInstance as any).resolve() : null;
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
actorId: typeof actorId === "string" ? actorId : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `sandbox-instance ensure failed: ${detail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_session", "creating agent session");
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: sandboxInstanceReady.error ?? "sandbox instance is not ready",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
|
||||
|
||||
const cwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : undefined;
|
||||
|
||||
return await sandboxInstance.createSession({
|
||||
prompt: typeof loopCtx.state.initialPrompt === "string" ? loopCtx.state.initialPrompt : buildAgentPrompt(loopCtx.state.task),
|
||||
cwd,
|
||||
agent: (loopCtx.state.agentType ?? config.default_agent) as any,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initExposeSandboxActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady?: { actorId?: string | null }): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const activeCwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : null;
|
||||
const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null;
|
||||
|
||||
await db
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initWriteDbActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
session: any,
|
||||
sandboxInstanceReady?: { actorId?: string | null },
|
||||
): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_write_db", "persisting task runtime");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
const activeSessionId = sessionHealthy ? sessionId : null;
|
||||
const statusMessage = sessionHealthy ? "session created" : session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
|
||||
const activeCwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : null;
|
||||
const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null;
|
||||
|
||||
await db
|
||||
.update(taskTable)
|
||||
.set({
|
||||
providerId,
|
||||
status: sessionHealthy ? "running" : "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initStartStatusSyncActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const sessionId = session?.id ?? null;
|
||||
if (!sessionId || session?.status === "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
await setTaskState(loopCtx, "init_start_status_sync", "starting session status sync");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sync = await getOrCreateTaskStatusSync(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.taskId, sandbox.sandboxId, sessionId, {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sessionId,
|
||||
intervalMs: 2_000,
|
||||
});
|
||||
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
}
|
||||
|
||||
export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
if (sessionHealthy) {
|
||||
await setTaskState(loopCtx, "init_complete", "task initialized");
|
||||
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "task.initialized",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId },
|
||||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
await setTaskState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages: [detail],
|
||||
});
|
||||
loopCtx.state.initialized = false;
|
||||
}
|
||||
|
||||
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
|
||||
const now = Date.now();
|
||||
const detail = resolveErrorDetail(error);
|
||||
const messages = collectErrorMessages(error);
|
||||
const db = loopCtx.db;
|
||||
const { config, providers } = getActorRuntimeContext();
|
||||
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
|
||||
|
||||
await db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
84
foundry/packages/backend/src/actors/task/workflow/push.ts
Normal file
84
foundry/packages/backend/src/actors/task/workflow/push.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
|
||||
|
||||
export interface PushActiveBranchOptions {
|
||||
reason?: string | null;
|
||||
historyKind?: string;
|
||||
}
|
||||
|
||||
export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const branchName = loopCtx.state.branchName ?? record.branchName;
|
||||
|
||||
if (!activeSandboxId) {
|
||||
throw new Error("cannot push: no active sandbox");
|
||||
}
|
||||
if (!branchName) {
|
||||
throw new Error("cannot push: task branch is not set");
|
||||
}
|
||||
|
||||
const activeSandbox = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
|
||||
const providerId = activeSandbox?.providerId ?? record.providerId;
|
||||
const cwd = activeSandbox?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot push: active sandbox cwd is not set");
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(providerId);
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`cd ${JSON.stringify(cwd)}`,
|
||||
"git rev-parse --verify HEAD >/dev/null",
|
||||
"git config credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
|
||||
`git push -u origin ${JSON.stringify(branchName)}`,
|
||||
].join("; ");
|
||||
|
||||
const result = await provider.executeCommand({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: activeSandboxId,
|
||||
command: ["bash", "-lc", JSON.stringify(script)].join(" "),
|
||||
label: `git push ${branchName}`,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`git push failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
|
||||
reason: options.reason ?? null,
|
||||
branchName,
|
||||
sandboxId: activeSandboxId,
|
||||
});
|
||||
}
|
||||
31
foundry/packages/backend/src/actors/task/workflow/queue.ts
Normal file
31
foundry/packages/backend/src/actors/task/workflow/queue.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export const TASK_QUEUE_NAMES = [
|
||||
"task.command.initialize",
|
||||
"task.command.provision",
|
||||
"task.command.attach",
|
||||
"task.command.switch",
|
||||
"task.command.push",
|
||||
"task.command.sync",
|
||||
"task.command.merge",
|
||||
"task.command.archive",
|
||||
"task.command.kill",
|
||||
"task.command.get",
|
||||
"task.command.workbench.mark_unread",
|
||||
"task.command.workbench.rename_task",
|
||||
"task.command.workbench.rename_branch",
|
||||
"task.command.workbench.create_session",
|
||||
"task.command.workbench.rename_session",
|
||||
"task.command.workbench.set_session_unread",
|
||||
"task.command.workbench.update_draft",
|
||||
"task.command.workbench.change_model",
|
||||
"task.command.workbench.send_message",
|
||||
"task.command.workbench.stop_session",
|
||||
"task.command.workbench.sync_session_status",
|
||||
"task.command.workbench.close_session",
|
||||
"task.command.workbench.publish_pr",
|
||||
"task.command.workbench.revert_file",
|
||||
"task.status_sync.result",
|
||||
] as const;
|
||||
|
||||
export function taskWorkflowQueueName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
143
foundry/packages/backend/src/actors/task/workflow/status-sync.ts
Normal file
143
foundry/packages/backend/src/actors/task/workflow/status-sync.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
function mapSessionStatus(status: "running" | "idle" | "error") {
|
||||
if (status === "idle") return "idle";
|
||||
if (status === "error") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boolean> {
|
||||
const newStatus = mapSessionStatus(body.status);
|
||||
const wasIdle = loopCtx.state.previousStatus === "idle";
|
||||
const didTransition = newStatus === "idle" && !wasIdle;
|
||||
const isDuplicateStatus = loopCtx.state.previousStatus === newStatus;
|
||||
|
||||
if (isDuplicateStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId,
|
||||
})
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.get();
|
||||
|
||||
const isActive = runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
|
||||
|
||||
if (isActive) {
|
||||
await db.update(taskTable).set({ status: newStatus, updatedAt: body.at }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(taskSandboxes.sandboxId, body.sandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.status", {
|
||||
status: body.status,
|
||||
sessionId: body.sessionId,
|
||||
sandboxId: body.sandboxId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
loopCtx.state.previousStatus = newStatus;
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
if (loopCtx.state.branchName) {
|
||||
driver.tmux.setWindowStatus(loopCtx.state.branchName, newStatus);
|
||||
}
|
||||
return didTransition;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const db = loopCtx.db;
|
||||
|
||||
const self = await db.select({ prSubmitted: taskTable.prSubmitted }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
|
||||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath);
|
||||
} catch (error) {
|
||||
logActorWarning("task.status-sync", "fetch before PR submit failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!loopCtx.state.branchName || !loopCtx.state.title) {
|
||||
throw new Error("cannot submit PR before task has a branch and title");
|
||||
}
|
||||
|
||||
try {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: "auto_submit_idle",
|
||||
historyKind: "task.push.auto",
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
|
||||
|
||||
await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.step", {
|
||||
step: "pr_submit",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number,
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "task.pr_created", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = resolveErrorDetail(error);
|
||||
await db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: `pr submit failed: ${detail}`,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.pr_create_failed", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
error: detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function idleNotifyActivity(loopCtx: any): Promise<void> {
|
||||
const { notifications } = getActorRuntimeContext();
|
||||
if (notifications && loopCtx.state.branchName) {
|
||||
await notifications.agentIdle(loopCtx.state.branchName);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue