Integrate OpenHandoff factory workspace (#212)

This commit is contained in:
Nathan Flurry 2026-03-09 14:00:20 -07:00 committed by GitHub
parent 3d9476ed0b
commit bf282199b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
251 changed files with 42824 additions and 692 deletions

View file

@ -0,0 +1,19 @@
{
"name": "@openhandoff/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.1.5"
},
"devDependencies": {
"tsup": "^8.5.0"
}
}

View file

@ -0,0 +1,54 @@
import { z } from "zod";
export const AgentEnumSchema = z.enum(["claude", "codex"]);
export const NotifyBackendSchema = z.enum([
"openclaw",
"macos-osascript",
"linux-notify-send",
"terminal"
]);
export const ConfigSchema = z.object({
theme: z.string().min(1).optional(),
auto_submit: z.boolean().default(false),
default_agent: AgentEnumSchema.default("codex"),
model: z.object({
provider: z.string(),
model: z.string()
}).optional(),
notify: z.array(NotifyBackendSchema).default(["terminal"]),
workspace: z.object({
default: z.string().min(1).default("default")
}).default({ default: "default" }),
backend: z.object({
host: z.string().default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(7741),
dbPath: z.string().default("~/.local/share/openhandoff/handoff.db"),
opencode_poll_interval: z.number().default(2),
github_poll_interval: z.number().default(30),
backup_interval_secs: z.number().default(3600),
backup_retention_days: z.number().default(7)
}).default({
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
backup_retention_days: 7
}),
providers: z.object({
local: z.object({
rootDir: z.string().optional(),
sandboxAgentPort: z.number().int().min(1).max(65535).optional(),
}).default({}),
daytona: z.object({
endpoint: z.string().optional(),
apiKey: z.string().optional(),
image: z.string().default("ubuntu:24.04")
}).default({ image: "ubuntu:24.04" })
}).default({ local: {}, daytona: { image: "ubuntu:24.04" } })
});
export type AppConfig = z.infer<typeof ConfigSchema>;

View file

