Compare commits
3 Commits
v2.8.13-mo
...
v2.8.17-bo
| Author | SHA1 | Date | |
|---|---|---|---|
| c11e26090f | |||
| e0feb53437 | |||
| 3c5b2c2bcf |
@@ -29,7 +29,9 @@ import { registerProviderRoutes } from './routes/providers.js';
|
|||||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||||
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
||||||
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
|
import { registerPlanRoutes } from './routes/plans.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
|
import { updatePlanFromRun } from './services/plan-store.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
|
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
|
||||||
@@ -229,8 +231,16 @@ async function main() {
|
|||||||
|
|
||||||
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
|
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
|
||||||
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
|
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
|
||||||
// terminal callback can be wired in.
|
// terminal callback can be wired in. onRunTerminal updates linked plans.
|
||||||
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
|
const flowRunner = createFlowRunner({
|
||||||
|
sql, broker, log: app.log, config,
|
||||||
|
onRunTerminal: (runId, status) => {
|
||||||
|
updatePlanFromRun(sql, runId, status).catch((err) => {
|
||||||
|
app.log.error({ err: err instanceof Error ? err.message : String(err), runId },
|
||||||
|
'plans: updatePlanFromRun failed');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
|
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
|
||||||
// Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
|
// Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
|
||||||
@@ -384,6 +394,7 @@ async function main() {
|
|||||||
registerWorktreeSafetyRoutes(app, sql);
|
registerWorktreeSafetyRoutes(app, sql);
|
||||||
registerLifecycleRoutes(app, sql);
|
registerLifecycleRoutes(app, sql);
|
||||||
registerAnalyticsRoutes(app, sql);
|
registerAnalyticsRoutes(app, sql);
|
||||||
|
registerPlanRoutes(app, sql);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
134
apps/coder/src/routes/plans.ts
Normal file
134
apps/coder/src/routes/plans.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Boulder state — plan routes.
|
||||||
|
*
|
||||||
|
* GET /api/plans?project_id= — list plans for a project
|
||||||
|
* GET /api/plans/active?project_id= — list active (in-flight) plans
|
||||||
|
* POST /api/plans — create a new plan
|
||||||
|
* PATCH /api/plans/:id — update plan progress / status
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import {
|
||||||
|
createPlan,
|
||||||
|
getPlan,
|
||||||
|
listPlans,
|
||||||
|
listActivePlans,
|
||||||
|
updatePlan,
|
||||||
|
} from '../services/plan-store.js';
|
||||||
|
|
||||||
|
const CreatePlanBody = z.object({
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
description: z.string().max(10_000).optional(),
|
||||||
|
flow_run_id: z.string().uuid().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ListPlansQuery = z.object({
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdatePlanBody = z.object({
|
||||||
|
title: z.string().min(1).max(500).optional(),
|
||||||
|
description: z.string().max(10_000).nullable().optional(),
|
||||||
|
status: z.enum(['active', 'completed', 'cancelled', 'failed']).optional(),
|
||||||
|
progress_pct: z.number().int().min(0).max(100).optional(),
|
||||||
|
items_total: z.number().int().min(0).optional(),
|
||||||
|
items_completed: z.number().int().min(0).optional(),
|
||||||
|
metadata: z.record(z.unknown()).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlanIdParam = z.string().uuid();
|
||||||
|
|
||||||
|
export function registerPlanRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/plans?project_id= — all plans for a project
|
||||||
|
app.get('/api/plans', async (req, reply) => {
|
||||||
|
const parsed = ListPlansQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const plans = await listPlans(sql, parsed.data.project_id);
|
||||||
|
return { plans };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/plans/active?project_id= — active plans only
|
||||||
|
app.get('/api/plans/active', async (req, reply) => {
|
||||||
|
const parsed = ListPlansQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const plans = await listActivePlans(sql, parsed.data.project_id);
|
||||||
|
return { plans };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/plans — create a new plan
|
||||||
|
app.post('/api/plans', async (req, reply) => {
|
||||||
|
const parsed = CreatePlanBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project_id, title, description, flow_run_id, metadata } = parsed.data;
|
||||||
|
const plan = await createPlan(sql, {
|
||||||
|
projectId: project_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
flowRunId: flow_run_id,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return { plan };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/plans/:id — single plan
|
||||||
|
app.get<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
|
||||||
|
const parsedId = PlanIdParam.safeParse(req.params.id);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid id' };
|
||||||
|
}
|
||||||
|
const plan = await getPlan(sql, parsedId.data);
|
||||||
|
if (!plan) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'plan not found' };
|
||||||
|
}
|
||||||
|
return { plan };
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/plans/:id — update plan
|
||||||
|
app.patch<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
|
||||||
|
const parsedId = PlanIdParam.safeParse(req.params.id);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid id' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UpdatePlanBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, status, progress_pct, items_total, items_completed, metadata } = parsed.data;
|
||||||
|
const plan = await updatePlan(sql, parsedId.data, {
|
||||||
|
title,
|
||||||
|
description: description === null ? null : description,
|
||||||
|
status,
|
||||||
|
progressPct: progress_pct,
|
||||||
|
itemsTotal: items_total,
|
||||||
|
itemsCompleted: items_completed,
|
||||||
|
metadata: metadata === null ? null : metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'plan not found' };
|
||||||
|
}
|
||||||
|
return { plan };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -438,3 +438,31 @@ CREATE TABLE IF NOT EXISTS flow_step_events (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
||||||
|
|
||||||
|
-- v2.9.0: Boulder state — cross-session plan persistence with auto-resumption.
|
||||||
|
-- project_id carries no FK (matches tasks/fow_runs convention).
|
||||||
|
-- flow_run_id links the plan to an in-flight orchestrator run for auto-tracking.
|
||||||
|
CREATE TABLE IF NOT EXISTS plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
flow_run_id UUID REFERENCES flow_runs(id) ON DELETE SET NULL,
|
||||||
|
progress_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
items_completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT plans_status_chk CHECK (status IN ('active', 'completed', 'cancelled', 'failed')),
|
||||||
|
CONSTRAINT plans_progress_chk CHECK (progress_pct >= 0 AND progress_pct <= 100),
|
||||||
|
CONSTRAINT plans_items_chk CHECK (items_total >= 0 AND items_completed >= 0 AND items_completed <= items_total)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Plan queries by project and status.
|
||||||
|
CREATE INDEX IF NOT EXISTS plans_project_status_idx ON plans(project_id, status);
|
||||||
|
-- Fast lookup of the plan owning a flow run (for onRunTerminal updates).
|
||||||
|
CREATE INDEX IF NOT EXISTS plans_flow_run_id_idx ON plans(flow_run_id);
|
||||||
|
-- Plans sorted by recency (for "resume from last" surface).
|
||||||
|
CREATE INDEX IF NOT EXISTS plans_project_created_idx ON plans(project_id, created_at DESC);
|
||||||
|
|||||||
16
apps/coder/src/services/__tests__/plan-store.test.ts
Normal file
16
apps/coder/src/services/__tests__/plan-store.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { planStatusFromRun } from '../plan-store.js';
|
||||||
|
|
||||||
|
describe('planStatusFromRun', () => {
|
||||||
|
it('maps completed to completed', () => {
|
||||||
|
expect(planStatusFromRun('completed')).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps failed to failed', () => {
|
||||||
|
expect(planStatusFromRun('failed')).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps cancelled to cancelled', () => {
|
||||||
|
expect(planStatusFromRun('cancelled')).toBe('cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,8 @@ interface Deps {
|
|||||||
broker: Broker;
|
broker: Broker;
|
||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
config: Config;
|
config: Config;
|
||||||
|
/** Fired when a flow run reaches a terminal state (for plan-store integration). */
|
||||||
|
onRunTerminal?: (runId: string, status: 'completed' | 'failed' | 'cancelled') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlowStepRow {
|
interface FlowStepRow {
|
||||||
@@ -479,6 +481,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
WHERE id = ${runId} AND status = 'running'
|
WHERE id = ${runId} AND status = 'running'
|
||||||
`;
|
`;
|
||||||
if (updated.count === 0) return; // already terminal (e.g. cancelled) — don't publish
|
if (updated.count === 0) return; // already terminal (e.g. cancelled) — don't publish
|
||||||
|
deps.onRunTerminal?.(runId, 'completed');
|
||||||
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
|
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
|
||||||
run_status: 'completed',
|
run_status: 'completed',
|
||||||
report,
|
report,
|
||||||
@@ -498,6 +501,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
WHERE id = ${runId} AND status = 'running'
|
WHERE id = ${runId} AND status = 'running'
|
||||||
`;
|
`;
|
||||||
if (updated.count === 0) return;
|
if (updated.count === 0) return;
|
||||||
|
deps.onRunTerminal?.(runId, 'failed');
|
||||||
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
||||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||||
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||||
@@ -512,6 +516,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
WHERE id = ${runId} AND status = 'running'
|
WHERE id = ${runId} AND status = 'running'
|
||||||
`;
|
`;
|
||||||
if (updated.count === 0) return; // idempotent — already terminal
|
if (updated.count === 0) return; // idempotent — already terminal
|
||||||
|
deps.onRunTerminal?.(runId, 'cancelled');
|
||||||
// Any remaining pending steps are unreachable; mark + publish them so the
|
// Any remaining pending steps are unreachable; mark + publish them so the
|
||||||
// pane can show them as cancelled rather than stuck in pending.
|
// pane can show them as cancelled rather than stuck in pending.
|
||||||
const pending = await sql<{ step_id: string; kind: string }[]>`
|
const pending = await sql<{ step_id: string; kind: string }[]>`
|
||||||
@@ -742,6 +747,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
WHERE id = ${runId} AND status = 'running'
|
WHERE id = ${runId} AND status = 'running'
|
||||||
`;
|
`;
|
||||||
if (updated.count === 0) return { cancelled: false, taskIds: [] };
|
if (updated.count === 0) return { cancelled: false, taskIds: [] };
|
||||||
|
deps.onRunTerminal?.(runId, 'cancelled');
|
||||||
|
|
||||||
// Mark all non-terminal steps cancelled and collect in-flight task_ids.
|
// Mark all non-terminal steps cancelled and collect in-flight task_ids.
|
||||||
const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
|
const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
|
||||||
|
|||||||
184
apps/coder/src/services/plan-store.ts
Normal file
184
apps/coder/src/services/plan-store.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Boulder state — cross-session plan persistence for BooCode.
|
||||||
|
*
|
||||||
|
* Plans live above flow_runs: a plan tracks a user's work goal and can link to
|
||||||
|
* a flow run for automatic progress tracking. When the linked flow run reaches
|
||||||
|
* a terminal state (completed/failed/cancelled), the plan is auto-updated.
|
||||||
|
*
|
||||||
|
* Auto-resumption: on startup, plans with a linked in-flight flow_run are
|
||||||
|
* surfaced via the GET endpoint so the UI can show a resume prompt. The
|
||||||
|
* flow-runner's initResume() re-advances the actual run; this store surfaces
|
||||||
|
* the plan-level view.
|
||||||
|
*/
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
flow_run_id: string | null;
|
||||||
|
progress_pct: number;
|
||||||
|
items_total: number;
|
||||||
|
items_completed: number;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePlanOpts {
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
flowRunId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlanOpts {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: 'active' | 'completed' | 'cancelled' | 'failed';
|
||||||
|
progressPct?: number;
|
||||||
|
itemsTotal?: number;
|
||||||
|
itemsCompleted?: number;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlan(sql: Sql, opts: CreatePlanOpts): Promise<Plan> {
|
||||||
|
return sql`
|
||||||
|
INSERT INTO plans (project_id, title, description, flow_run_id, metadata)
|
||||||
|
VALUES (
|
||||||
|
${opts.projectId},
|
||||||
|
${opts.title},
|
||||||
|
${opts.description ?? null},
|
||||||
|
${opts.flowRunId ?? null},
|
||||||
|
${opts.metadata ? sql.json(opts.metadata as never) : null}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`.then((rows) => rows[0] as unknown as Plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlan(sql: Sql, planId: string): Promise<Plan | null> {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM plans WHERE id = ${planId}
|
||||||
|
`.then((rows) => (rows[0] as unknown as Plan) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPlans(sql: Sql, projectId: string): Promise<Plan[]> {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM plans
|
||||||
|
WHERE project_id = ${projectId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
` as Promise<Plan[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActivePlans(sql: Sql, projectId: string): Promise<Plan[]> {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM plans
|
||||||
|
WHERE project_id = ${projectId} AND status = 'active'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
` as Promise<Plan[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlan(
|
||||||
|
sql: Sql,
|
||||||
|
planId: string,
|
||||||
|
opts: UpdatePlanOpts,
|
||||||
|
): Promise<Plan | null> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (opts.title !== undefined) {
|
||||||
|
sets.push(`title = $${values.length + 1}`);
|
||||||
|
values.push(opts.title);
|
||||||
|
}
|
||||||
|
if (opts.description !== undefined) {
|
||||||
|
sets.push(`description = $${values.length + 1}`);
|
||||||
|
values.push(opts.description);
|
||||||
|
}
|
||||||
|
if (opts.status !== undefined) {
|
||||||
|
sets.push(`status = $${values.length + 1}`);
|
||||||
|
values.push(opts.status);
|
||||||
|
}
|
||||||
|
if (opts.progressPct !== undefined) {
|
||||||
|
sets.push(`progress_pct = $${values.length + 1}`);
|
||||||
|
values.push(opts.progressPct);
|
||||||
|
}
|
||||||
|
if (opts.itemsTotal !== undefined) {
|
||||||
|
sets.push(`items_total = $${values.length + 1}`);
|
||||||
|
values.push(opts.itemsTotal);
|
||||||
|
}
|
||||||
|
if (opts.itemsCompleted !== undefined) {
|
||||||
|
sets.push(`items_completed = $${values.length + 1}`);
|
||||||
|
values.push(opts.itemsCompleted);
|
||||||
|
}
|
||||||
|
if (opts.metadata !== undefined) {
|
||||||
|
sets.push(`metadata = $${values.length + 1}::jsonb`);
|
||||||
|
values.push(opts.metadata !== null ? JSON.stringify(opts.metadata) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return getPlan(sql, planId);
|
||||||
|
|
||||||
|
sets.push(`updated_at = clock_timestamp()`);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE plans SET ${sets.join(', ')}
|
||||||
|
WHERE id = $${values.length + 1}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
values.push(planId);
|
||||||
|
|
||||||
|
const result = await sql.unsafe(query, values as never[]);
|
||||||
|
return (result[0] as unknown as Plan) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a flow run reaches a terminal state. Updates the linked plan's
|
||||||
|
* status based on the run outcome:
|
||||||
|
* - completed → plan completed
|
||||||
|
* - failed → plan failed
|
||||||
|
* - cancelled → plan cancelled
|
||||||
|
* Returns true when a plan was updated, false when no plan is linked to the run.
|
||||||
|
*/
|
||||||
|
export async function updatePlanFromRun(
|
||||||
|
sql: Sql,
|
||||||
|
runId: string,
|
||||||
|
runStatus: 'completed' | 'failed' | 'cancelled',
|
||||||
|
): Promise<boolean> {
|
||||||
|
const planStatus = planStatusFromRun(runStatus);
|
||||||
|
const updated = await sql`
|
||||||
|
UPDATE plans
|
||||||
|
SET status = ${planStatus}, progress_pct = 100,
|
||||||
|
items_completed = items_total, updated_at = clock_timestamp()
|
||||||
|
WHERE flow_run_id = ${runId} AND status = 'active'
|
||||||
|
`;
|
||||||
|
return updated.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a flow-run terminal status to its corresponding plan status. Pure. */
|
||||||
|
export function planStatusFromRun(runStatus: 'completed' | 'failed' | 'cancelled'): string {
|
||||||
|
return runStatus === 'completed' ? 'completed' : runStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find any active plan linked to a running flow run — used by the startup
|
||||||
|
* resume path to surface plans that have in-flight orchestrator runs.
|
||||||
|
*/
|
||||||
|
export async function findPlanWithRunningRun(
|
||||||
|
sql: Sql,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<(Plan & { run_status: string }) | null> {
|
||||||
|
const [row] = await sql`
|
||||||
|
SELECT p.*, fr.status AS run_status
|
||||||
|
FROM plans p
|
||||||
|
JOIN flow_runs fr ON fr.id = p.flow_run_id
|
||||||
|
WHERE p.project_id = ${projectId}
|
||||||
|
AND p.status = 'active'
|
||||||
|
AND fr.status = 'running'
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return (row as unknown as Plan & { run_status: string }) ?? null;
|
||||||
|
}
|
||||||
110
apps/server/src/services/boocontext_client.ts
Normal file
110
apps/server/src/services/boocontext_client.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* v2.7.18: shared MCP client wrapper for the boocontext sidecar.
|
||||||
|
*
|
||||||
|
* Calls into the existing multi-server MCP client infrastructure
|
||||||
|
* (services/mcp-client.ts) which connects to boocontext as a stdio
|
||||||
|
* MCP process defined in data/mcp.json (server name "boocontext",
|
||||||
|
* command: `node /opt/forks/boocontext/dist/standalone.js`).
|
||||||
|
*
|
||||||
|
* The boocontext MCP server is initialized once at app boot in
|
||||||
|
* index.ts via initMcp() and the actual MCP tool call routing is
|
||||||
|
* handled by mcp-client.ts:callTool() — this module is a thin
|
||||||
|
* convenience wrapper that prepends the "boocontext_" server prefix,
|
||||||
|
* normalises the response, and applies inline truncation matching
|
||||||
|
* the same pattern as codecontext_client.ts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { callBoocontext } from './services/boocontext_client.js';
|
||||||
|
* const resp = await callBoocontext({
|
||||||
|
* toolName: 'codesight_get_summary',
|
||||||
|
* args: { directory: '/opt/boocode' },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callTool } from './mcp-client.js';
|
||||||
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
|
// ---- Exported types ----
|
||||||
|
|
||||||
|
export interface BoocontextRequest {
|
||||||
|
/** Unprefixed tool name as defined on the boocontext MCP server
|
||||||
|
* (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */
|
||||||
|
toolName: string;
|
||||||
|
/** Arguments to pass to the tool. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoocontextResponse {
|
||||||
|
/** The tool output text. */
|
||||||
|
result: string;
|
||||||
|
/** Whether the result was truncated to fit the inline limit. */
|
||||||
|
truncated: boolean;
|
||||||
|
/** Opaque id pointing at the full pre-slice content on tmpfs, set when
|
||||||
|
* truncated=true and storage succeeded. */
|
||||||
|
outputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
/** Must match the server name in data/mcp.json. */
|
||||||
|
const BOOCONTEXT_SERVER_NAME = 'boocontext';
|
||||||
|
|
||||||
|
/** Inline truncation limit, matching codecontext_client.ts. */
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a boocontext MCP tool by its unprefixed name.
|
||||||
|
*
|
||||||
|
* Prepends the "boocontext_" server prefix, delegates to the
|
||||||
|
* multi-server MCP client's callTool(), and normalises the response
|
||||||
|
* into a BoocontextResponse with inline truncation.
|
||||||
|
*
|
||||||
|
* @param req The tool name and arguments.
|
||||||
|
* @param log Optional Fastify-compatible logger (for debug traces).
|
||||||
|
* @returns The tool result, possibly truncated.
|
||||||
|
* @throws If the boocontext server is not connected or the tool
|
||||||
|
* returns an MCP-level error.
|
||||||
|
*/
|
||||||
|
export async function callBoocontext(
|
||||||
|
req: BoocontextRequest,
|
||||||
|
log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void },
|
||||||
|
): Promise<BoocontextResponse> {
|
||||||
|
const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`;
|
||||||
|
|
||||||
|
log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool');
|
||||||
|
|
||||||
|
const raw = await callTool(prefixedName, req.args);
|
||||||
|
|
||||||
|
// callTool returns { error: true, output: string } on failure (both
|
||||||
|
// for MCP-level isError and for network/protocol exceptions).
|
||||||
|
if (typeof raw === 'object' && raw !== null && (raw as Record<string, unknown>).error === true) {
|
||||||
|
const errOutput = (raw as Record<string, unknown>).output ?? 'Unknown MCP error';
|
||||||
|
throw new Error(`boocontext error: ${String(errOutput)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||||
|
|
||||||
|
// Inline truncation at 32 kB, matching codecontext_client.ts.
|
||||||
|
// The model gets a clear hint about how to narrow the next call
|
||||||
|
// rather than a silent cut.
|
||||||
|
if (result.length > TRUNCATION_LIMIT) {
|
||||||
|
const truncated = result.slice(0, TRUNCATION_LIMIT);
|
||||||
|
const omitted = result.length - TRUNCATION_LIMIT;
|
||||||
|
const slicedWithMarker =
|
||||||
|
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`;
|
||||||
|
const wrapped = await truncateIfNeeded({
|
||||||
|
fullContent: result,
|
||||||
|
slicedContent: slicedWithMarker,
|
||||||
|
wasTruncated: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result: wrapped.content,
|
||||||
|
truncated: wrapped.truncated,
|
||||||
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, truncated: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import { callBoocontext } from '../../boocontext_client.js';
|
||||||
|
|
||||||
|
export const GetCodeHealthInput = z.object({
|
||||||
|
directory: z.string().optional().describe('Directory to analyze (defaults to project root)'),
|
||||||
|
file: z.string().optional().describe('Optional: specific file to analyze'),
|
||||||
|
});
|
||||||
|
export type GetCodeHealthInputT = z.infer<typeof GetCodeHealthInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Code health analysis. Returns A–F grades per file across 7 dimensions ' +
|
||||||
|
'(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' +
|
||||||
|
'Includes project health summary and refactoring candidates.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function — calls the boocontext MCP server's
|
||||||
|
* boocontext_health tool and returns the raw report text.
|
||||||
|
*
|
||||||
|
* Structured for direct test access: accepts input + projectPath,
|
||||||
|
* no side effects beyond the MCP call.
|
||||||
|
*/
|
||||||
|
export async function executeGetCodeHealth(
|
||||||
|
input: GetCodeHealthInputT,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
if (input.directory) args['directory'] = input.directory;
|
||||||
|
if (input.file) args['file'] = input.file;
|
||||||
|
const resp = await callBoocontext({ toolName: 'boocontext_health', args });
|
||||||
|
return resp.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeHealth: ToolDef<GetCodeHealthInputT> = {
|
||||||
|
name: 'get_code_health',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeHealthInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_health',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory to analyze (defaults to project root)',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional: specific file to analyze',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return executeGetCodeHealth(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
// ======================= MCP Client =======================
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js');
|
||||||
|
const TOOL_CALL_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
interface JsonRpcMessage {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id?: number | string;
|
||||||
|
result?: {
|
||||||
|
content?: Array<{ type: string; text: string }>;
|
||||||
|
};
|
||||||
|
error?: { code?: number; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-shot MCP JSON-RPC client for boocontext.
|
||||||
|
* Spawns the process, sends initialize + tools/call over NDJSON, returns the
|
||||||
|
* text result from the content array. The boocontext MCP server auto-detects
|
||||||
|
* newline-delimited JSON transport when the first input lacks Content-Length
|
||||||
|
* headers, which is exactly what we send.
|
||||||
|
*/
|
||||||
|
async function callBoocontext(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise<string>((resolvePromise, reject) => {
|
||||||
|
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: TOOL_CALL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
function finalize(err?: Error, result?: string): void {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolvePromise(result!);
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout!.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr!.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err: Error) => {
|
||||||
|
finalize(new Error(`boocontext spawn error: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
if (resolved) return;
|
||||||
|
|
||||||
|
// Parse newline-delimited JSON responses from stdout
|
||||||
|
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
|
||||||
|
let toolText: string | undefined;
|
||||||
|
let toolError: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line) as JsonRpcMessage;
|
||||||
|
if (msg.id === 2) {
|
||||||
|
if (msg.error) {
|
||||||
|
toolError = msg.error.message ?? 'boocontext tool call failed';
|
||||||
|
} else if (msg.result?.content?.[0]?.text !== undefined) {
|
||||||
|
toolText = msg.result.content[0].text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolError) {
|
||||||
|
finalize(new Error(toolError));
|
||||||
|
} else if (toolText !== undefined) {
|
||||||
|
finalize(undefined, toolText);
|
||||||
|
} else {
|
||||||
|
const errSuffix =
|
||||||
|
stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : '';
|
||||||
|
finalize(
|
||||||
|
new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: initialize — establishes MCP protocol version + capabilities
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'boocode-server', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: tools/call — invoke the named boocontext tool
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: toolName, arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stdin!.end();
|
||||||
|
|
||||||
|
// Safety timeout — prevent hung processes
|
||||||
|
setTimeout(() => {
|
||||||
|
finalize(
|
||||||
|
new Error(
|
||||||
|
`boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, TOOL_CALL_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= Tool Definition =======================
|
||||||
|
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
export const GetCodeImpactInput = z.object({
|
||||||
|
symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'),
|
||||||
|
file: z.string().optional().describe('File path for codesight blast_radius'),
|
||||||
|
directory: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Directory (defaults to project root)'),
|
||||||
|
depth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(5)
|
||||||
|
.optional()
|
||||||
|
.describe('Max blast-radius traversal depth (default 1)'),
|
||||||
|
});
|
||||||
|
export type GetCodeImpactInputT = z.infer<typeof GetCodeImpactInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' +
|
||||||
|
'Use before making changes to understand change propagation. ' +
|
||||||
|
'Single call replaces separate get_symbol_info + get_blast_radius steps.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function — calls the boocontext MCP `boocontext_impact`
|
||||||
|
* tool via a short-lived child process, then wraps the result in the standard
|
||||||
|
* CodecontextResponse shape with inline truncation at 32 KB.
|
||||||
|
*/
|
||||||
|
export async function executeGetCodeImpact(
|
||||||
|
input: GetCodeImpactInputT,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = {
|
||||||
|
symbol: input.symbol,
|
||||||
|
directory: input.directory ?? projectPath,
|
||||||
|
};
|
||||||
|
if (input.file) args['file'] = input.file;
|
||||||
|
|
||||||
|
const text = await callBoocontext('boocontext_impact', args);
|
||||||
|
|
||||||
|
// Inline truncation matching codecontext_client.ts patterns (32 KB ceiling).
|
||||||
|
if (text.length > TRUNCATION_LIMIT) {
|
||||||
|
const sliced = text.slice(0, TRUNCATION_LIMIT);
|
||||||
|
const omitted = text.length - TRUNCATION_LIMIT;
|
||||||
|
return {
|
||||||
|
result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: text, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeImpact: ToolDef<GetCodeImpactInputT> = {
|
||||||
|
name: 'get_code_impact',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeImpactInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_impact',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
symbol: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Symbol name for TSA trace_impact',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File path for codesight blast_radius',
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory (defaults to project root)',
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Max blast-radius traversal depth (default 1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['symbol'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute(input, projectRoot) {
|
||||||
|
return executeGetCodeImpact(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
|
||||||
|
export const GetCodeMapInput = z.object({
|
||||||
|
directory: z.string().optional().describe('Directory to scan (defaults to project root)'),
|
||||||
|
compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'),
|
||||||
|
});
|
||||||
|
export type GetCodeMapInputT = z.infer<typeof GetCodeMapInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' +
|
||||||
|
'Use compress=false for full detail, compress=true (default) for token-efficient overview.';
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||||
|
const TOOL_TIMEOUT_MS = 30_000;
|
||||||
|
const MAX_RESULT_BYTES = 32_768;
|
||||||
|
|
||||||
|
export interface CodeMapResponse {
|
||||||
|
result: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the boocontext MCP server over stdio JSON-RPC to invoke
|
||||||
|
* the boocontext_map tool. Spawns the standalone binary, sends
|
||||||
|
* initialize + tools/call, collects NDJSON responses, and kills
|
||||||
|
* the child process.
|
||||||
|
*/
|
||||||
|
function callBoocontextMap(args: Record<string, unknown>): Promise<CodeMapResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('node', [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutBuf = '';
|
||||||
|
const lines: string[] = [];
|
||||||
|
let timedOut = false;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`));
|
||||||
|
}, TOOL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
function tryParse(): void {
|
||||||
|
if (resolved || timedOut) return;
|
||||||
|
|
||||||
|
// Accumulate complete NDJSON lines
|
||||||
|
const parts = stdoutBuf.split('\n');
|
||||||
|
stdoutBuf = parts.pop()! ?? '';
|
||||||
|
for (const p of parts) {
|
||||||
|
const t = p.trim();
|
||||||
|
if (t) lines.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need at least 2 responses: initialize + tools/call
|
||||||
|
if (lines.length < 2) return;
|
||||||
|
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.kill();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const callResponse = JSON.parse(lines[1]!);
|
||||||
|
if (callResponse.error) {
|
||||||
|
reject(new Error(`MCP error: ${callResponse.error.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = callResponse.result?.content;
|
||||||
|
if (!content?.[0]?.text) {
|
||||||
|
reject(new Error('Unexpected MCP response shape — missing content[0].text'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// content[0].text is JSON-stringified VerdictEnvelope from boocontext
|
||||||
|
const envelope = JSON.parse(content[0].text as string);
|
||||||
|
const details = envelope.details;
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
if (details && typeof details === 'object' && 'data' in details) {
|
||||||
|
// DcpEnvelope shape: { compressed, originalLength, compressedLength, data }
|
||||||
|
if (details.compressed) {
|
||||||
|
// Return the full DcpEnvelope as JSON so the LLM can pass it
|
||||||
|
// transparently to a decompression step
|
||||||
|
result = JSON.stringify(details);
|
||||||
|
} else {
|
||||||
|
// Uncompressed — data is the raw output
|
||||||
|
result = details.data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = JSON.stringify(details ?? envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES;
|
||||||
|
if (truncated) {
|
||||||
|
result = result.substring(0, MAX_RESULT_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ result, truncated });
|
||||||
|
} catch (e: any) {
|
||||||
|
reject(new Error(`Failed to parse boocontext response: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout!.on('data', (chunk: Buffer) => {
|
||||||
|
if (timedOut) return;
|
||||||
|
stdoutBuf += chunk.toString('utf-8');
|
||||||
|
tryParse();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr!.on('data', (_chunk: Buffer) => {
|
||||||
|
// Captured but not surfaced — logged only on parse failure
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err: Error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error(`boocontext spawn failed: ${err.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!resolved && !timedOut) {
|
||||||
|
tryParse();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error('boocontext process closed without producing a valid response'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: initialize
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: tools/call for boocontext_map
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'boocontext_map', arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeMap: ToolDef<GetCodeMapInputT> = {
|
||||||
|
name: 'get_code_map',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeMapInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_map',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
directory: { type: 'string', description: 'Directory to scan (defaults to project root)' },
|
||||||
|
compress: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Apply DCP compression if payload exceeds threshold (default: true)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot): Promise<CodeMapResponse> {
|
||||||
|
return callBoocontextMap({
|
||||||
|
directory: input.directory ?? projectRoot,
|
||||||
|
compress: input.compress ?? true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeGetCodeMap(
|
||||||
|
input: GetCodeMapInputT,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<CodeMapResponse> {
|
||||||
|
return callBoocontextMap({
|
||||||
|
directory: input.directory ?? projectRoot,
|
||||||
|
compress: input.compress ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
export const GetTypeInfoInput = z.object({
|
||||||
|
file: z.string().min(1).describe('File path to resolve types in'),
|
||||||
|
symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'),
|
||||||
|
directory: z.string().optional().describe('Project directory for type resolution context'),
|
||||||
|
});
|
||||||
|
export type GetTypeInfoInputT = z.infer<typeof GetTypeInfoInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'TypeScript type recovery. Returns type signatures, interface definitions, ' +
|
||||||
|
'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.';
|
||||||
|
|
||||||
|
// ---- JSON-RPC-over-stdio MCP caller for boocontext --------------------------
|
||||||
|
|
||||||
|
async function callBoocontext(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderrBuf = '';
|
||||||
|
child.stderr!.on('data', (chunk: Buffer) => {
|
||||||
|
stderrBuf += chunk.toString('utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
let killed = false;
|
||||||
|
const killChild = () => {
|
||||||
|
if (killed) return;
|
||||||
|
killed = true;
|
||||||
|
child.kill();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read one complete JSON-RPC response from stdout (handles both
|
||||||
|
// Content-Length framed and newline-delimited transport).
|
||||||
|
async function readResponse(timeoutMs = 30_000): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Timeout reading boocontext response'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout!.removeListener('data', onData);
|
||||||
|
child.stdout!.removeListener('end', onEnd);
|
||||||
|
child.stdout!.removeListener('error', onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
buf += chunk.toString('utf-8');
|
||||||
|
|
||||||
|
const msg = tryExtractMessage(buf);
|
||||||
|
if (msg !== null) {
|
||||||
|
cleanup();
|
||||||
|
resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.length > 1_024 * 1_024) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Boocontext response exceeded 1 MB'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
cleanup();
|
||||||
|
if (buf.trim()) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buf.trim()));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Boocontext stream ended with incomplete data'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error('Boocontext stream ended unexpectedly'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout!.on('data', onData);
|
||||||
|
child.stdout!.on('end', onEnd);
|
||||||
|
child.stdout!.on('error', onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the process to be fully spawned.
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('spawn', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1 — MCP initialize
|
||||||
|
let reqId = 0;
|
||||||
|
reqId++;
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const initResp = await readResponse() as { error?: { message: string } };
|
||||||
|
if (initResp.error) {
|
||||||
|
throw new Error(`Boocontext init failed: ${initResp.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — tools/call
|
||||||
|
reqId++;
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: reqId,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: toolName, arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const callResp = await readResponse() as {
|
||||||
|
error?: { message: string };
|
||||||
|
result?: { content?: Array<{ type: string; text: string }> };
|
||||||
|
};
|
||||||
|
if (callResp.error) {
|
||||||
|
throw new Error(`Boocontext tool call failed: ${callResp.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from the MCP tool result shape:
|
||||||
|
// { content: [{ type: "text", text: "…" }] }
|
||||||
|
const content = callResp.result?.content;
|
||||||
|
let text: string;
|
||||||
|
if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') {
|
||||||
|
text = content[0]!.text;
|
||||||
|
} else {
|
||||||
|
text = JSON.stringify(callResp.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline truncation at 32 KB.
|
||||||
|
if (text.length > TRUNCATION_LIMIT) {
|
||||||
|
const omitted = text.length - TRUNCATION_LIMIT;
|
||||||
|
return {
|
||||||
|
result:
|
||||||
|
text.slice(0, TRUNCATION_LIMIT) +
|
||||||
|
`\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: text, truncated: false };
|
||||||
|
} finally {
|
||||||
|
killChild();
|
||||||
|
// Give the process a moment to release resources.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 2_000);
|
||||||
|
child.on('exit', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to extract one complete JSON-RPC message from the head of a
|
||||||
|
* buffer. Handles both Content-Length framed and newline-delimited
|
||||||
|
* formats. Returns `null` when more data is needed.
|
||||||
|
*/
|
||||||
|
function tryExtractMessage(buf: string): unknown | null {
|
||||||
|
// --- Content-Length framed ---
|
||||||
|
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd !== -1) {
|
||||||
|
const header = buf.substring(0, headerEnd);
|
||||||
|
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (lengthMatch) {
|
||||||
|
const contentLength = parseInt(lengthMatch[1]!, 10);
|
||||||
|
const bodyStart = headerEnd + 4;
|
||||||
|
if (buf.length >= bodyStart + contentLength) {
|
||||||
|
const jsonStr = buf.substring(bodyStart, bodyStart + contentLength);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
return null; // need more data
|
||||||
|
}
|
||||||
|
// Has \r\n\r\n but no Content-Length — junk segment; skip and retry.
|
||||||
|
return tryExtractMessage(buf.substring(headerEnd + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Newline-delimited ---
|
||||||
|
const nlIndex = buf.indexOf('\n');
|
||||||
|
if (nlIndex !== -1) {
|
||||||
|
const line = buf.substring(0, nlIndex).trim();
|
||||||
|
if (line && line.startsWith('{')) {
|
||||||
|
return JSON.parse(line);
|
||||||
|
}
|
||||||
|
// Non-JSON line (e.g. stderr echo), skip and continue.
|
||||||
|
return tryExtractMessage(buf.substring(nlIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // need more data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ToolDef ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getTypeInfo: ToolDef<GetTypeInfoInputT> = {
|
||||||
|
name: 'get_type_info',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetTypeInfoInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_type_info',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', description: 'File path to resolve types in' },
|
||||||
|
symbol: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Symbol name to resolve (supports regex)',
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Project directory for type resolution context',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['file'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = { file: input.file };
|
||||||
|
if (input.symbol) args['symbol'] = input.symbol;
|
||||||
|
return callBoocontext('boocontext_types', args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function matching the `execute` shape returned by
|
||||||
|
* `makeCodecontextTool` — useful for direct callers and tests.
|
||||||
|
*
|
||||||
|
* Note: unlike the HTTP-backed codecontext tools this does NOT accept a
|
||||||
|
* `fetcher` override because it communicates over stdio rather than HTTP.
|
||||||
|
*/
|
||||||
|
export async function executeGetTypeInfo(
|
||||||
|
input: GetTypeInfoInputT,
|
||||||
|
_projectPath?: string,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = { file: input.file };
|
||||||
|
if (input.symbol) args['symbol'] = input.symbol;
|
||||||
|
return callBoocontext('boocontext_types', args);
|
||||||
|
}
|
||||||
@@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js';
|
|||||||
export { getHotFiles } from './get_hot_files.js';
|
export { getHotFiles } from './get_hot_files.js';
|
||||||
export { getRoutes } from './get_routes.js';
|
export { getRoutes } from './get_routes.js';
|
||||||
export { getMiddleware } from './get_middleware.js';
|
export { getMiddleware } from './get_middleware.js';
|
||||||
|
// v2.8.14-domain2-phase1: boocontext-backed tools.
|
||||||
|
export { getCodeHealth } from './get_code_health.js';
|
||||||
|
export { getCodeImpact } from './get_code_impact.js';
|
||||||
|
export { getTypeInfo } from './get_type_info.js';
|
||||||
|
export { getCodeMap } from './get_code_map.js';
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
getHotFiles,
|
getHotFiles,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
getMiddleware,
|
getMiddleware,
|
||||||
|
getCodeHealth,
|
||||||
|
getCodeImpact,
|
||||||
|
getTypeInfo,
|
||||||
|
getCodeMap,
|
||||||
} from './codecontext/index.js';
|
} from './codecontext/index.js';
|
||||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
|||||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||||
readTabByNumber as ToolDef<unknown>,
|
readTabByNumber as ToolDef<unknown>,
|
||||||
|
// v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext
|
||||||
|
// MCP server. All read-only. Health, impact, types, map analysis.
|
||||||
|
getCodeHealth as ToolDef<unknown>,
|
||||||
|
getCodeImpact as ToolDef<unknown>,
|
||||||
|
getTypeInfo as ToolDef<unknown>,
|
||||||
|
getCodeMap as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
160
scripts/omo-paseo-bridge.sh
Executable file
160
scripts/omo-paseo-bridge.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# omo-paseo-bridge.sh — Import OMO task() child sessions as Paseo agents
|
||||||
|
#
|
||||||
|
# Automates calling `paseo import` on child session IDs so OMO subagents
|
||||||
|
# appear in `paseo ls` alongside native Paseo agents.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# omo-paseo-bridge.sh import [--type <category>] <session-id>...
|
||||||
|
# Import session(s) as Paseo agents with omo=true labels
|
||||||
|
#
|
||||||
|
# omo-paseo-bridge.sh archive <agent-id>...
|
||||||
|
# Archive (soft-delete) agent(s) imported by this bridge
|
||||||
|
#
|
||||||
|
# omo-paseo-bridge.sh ls [--all]
|
||||||
|
# List agents tagged omo=true via paseo ls
|
||||||
|
#
|
||||||
|
# omo-paseo-bridge.sh --dry-run <command> ...
|
||||||
|
# Print what would be done without executing
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# omo-paseo-bridge.sh import ses_abc123 ses_def456
|
||||||
|
# omo-paseo-bridge.sh import --type research ses_abc123
|
||||||
|
# omo-paseo-bridge.sh archive agt_789
|
||||||
|
# omo-paseo-bridge.sh ls
|
||||||
|
# omo-paseo-bridge.sh --dry-run import ses_abc123
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
|
PASEO="$(which paseo 2>/dev/null || echo "paseo")"
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log() { printf "[%s] %s\n" "$SCRIPT_NAME" "$*"; }
|
||||||
|
warn() { printf "[%s] WARNING: %s\n" "$SCRIPT_NAME" "$*" >&2; }
|
||||||
|
err() { printf "[%s] ERROR: %s\n" "$SCRIPT_NAME" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
paseo_cmd() {
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log "[DRY-RUN] would run: $PASEO $*"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
"$PASEO" "$@" 2>&1 || warn "'paseo $*' exited with code $?"
|
||||||
|
}
|
||||||
|
|
||||||
|
paseo_import() {
|
||||||
|
local session_id="$1"
|
||||||
|
shift
|
||||||
|
local type_label="${1:-}"
|
||||||
|
local labels=("--label" "omo=true")
|
||||||
|
|
||||||
|
# Add parent session label if OMO_SESSION_ID is set (injected by agent)
|
||||||
|
if [[ -n "${OMO_SESSION_ID:-}" ]]; then
|
||||||
|
labels+=("--label" "parent=${OMO_SESSION_ID}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$type_label" ]]; then
|
||||||
|
labels+=("--label" "type=${type_label}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Importing session ${session_id} as Paseo agent ..."
|
||||||
|
paseo_cmd import "$session_id" --provider opencode "${labels[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
paseo_archive() {
|
||||||
|
local agent_id="$1"
|
||||||
|
log "Archiving agent ${agent_id} ..."
|
||||||
|
paseo_cmd archive "$agent_id" --force
|
||||||
|
}
|
||||||
|
|
||||||
|
paseo_list() {
|
||||||
|
local all_flag="${1:-}"
|
||||||
|
if [[ "$all_flag" == "--all" ]]; then
|
||||||
|
paseo_cmd ls --label "omo=true" --all
|
||||||
|
else
|
||||||
|
paseo_cmd ls --label "omo=true"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── usage ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $SCRIPT_NAME [--dry-run] <command> [options] [args...]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
import [--type <category>] <session-id>...
|
||||||
|
Import OMO child session(s) as Paseo agents
|
||||||
|
archive <agent-id>...
|
||||||
|
Archive Paseo agent(s) (soft-delete)
|
||||||
|
ls [--all]
|
||||||
|
List agents tagged omo=true
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print actions without executing them
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$SCRIPT_NAME import --type research ses_abc123
|
||||||
|
$SCRIPT_NAME archive agt_789
|
||||||
|
$SCRIPT_NAME ls --all
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Peel off global flags
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $# -eq 0 ]] && usage
|
||||||
|
|
||||||
|
COMMAND="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
import)
|
||||||
|
TYPE_LABEL=""
|
||||||
|
SESSION_IDS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--type) TYPE_LABEL="$2"; shift 2 ;;
|
||||||
|
--type=*) TYPE_LABEL="${1#*=}"; shift ;;
|
||||||
|
-*) err "Unknown option for import: $1" ;;
|
||||||
|
*) SESSION_IDS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ ${#SESSION_IDS[@]} -eq 0 ]] && err "import requires at least one session-id"
|
||||||
|
|
||||||
|
for sid in "${SESSION_IDS[@]}"; do
|
||||||
|
paseo_import "$sid" "$TYPE_LABEL"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
|
||||||
|
archive)
|
||||||
|
[[ $# -eq 0 ]] && err "archive requires at least one agent-id"
|
||||||
|
for aid in "$@"; do
|
||||||
|
paseo_archive "$aid"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
|
||||||
|
ls)
|
||||||
|
paseo_list "${1:-}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
err "Unknown command: $COMMAND\n$(usage)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user