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:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View 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 });

View file

@ -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",
});

View file

@ -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
);

View file

@ -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`;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1 @@
ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text;

View file

@ -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
);

View file

@ -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": {}
}
}

View file

@ -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": {}
}
}

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View 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,
};

View 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(),
});

View 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 };

View 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);
}

View 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));
}

View 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({});
}

View 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);
});
}

View 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,
});
}

View 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,
});
}

View 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;
}

View 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);
}
}