Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions .opencode/plugins/task-runtime-sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
buildFollowUpTaskPrompt,
buildInitialTaskPrompt,
} from "./task-runtime-sqlite/prompt-builder.js";
import {
createSessionRuntime,
type SessionRuntimeHost,
} from "./task-runtime-sqlite/session-runtime.js";
import {
createTaskRepository,
TASK_STATUSES,
type TaskStatus,
} from "./task-runtime-sqlite/task-repository.js";

export type SqliteTaskRuntimePluginOptions = {
host?: SessionRuntimeHost;
projectDir: string;
};

type ToolContext = {
sessionID: string;
};

const createTaskBoundTools = (options: SqliteTaskRuntimePluginOptions) => {
const getRepository = () =>
createTaskRepository({
now: () => new Date().toISOString(),
projectDir: options.projectDir,
});
const getSessionRuntime = () => createSessionRuntime(options.host);

const dispatchTasks = async () => {
const repository = getRepository();
const sessionRuntime = getSessionRuntime();
const results: Array<{
action: "continued" | "created" | "reclaimed" | "skipped_busy";
sessionID: string;
taskID: string;
}> = [];

for (const task of repository.listUnfinishedTasks()) {
if (task.session_id) {
const session = await sessionRuntime.getSession(task.session_id);

if (session?.busy) {
results.push({
action: "skipped_busy",
sessionID: task.session_id,
taskID: task.task_id,
});
continue;
}

if (session) {
await sessionRuntime.sendPrompt(
session.id,
buildFollowUpTaskPrompt(task),
);
results.push({
action: "continued",
sessionID: session.id,
taskID: task.task_id,
});
continue;
}
}

const session = await sessionRuntime.createSession();
const previousSessionID = task.session_id;

repository.assignSession(task.task_id, session.id);

try {
await sessionRuntime.sendPrompt(
session.id,
buildInitialTaskPrompt({ ...task, session_id: session.id }),
);
} catch (error) {
repository.assignSession(task.task_id, previousSessionID);
throw error;
}

results.push({
action: task.session_id ? "reclaimed" : "created",
sessionID: session.id,
taskID: task.task_id,
});
}

return results;
};

return {
"dispatch-tasks": {
schema: {},
execute: dispatchTasks,
},
"list-processing-tasks": {
schema: {},
execute: () => getRepository().listProcessingTasks(),
},
"get-current-task": {
schema: {},
execute: (_input: Record<string, never>, context: ToolContext) =>
getRepository().getRequiredTaskBySessionID(context.sessionID),
},
"mark-task-status": {
schema: { status: [...TASK_STATUSES] },
execute: (input: { status: TaskStatus }, context: ToolContext) =>
getRepository().markTaskStatus({
sessionID: context.sessionID,
status: input.status,
}),
},
"setup-worktree-path": {
schema: { worktreePath: true },
execute: (input: { worktreePath: string }, context: ToolContext) =>
getRepository().setupWorktreePath({
sessionID: context.sessionID,
worktreePath: input.worktreePath,
}),
},
"setup-pull-request-url": {
schema: { pullRequestURL: true },
execute: (input: { pullRequestURL: string }, context: ToolContext) =>
getRepository().setupPullRequestURL({
sessionID: context.sessionID,
pullRequestURL: input.pullRequestURL,
}),
},
};
};

export const createSqliteTaskRuntimePlugin = (
options: SqliteTaskRuntimePluginOptions,
) => ({
name: "sqlite-task-runtime",
tools: createTaskBoundTools(options),
});
33 changes: 33 additions & 0 deletions .opencode/plugins/task-runtime-sqlite/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";

export type TaskRecord = {
task_id: string;
task_spec: string;
session_id: string | null;
worktree_path: string | null;
pull_request_url: string | null;
status: string | null;
done: boolean;
updated_at: string | null;
};

export type TaskRuntimeDatabase = {
projectDir: string;
path: string;
open(): DatabaseSync;
};

export const getTaskRuntimeDatabasePath = (projectDir: string) =>
join(projectDir, "aim.sqlite");

export const openTaskRuntimeDatabase = (projectDir: string) =>
new DatabaseSync(getTaskRuntimeDatabasePath(projectDir));
Comment on lines +21 to +25

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openTaskRuntimeDatabase() uses new DatabaseSync(path) which will create aim.sqlite when it doesn’t exist. The design spec in this PR says the plugin should error when the DB is missing or schema is invalid; auto-creating an empty DB can hide misconfiguration and lead to dispatch operating against the wrong file. Consider checking file existence (and optionally opening read-only if supported) and throwing a clear error before creating/using the DB.

Copilot uses AI. Check for mistakes.

export const createTaskRuntimeDatabase = (
projectDir: string,
): TaskRuntimeDatabase => ({
projectDir,
path: getTaskRuntimeDatabasePath(projectDir),
open: () => openTaskRuntimeDatabase(projectDir),
});
47 changes: 47 additions & 0 deletions .opencode/plugins/task-runtime-sqlite/prompt-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { TaskRecord } from "./database.js";

const buildCodeFence = (value: string) => {
const backtickRuns = value.match(/`+/g) ?? [];
const longestRun = backtickRuns.reduce(
(max, run) => Math.max(max, run.length),
0,
);

return "`".repeat(Math.max(3, longestRun + 1));
};

const formatTaskSnapshot = (task: TaskRecord) =>
[
`task_id: ${task.task_id}`,
"task_spec:",
`${buildCodeFence(task.task_spec)}text`,
task.task_spec,
buildCodeFence(task.task_spec),
`status: ${task.status ?? "unknown"}`,
`worktree_path: ${task.worktree_path ?? "not set"}`,
`pull_request_url: ${task.pull_request_url ?? "not set"}`,
].join("\n");

export const buildInitialTaskPrompt = (task: TaskRecord) =>
[
"你正在处理一条已绑定的 Task。",
"请根据下面的任务快照开始推进当前任务:",
formatTaskSnapshot(task),
].join("\n\n");

export const buildFollowUpTaskPrompt = (task: TaskRecord) =>
[
"继续推进当前 Task。",
"以下是当前任务快照:",
formatTaskSnapshot(task),
].join("\n\n");

export type PromptBuilder = {
buildInitialTaskPrompt(task: TaskRecord): string;
buildFollowUpTaskPrompt(task: TaskRecord): string;
};

export const createPromptBuilder = (): PromptBuilder => ({
buildInitialTaskPrompt,
buildFollowUpTaskPrompt,
});
42 changes: 42 additions & 0 deletions .opencode/plugins/task-runtime-sqlite/session-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type SessionRecord = {
id: string;
busy?: boolean;
};

export type SessionRuntimeHost = {
getSession?(
sessionID: string,
): Promise<SessionRecord | null> | SessionRecord | null;
createSession(): Promise<SessionRecord> | SessionRecord;
sendPrompt(sessionID: string, prompt: string): Promise<void> | void;
};

export type SessionRuntime = {
getSession(sessionID: string): Promise<SessionRecord | null>;
createSession(): Promise<SessionRecord>;
sendPrompt(sessionID: string, prompt: string): Promise<void>;
};

export const createSessionRuntime = (
host?: SessionRuntimeHost,
): SessionRuntime => ({
async getSession(sessionID) {
return (await host?.getSession?.(sessionID)) ?? null;
},

async createSession() {
if (!host) {
throw new Error("Session host does not support createSession()");
}

return host.createSession();
},

async sendPrompt(sessionID, prompt) {
if (!host) {
throw new Error("Session host does not support sendPrompt()");
}

await host.sendPrompt(sessionID, prompt);
},
});
Loading
Loading