@ -0,0 +1,252 @@
import { z } from "zod";
export const WorkspaceIdSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9._-]+$/);
export type WorkspaceId = z.infer<typeof WorkspaceIdSchema>;
export const ProviderIdSchema = z.enum(["daytona", "local"]);
export type ProviderId = z.infer<typeof ProviderIdSchema>;
export const AgentTypeSchema = z.enum(["claude", "codex"]);
export type AgentType = z.infer<typeof AgentTypeSchema>;
export const RepoIdSchema = z.string().min(1).max(128);
export type RepoId = z.infer<typeof RepoIdSchema>;
export const RepoRemoteSchema = z.string().min(1).max(2048);
export type RepoRemote = z.infer<typeof RepoRemoteSchema>;
export const HandoffStatusSchema = z.enum([
"init_bootstrap_db",
"init_enqueue_provision",
"init_ensure_name",
"init_assert_name",
"init_create_sandbox",
"init_ensure_agent",
"init_start_sandbox_instance",
"init_create_session",
"init_write_db",
"init_start_status_sync",
"init_complete",
"running",
"idle",
"archive_stop_status_sync",
"archive_release_sandbox",
"archive_finalize",
"archived",
"kill_destroy_sandbox",
"kill_finalize",
"killed",
"error"
]);
export type HandoffStatus = z.infer<typeof HandoffStatusSchema>;
export const RepoRecordSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: RepoIdSchema,
remoteUrl: RepoRemoteSchema,
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
export type RepoRecord = z.infer<typeof RepoRecordSchema>;
export const AddRepoInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
remoteUrl: RepoRemoteSchema,
});
export type AddRepoInput = z.infer<typeof AddRepoInputSchema>;
export const CreateHandoffInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: RepoIdSchema,
task: z.string().min(1),
explicitTitle: z.string().trim().min(1).optional(),
explicitBranchName: z.string().trim().min(1).optional(),
providerId: ProviderIdSchema.optional(),
agentType: AgentTypeSchema.optional(),
onBranch: z.string().trim().min(1).optional()
});
export type CreateHandoffInput = z.infer<typeof CreateHandoffInputSchema>;
export const HandoffRecordSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: z.string().min(1),
repoRemote: RepoRemoteSchema,
handoffId: z.string().min(1),
branchName: z.string().min(1).nullable(),
title: z.string().min(1).nullable(),
task: z.string().min(1),
providerId: ProviderIdSchema,
status: HandoffStatusSchema,
statusMessage: z.string().nullable(),
activeSandboxId: z.string().nullable(),
activeSessionId: z.string().nullable(),
sandboxes: z.array(
z.object({
sandboxId: z.string().min(1),
providerId: ProviderIdSchema,
sandboxActorId: z.string().nullable(),
switchTarget: z.string().min(1),
cwd: z.string().nullable(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
})
),
agentType: z.string().nullable(),
prSubmitted: z.boolean(),
diffStat: z.string().nullable(),
prUrl: z.string().nullable(),
prAuthor: z.string().nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
conflictsWithMain: z.string().nullable(),
hasUnpushed: z.string().nullable(),
parentBranch: z.string().nullable(),
createdAt: z.number().int(),
updatedAt: z.number().int()
});
export type HandoffRecord = z.infer<typeof HandoffRecordSchema>;
export const HandoffSummarySchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: z.string().min(1),
handoffId: z.string().min(1),
branchName: z.string().min(1).nullable(),
title: z.string().min(1).nullable(),
status: HandoffStatusSchema,
updatedAt: z.number().int()
});
export type HandoffSummary = z.infer<typeof HandoffSummarySchema>;
export const HandoffActionInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
handoffId: z.string().min(1)
});
export type HandoffActionInput = z.infer<typeof HandoffActionInputSchema>;
export const SwitchResultSchema = z.object({
workspaceId: WorkspaceIdSchema,
handoffId: z.string().min(1),
providerId: ProviderIdSchema,
switchTarget: z.string().min(1)
});
export type SwitchResult = z.infer<typeof SwitchResultSchema>;
export const ListHandoffsInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: RepoIdSchema.optional()
});
export type ListHandoffsInput = z.infer<typeof ListHandoffsInputSchema>;
export const RepoBranchRecordSchema = z.object({
branchName: z.string().min(1),
commitSha: z.string().min(1),
parentBranch: z.string().nullable(),
trackedInStack: z.boolean(),
diffStat: z.string().nullable(),
hasUnpushed: z.boolean(),
conflictsWithMain: z.boolean(),
handoffId: z.string().nullable(),
handoffTitle: z.string().nullable(),
handoffStatus: HandoffStatusSchema.nullable(),
prNumber: z.number().int().nullable(),
prState: z.string().nullable(),
prUrl: z.string().nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
firstSeenAt: z.number().int().nullable(),
lastSeenAt: z.number().int().nullable(),
updatedAt: z.number().int()
});
export type RepoBranchRecord = z.infer<typeof RepoBranchRecordSchema>;
export const RepoOverviewSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: RepoIdSchema,
remoteUrl: RepoRemoteSchema,
baseRef: z.string().nullable(),
stackAvailable: z.boolean(),
fetchedAt: z.number().int(),
branches: z.array(RepoBranchRecordSchema)
});
export type RepoOverview = z.infer<typeof RepoOverviewSchema>;
export const RepoStackActionSchema = z.enum([
"sync_repo",
"restack_repo",
"restack_subtree",
"rebase_branch",
"reparent_branch"
]);
export type RepoStackAction = z.infer<typeof RepoStackActionSchema>;
export const RepoStackActionInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
repoId: RepoIdSchema,
action: RepoStackActionSchema,
branchName: z.string().trim().min(1).optional(),
parentBranch: z.string().trim().min(1).optional()
});
export type RepoStackActionInput = z.infer<typeof RepoStackActionInputSchema>;
export const RepoStackActionResultSchema = z.object({
action: RepoStackActionSchema,
executed: z.boolean(),
message: z.string().min(1),
at: z.number().int()
});
export type RepoStackActionResult = z.infer<typeof RepoStackActionResultSchema>;
export const WorkspaceUseInputSchema = z.object({
workspaceId: WorkspaceIdSchema
});
export type WorkspaceUseInput = z.infer<typeof WorkspaceUseInputSchema>;
export const HistoryQueryInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
limit: z.number().int().positive().max(500).optional(),
branch: z.string().min(1).optional(),
handoffId: z.string().min(1).optional()
});
export type HistoryQueryInput = z.infer<typeof HistoryQueryInputSchema>;
export const HistoryEventSchema = z.object({
id: z.number().int(),
workspaceId: WorkspaceIdSchema,
repoId: z.string().nullable(),
handoffId: z.string().nullable(),
branchName: z.string().nullable(),
kind: z.string().min(1),
payloadJson: z.string().min(1),
createdAt: z.number().int()
});
export type HistoryEvent = z.infer<typeof HistoryEventSchema>;
export const PruneInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
dryRun: z.boolean(),
yes: z.boolean()
});
export type PruneInput = z.infer<typeof PruneInputSchema>;
export const KillInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
handoffId: z.string().min(1),
deleteBranch: z.boolean(),
abandon: z.boolean()
});
export type KillInput = z.infer<typeof KillInputSchema>;
export const StatuslineInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
format: z.enum(["table", "claude-code"])
});
export type StatuslineInput = z.infer<typeof StatuslineInputSchema>;
export const ListInputSchema = z.object({
workspaceId: WorkspaceIdSchema,
format: z.enum(["table", "json"]),
full: z.boolean()
});
export type ListInput = z.infer<typeof ListInputSchema>;

View file

@ -0,0 +1,4 @@
export * from "./contracts.js";
export * from "./config.js";
export * from "./workbench.js";
export * from "./workspace.js";

View file

@ -0,0 +1,181 @@
export type WorkbenchHandoffStatus = "running" | "idle" | "new" | "archived";
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor";
export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
export interface WorkbenchTranscriptEvent {
id: string;
eventIndex: number;
sessionId: string;
createdAt: number;
connectionId: string;
sender: "client" | "agent";
payload: unknown;
}
export interface WorkbenchComposerDraft {
text: string;
attachments: WorkbenchLineAttachment[];
updatedAtMs: number | null;
}
export interface WorkbenchAgentTab {
id: string;
sessionId: string | null;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
created: boolean;
draft: WorkbenchComposerDraft;
transcript: WorkbenchTranscriptEvent[];
}
export interface WorkbenchFileChange {
path: string;
added: number;
removed: number;
type: "M" | "A" | "D";
}
export interface WorkbenchFileTreeNode {
name: string;
path: string;
isDir: boolean;
children?: WorkbenchFileTreeNode[];
}
export interface WorkbenchLineAttachment {
id: string;
filePath: string;
lineNumber: number;
lineContent: string;
}
export interface WorkbenchHistoryEvent {
id: string;
messageId: string;
preview: string;
sessionName: string;
tabId: string;
createdAtMs: number;
detail: string;
}
export type WorkbenchDiffLineKind = "context" | "add" | "remove" | "hunk";
export interface WorkbenchParsedDiffLine {
kind: WorkbenchDiffLineKind;
lineNumber: number;
text: string;
}
export interface WorkbenchPullRequestSummary {
number: number;
status: "draft" | "ready";
}
export interface WorkbenchHandoff {
id: string;
repoId: string;
title: string;
status: WorkbenchHandoffStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
tabs: WorkbenchAgentTab[];
fileChanges: WorkbenchFileChange[];
diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[];
}
export interface WorkbenchRepo {
id: string;
label: string;
}
export interface WorkbenchProjectSection {
id: string;
label: string;
updatedAtMs: number;
handoffs: WorkbenchHandoff[];
}
export interface HandoffWorkbenchSnapshot {
workspaceId: string;
repos: WorkbenchRepo[];
projects: WorkbenchProjectSection[];
handoffs: WorkbenchHandoff[];
}
export interface WorkbenchModelOption {
id: WorkbenchModelId;
label: string;
}
export interface WorkbenchModelGroup {
provider: string;
models: WorkbenchModelOption[];
}
export interface HandoffWorkbenchSelectInput {
handoffId: string;
}
export interface HandoffWorkbenchCreateHandoffInput {
repoId: string;
task: string;
title?: string;
branch?: string;
model?: WorkbenchModelId;
}
export interface HandoffWorkbenchRenameInput {
handoffId: string;
value: string;
}
export interface HandoffWorkbenchSendMessageInput {
handoffId: string;
tabId: string;
text: string;
attachments: WorkbenchLineAttachment[];
}
export interface HandoffWorkbenchTabInput {
handoffId: string;
tabId: string;
}
export interface HandoffWorkbenchRenameSessionInput extends HandoffWorkbenchTabInput {
title: string;
}
export interface HandoffWorkbenchChangeModelInput extends HandoffWorkbenchTabInput {
model: WorkbenchModelId;
}
export interface HandoffWorkbenchUpdateDraftInput extends HandoffWorkbenchTabInput {
text: string;
attachments: WorkbenchLineAttachment[];
}
export interface HandoffWorkbenchSetSessionUnreadInput extends HandoffWorkbenchTabInput {
unread: boolean;
}
export interface HandoffWorkbenchDiffInput {
handoffId: string;
path: string;
}
export interface HandoffWorkbenchCreateHandoffResponse {
handoffId: string;
tabId?: string;
}
export interface HandoffWorkbenchAddTabResponse {
tabId: string;
}

View file

@ -0,0 +1,16 @@
import type { AppConfig } from "./config.js";
export function resolveWorkspaceId(
flagWorkspace: string | undefined,
config: AppConfig
): string {
if (flagWorkspace && flagWorkspace.trim().length > 0) {
return flagWorkspace.trim();
}
if (config.workspace.default.trim().length > 0) {
return config.workspace.default.trim();
}
return "default";
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "../src/index.js";
const cfg: AppConfig = ConfigSchema.parse({
auto_submit: true,
notify: ["terminal"],
workspace: { default: "team-a" },
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/openhandoff/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
backup_retention_days: 7
},
providers: {
daytona: { image: "ubuntu:24.04" }
}
});
describe("resolveWorkspaceId", () => {
it("prefers explicit flag", () => {
expect(resolveWorkspaceId("feature", cfg)).toBe("feature");
});
it("falls back to config default", () => {
expect(resolveWorkspaceId(undefined, cfg)).toBe("team-a");
});
it("falls back to literal default when config value is empty", () => {
const empty = {
...cfg,
workspace: { default: "" }
} as AppConfig;
expect(resolveWorkspaceId(undefined, empty)).toBe("default");
});
});

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"]
}