feat(orchestrator): convoy driver tick loop and Redis job queue (Epic 6)
Adds apps/orchestrator with planOrchestratorAction in shared, dry-run mode, Redis role_dispatch enqueue, gate pauses, and stall detection logging. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5025ac5266
commit
8b01c1b8dd
22 changed files with 853 additions and 12 deletions
|
|
@ -31,6 +31,12 @@ MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
|
||||||
# Local dev: redis://localhost:6379
|
# Local dev: redis://localhost:6379
|
||||||
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
||||||
|
|
||||||
|
# ----- Orchestrator (apps/orchestrator) -----
|
||||||
|
# ORCHESTRATOR_DRY_RUN=1 logs dispatch/transition without Redis enqueue
|
||||||
|
# ORCHESTRATOR_POLL_MS=5000
|
||||||
|
# ORCHESTRATOR_STALL_MINUTES=120
|
||||||
|
# ORCHESTRATOR_MAX_IMPLEMENTERS=2
|
||||||
|
|
||||||
# ----- Auth (NextAuth v5) -----
|
# ----- Auth (NextAuth v5) -----
|
||||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
|
||||||
28
apps/orchestrator/package.json
Normal file
28
apps/orchestrator/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "@tasks/orchestrator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "node dist/index.mjs",
|
||||||
|
"build": "tsup",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tasks/database": "workspace:*",
|
||||||
|
"@tasks/shared": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.38.0",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
162
apps/orchestrator/src/db/convoy.ts
Normal file
162
apps/orchestrator/src/db/convoy.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
||||||
|
import {
|
||||||
|
briefObjectContentSchema,
|
||||||
|
convoyObjectContentSchema,
|
||||||
|
validateConvoyTransition,
|
||||||
|
type ConvoyStatus,
|
||||||
|
} from "@tasks/shared/convoy-lifecycle";
|
||||||
|
import type { BriefSummary } from "@tasks/shared/orchestrator";
|
||||||
|
|
||||||
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
export type LoadedConvoy = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
title: string;
|
||||||
|
status: ConvoyStatus;
|
||||||
|
skipFlags: string[];
|
||||||
|
briefs: BriefSummary[];
|
||||||
|
completedRoles: Set<string>;
|
||||||
|
completedBriefIds: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseStatus(raw: string | null): ConvoyStatus {
|
||||||
|
const status = raw ?? "draft";
|
||||||
|
return status as ConvoyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(objects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(objects.type, "project"),
|
||||||
|
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
|
||||||
|
inArray(objects.status, ["draft", "ready", "in_progress"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded: LoadedConvoy[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const content = convoyObjectContentSchema.safeParse(row.content);
|
||||||
|
if (!content.success) continue;
|
||||||
|
|
||||||
|
const briefRows = await db
|
||||||
|
.select()
|
||||||
|
.from(objects)
|
||||||
|
.where(and(eq(objects.parentId, row.id), eq(objects.workspaceId, row.workspaceId)));
|
||||||
|
|
||||||
|
const events = await db
|
||||||
|
.select({ role: convoyEvents.role, eventType: convoyEvents.eventType, briefId: convoyEvents.briefId })
|
||||||
|
.from(convoyEvents)
|
||||||
|
.where(eq(convoyEvents.convoyId, row.id))
|
||||||
|
.orderBy(desc(convoyEvents.occurredAt))
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
const completedRoles = new Set<string>();
|
||||||
|
const completedBriefIds = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.eventType === "role_complete") {
|
||||||
|
completedRoles.add(event.role);
|
||||||
|
if (event.briefId) completedBriefIds.add(event.briefId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const briefs: BriefSummary[] = briefRows.map((brief) => {
|
||||||
|
const parsed = briefObjectContentSchema.safeParse(brief.content);
|
||||||
|
return {
|
||||||
|
id: brief.id,
|
||||||
|
briefNumber: parsed.success ? parsed.data.briefNumber : brief.sortOrder,
|
||||||
|
status: brief.status,
|
||||||
|
sliceDependencies: parsed.success ? parsed.data.sliceDependencies : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
loaded.push({
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
title: row.title,
|
||||||
|
status: parseStatus(row.status),
|
||||||
|
skipFlags: content.data.skipFlags,
|
||||||
|
briefs,
|
||||||
|
completedRoles,
|
||||||
|
completedBriefIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionConvoyInDb(
|
||||||
|
db: Db,
|
||||||
|
input: {
|
||||||
|
workspaceId: string;
|
||||||
|
convoyId: string;
|
||||||
|
toStatus: ConvoyStatus;
|
||||||
|
actor: string;
|
||||||
|
reason?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [convoy] = await db
|
||||||
|
.select()
|
||||||
|
.from(objects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(objects.id, input.convoyId),
|
||||||
|
eq(objects.workspaceId, input.workspaceId),
|
||||||
|
eq(objects.type, "project"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!convoy) {
|
||||||
|
throw new Error(`Convoy ${input.convoyId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = parseStatus(convoy.status);
|
||||||
|
validateConvoyTransition({
|
||||||
|
from: fromStatus,
|
||||||
|
to: input.toStatus,
|
||||||
|
actor: input.actor,
|
||||||
|
reason: input.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(objects)
|
||||||
|
.set({ status: input.toStatus, updatedAt: new Date() })
|
||||||
|
.where(eq(objects.id, convoy.id));
|
||||||
|
|
||||||
|
await db.insert(convoyEvents).values({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
convoyId: convoy.id,
|
||||||
|
role: input.actor,
|
||||||
|
actor: input.actor,
|
||||||
|
eventType: "status_change",
|
||||||
|
metadata: {
|
||||||
|
from: fromStatus,
|
||||||
|
to: input.toStatus,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
source: "orchestrator",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
|
||||||
|
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
|
||||||
|
const openRuns = await db
|
||||||
|
.select({
|
||||||
|
backlogItemId: agentRuns.backlogItemId,
|
||||||
|
startedAt: agentRuns.startedAt,
|
||||||
|
})
|
||||||
|
.from(agentRuns)
|
||||||
|
.where(and(isNull(agentRuns.finishedAt), lt(agentRuns.startedAt, cutoff)))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (openRuns.length === 0) return [];
|
||||||
|
|
||||||
|
return openRuns.map((run) => run.backlogItemId);
|
||||||
|
}
|
||||||
42
apps/orchestrator/src/env.ts
Normal file
42
apps/orchestrator/src/env.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
REDIS_URL: z.string().min(1).optional(),
|
||||||
|
ORCHESTRATOR_DRY_RUN: z
|
||||||
|
.enum(["0", "1", "true", "false", ""])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === "1" || value === "true"),
|
||||||
|
ORCHESTRATOR_POLL_MS: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
const parsed = Number(value ?? "5000");
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;
|
||||||
|
}),
|
||||||
|
ORCHESTRATOR_STALL_MINUTES: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
const parsed = Number(value ?? "120");
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||||
|
}),
|
||||||
|
ORCHESTRATOR_MAX_IMPLEMENTERS: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
const parsed = Number(value ?? "2");
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrchestratorEnv = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
export function loadEnv(): OrchestratorEnv {
|
||||||
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("[orchestrator] Invalid environment:", parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
49
apps/orchestrator/src/index.ts
Normal file
49
apps/orchestrator/src/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { db } from "@tasks/database";
|
||||||
|
import IoRedis from "ioredis";
|
||||||
|
|
||||||
|
import { detectStalledConvoys } from "./db/convoy.js";
|
||||||
|
import { loadEnv } from "./env.js";
|
||||||
|
import { runOrchestratorTick } from "./tick.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const env = loadEnv();
|
||||||
|
const redis = env.REDIS_URL ? new IoRedis(env.REDIS_URL) : null;
|
||||||
|
|
||||||
|
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||||
|
console.info("[orchestrator] DRY-RUN mode — jobs will be logged, not enqueued");
|
||||||
|
} else if (!redis) {
|
||||||
|
console.warn("[orchestrator] REDIS_URL not set — running in DRY-RUN mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[orchestrator] polling every ${env.ORCHESTRATOR_POLL_MS}ms`);
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
try {
|
||||||
|
const results = await runOrchestratorTick(db, redis, {
|
||||||
|
...env,
|
||||||
|
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stalled = await detectStalledConvoys(db, env.ORCHESTRATOR_STALL_MINUTES);
|
||||||
|
if (stalled.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
console.info("[orchestrator] no active convoys");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[orchestrator] tick failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await loop();
|
||||||
|
setInterval(loop, env.ORCHESTRATOR_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[orchestrator] fatal:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type IoRedis from "ioredis";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ORCHESTRATOR_QUEUE_KEY,
|
||||||
|
orchestratorJobSchema,
|
||||||
|
type OrchestratorJob,
|
||||||
|
} from "@tasks/shared/orchestrator";
|
||||||
|
|
||||||
|
export async function enqueueJob(redis: IoRedis, job: Omit<OrchestratorJob, "id" | "enqueuedAt">) {
|
||||||
|
const payload = orchestratorJobSchema.parse({
|
||||||
|
...job,
|
||||||
|
id: randomUUID(),
|
||||||
|
enqueuedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await redis.rpush(ORCHESTRATOR_QUEUE_KEY, JSON.stringify(payload));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dequeueJob(redis: IoRedis, timeoutSeconds = 5): Promise<OrchestratorJob | null> {
|
||||||
|
const result = await redis.blpop(ORCHESTRATOR_QUEUE_KEY, timeoutSeconds);
|
||||||
|
if (!result) return null;
|
||||||
|
const [, raw] = result;
|
||||||
|
try {
|
||||||
|
return orchestratorJobSchema.parse(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
console.error("[orchestrator] Dropping invalid job payload:", raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/orchestrator/src/tick.ts
Normal file
122
apps/orchestrator/src/tick.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import type IoRedis from "ioredis";
|
||||||
|
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import {
|
||||||
|
planOrchestratorAction,
|
||||||
|
type OrchestratorAction,
|
||||||
|
type OrchestratorJob,
|
||||||
|
} from "@tasks/shared/orchestrator";
|
||||||
|
|
||||||
|
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
|
||||||
|
import { enqueueJob } from "./queue/redis-queue.js";
|
||||||
|
import type { OrchestratorEnv } from "./env.js";
|
||||||
|
|
||||||
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
export type TickResult = {
|
||||||
|
convoyId: string;
|
||||||
|
action: OrchestratorAction;
|
||||||
|
job?: OrchestratorJob;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyAction(
|
||||||
|
db: Db,
|
||||||
|
redis: IoRedis | null,
|
||||||
|
env: OrchestratorEnv,
|
||||||
|
convoy: LoadedConvoy,
|
||||||
|
action: OrchestratorAction,
|
||||||
|
): Promise<TickResult> {
|
||||||
|
const base = { convoyId: convoy.id, action };
|
||||||
|
|
||||||
|
if (action.type === "noop" || action.type === "pause") {
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] convoy=${convoy.title} status=${convoy.status} → ${action.type}: ${
|
||||||
|
action.type === "pause" ? action.reason : action.reason
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "transition") {
|
||||||
|
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] DRY-RUN transition convoy=${convoy.title} ${convoy.status} → ${action.toStatus} (actor=${action.actor})`,
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
await transitionConvoyInDb(db, {
|
||||||
|
workspaceId: convoy.workspaceId,
|
||||||
|
convoyId: convoy.id,
|
||||||
|
toStatus: action.toStatus,
|
||||||
|
actor: action.actor,
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] transition convoy=${convoy.title} → ${action.toStatus} (actor=${action.actor})`,
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "dispatch") {
|
||||||
|
if (
|
||||||
|
action.roleId === "role-implementer" &&
|
||||||
|
convoy.status === "ready" &&
|
||||||
|
!env.ORCHESTRATOR_DRY_RUN
|
||||||
|
) {
|
||||||
|
await transitionConvoyInDb(db, {
|
||||||
|
workspaceId: convoy.workspaceId,
|
||||||
|
convoyId: convoy.id,
|
||||||
|
toStatus: "in_progress",
|
||||||
|
actor: "role-implementer",
|
||||||
|
});
|
||||||
|
convoy.status = "in_progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] DRY-RUN dispatch convoy=${convoy.title} role=${action.roleId}${
|
||||||
|
action.briefId ? ` brief=${action.briefId}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await enqueueJob(redis, {
|
||||||
|
type: "role_dispatch",
|
||||||
|
workspaceId: convoy.workspaceId,
|
||||||
|
convoyId: convoy.id,
|
||||||
|
roleId: action.roleId,
|
||||||
|
briefId: action.briefId,
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] enqueued job=${job.id} convoy=${convoy.title} role=${action.roleId}`,
|
||||||
|
);
|
||||||
|
return { ...base, job };
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOrchestratorTick(
|
||||||
|
db: Db,
|
||||||
|
redis: IoRedis | null,
|
||||||
|
env: OrchestratorEnv,
|
||||||
|
): Promise<TickResult[]> {
|
||||||
|
const convoys = await listActiveConvoys(db);
|
||||||
|
const results: TickResult[] = [];
|
||||||
|
|
||||||
|
for (const convoy of convoys) {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId: convoy.id,
|
||||||
|
status: convoy.status,
|
||||||
|
skipFlags: convoy.skipFlags,
|
||||||
|
briefs: convoy.briefs,
|
||||||
|
completedRoles: convoy.completedRoles,
|
||||||
|
completedBriefIds: convoy.completedBriefIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(await applyAction(db, redis, env, convoy, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
9
apps/orchestrator/tsconfig.json
Normal file
9
apps/orchestrator/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
11
apps/orchestrator/tsup.config.ts
Normal file
11
apps/orchestrator/tsup.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
noExternal: [/^@tasks\//],
|
||||||
|
target: "node20",
|
||||||
|
platform: "node",
|
||||||
|
clean: true,
|
||||||
|
});
|
||||||
7
apps/orchestrator/vitest.config.ts
Normal file
7
apps/orchestrator/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
30
docs/orchestrator.md
Normal file
30
docs/orchestrator.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Orchestrator (`apps/orchestrator`)
|
||||||
|
|
||||||
|
Long-running service that drives convoy lifecycle: plans next pipeline role, enqueues worker jobs on Redis, pauses at human gates, and detects stalled agent runs.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run (no Redis required)
|
||||||
|
ORCHESTRATOR_DRY_RUN=1 pnpm --filter @tasks/orchestrator dev
|
||||||
|
|
||||||
|
# Live enqueue (requires REDIS_URL)
|
||||||
|
pnpm --filter @tasks/orchestrator dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Driver** — pure `planOrchestratorAction()` in `@tasks/shared/orchestrator`
|
||||||
|
- **Tick loop** — loads active convoys (`draft`, `ready`, `in_progress`), applies next action
|
||||||
|
- **Queue** — `echodo:orchestrator:jobs` Redis list (consumed by `apps/worker`, Epic 7)
|
||||||
|
- **Gates** — `convoy_review`, `pr_review`, `merged` pause until mission control approves
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `DATABASE_URL` | required | Convoy + event reads/writes |
|
||||||
|
| `REDIS_URL` | optional | Job queue; missing ⇒ dry-run |
|
||||||
|
| `ORCHESTRATOR_DRY_RUN` | `false` | Log actions without enqueue |
|
||||||
|
| `ORCHESTRATOR_POLL_MS` | `5000` | Tick interval |
|
||||||
|
| `ORCHESTRATOR_STALL_MINUTES` | `120` | Stall detector threshold |
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./connected-repos": "./src/connected-repos/index.ts",
|
"./connected-repos": "./src/connected-repos/index.ts",
|
||||||
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||||
|
"./orchestrator": "./src/orchestrator/index.ts",
|
||||||
"./model-routing": "./src/model-routing/index.ts",
|
"./model-routing": "./src/model-routing/index.ts",
|
||||||
"./types": "./src/types/index.ts",
|
"./types": "./src/types/index.ts",
|
||||||
"./utils": "./src/utils/index.ts"
|
"./utils": "./src/utils/index.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from "./connected-repos";
|
export * from "./connected-repos";
|
||||||
export * from "./convoy-lifecycle";
|
export * from "./convoy-lifecycle";
|
||||||
export * from "./model-routing";
|
export * from "./model-routing";
|
||||||
|
export * from "./orchestrator";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|
|
||||||
82
packages/shared/src/orchestrator/convoy-driver.test.ts
Normal file
82
packages/shared/src/orchestrator/convoy-driver.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { planOrchestratorAction } from "./convoy-driver";
|
||||||
|
|
||||||
|
describe("planOrchestratorAction", () => {
|
||||||
|
const convoyId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
|
||||||
|
it("pauses at convoy_review gate", () => {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId,
|
||||||
|
status: "convoy_review",
|
||||||
|
skipFlags: [],
|
||||||
|
briefs: [],
|
||||||
|
completedRoles: new Set(),
|
||||||
|
completedBriefIds: new Set(),
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({ type: "pause", gate: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches first planning role on draft", () => {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId,
|
||||||
|
status: "draft",
|
||||||
|
skipFlags: [],
|
||||||
|
briefs: [],
|
||||||
|
completedRoles: new Set(),
|
||||||
|
completedBriefIds: new Set(),
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({ type: "dispatch", roleId: "role-ia-architect" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips ia when in skip flags", () => {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId,
|
||||||
|
status: "draft",
|
||||||
|
skipFlags: ["ia"],
|
||||||
|
briefs: [],
|
||||||
|
completedRoles: new Set(),
|
||||||
|
completedBriefIds: new Set(),
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({ type: "dispatch", roleId: "role-ux-reviewer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to convoy_review when planning roles complete", () => {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId,
|
||||||
|
status: "draft",
|
||||||
|
skipFlags: ["ia", "ux"],
|
||||||
|
briefs: [],
|
||||||
|
completedRoles: new Set(["role-architect"]),
|
||||||
|
completedBriefIds: new Set(),
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({
|
||||||
|
type: "transition",
|
||||||
|
toStatus: "convoy_review",
|
||||||
|
actor: "role-architect",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches implementer for ready brief", () => {
|
||||||
|
const action = planOrchestratorAction({
|
||||||
|
convoyId,
|
||||||
|
status: "ready",
|
||||||
|
skipFlags: [],
|
||||||
|
briefs: [
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000010",
|
||||||
|
briefNumber: 1,
|
||||||
|
status: "ready",
|
||||||
|
sliceDependencies: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
completedRoles: new Set(),
|
||||||
|
completedBriefIds: new Set(),
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({
|
||||||
|
type: "dispatch",
|
||||||
|
roleId: "role-implementer",
|
||||||
|
briefId: "00000000-0000-4000-8000-000000000010",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
147
packages/shared/src/orchestrator/convoy-driver.ts
Normal file
147
packages/shared/src/orchestrator/convoy-driver.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import type { ConvoyStatus } from "../convoy-lifecycle/schemas";
|
||||||
|
|
||||||
|
export type BriefSummary = {
|
||||||
|
id: string;
|
||||||
|
briefNumber: number;
|
||||||
|
status: string | null;
|
||||||
|
sliceDependencies: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorPauseAction = {
|
||||||
|
type: "pause";
|
||||||
|
reason: string;
|
||||||
|
gate: "1" | "2" | "3" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorDispatchAction = {
|
||||||
|
type: "dispatch";
|
||||||
|
roleId: string;
|
||||||
|
convoyId: string;
|
||||||
|
briefId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorTransitionAction = {
|
||||||
|
type: "transition";
|
||||||
|
actor: string;
|
||||||
|
toStatus: ConvoyStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorNoopAction = {
|
||||||
|
type: "noop";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorAction =
|
||||||
|
| OrchestratorPauseAction
|
||||||
|
| OrchestratorDispatchAction
|
||||||
|
| OrchestratorTransitionAction
|
||||||
|
| OrchestratorNoopAction;
|
||||||
|
|
||||||
|
const PLANNING_PIPELINE: Array<{ skipFlag: string; roleId: string }> = [
|
||||||
|
{ skipFlag: "ia", roleId: "role-ia-architect" },
|
||||||
|
{ skipFlag: "ux", roleId: "role-ux-reviewer" },
|
||||||
|
{ skipFlag: "arch", roleId: "role-architect" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GATE_STATUSES: Record<ConvoyStatus, "1" | "2" | "3" | null> = {
|
||||||
|
draft: null,
|
||||||
|
convoy_review: "1",
|
||||||
|
ready: null,
|
||||||
|
in_progress: null,
|
||||||
|
pr_review: "2",
|
||||||
|
merged: "3",
|
||||||
|
released: null,
|
||||||
|
blocked: null,
|
||||||
|
cancelled: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBriefComplete(brief: BriefSummary): boolean {
|
||||||
|
const status = brief.status ?? "";
|
||||||
|
return status === "done" || status === "completed" || status === "merged";
|
||||||
|
}
|
||||||
|
|
||||||
|
function briefsReadyToDispatch(briefs: BriefSummary[], completedBriefIds: Set<string>): BriefSummary[] {
|
||||||
|
return briefs.filter((brief) => {
|
||||||
|
if (completedBriefIds.has(brief.id)) return false;
|
||||||
|
if (isBriefComplete(brief)) return false;
|
||||||
|
const deps = brief.sliceDependencies;
|
||||||
|
if (deps.length === 0) return true;
|
||||||
|
return deps.every((depNumber) =>
|
||||||
|
briefs.some((candidate) => candidate.briefNumber === depNumber && isBriefComplete(candidate)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure convoy driver — given current status and completed roles/briefs, returns the next orchestrator action.
|
||||||
|
*/
|
||||||
|
export function planOrchestratorAction(input: {
|
||||||
|
convoyId: string;
|
||||||
|
status: ConvoyStatus;
|
||||||
|
skipFlags: string[];
|
||||||
|
briefs: BriefSummary[];
|
||||||
|
completedRoles: Set<string>;
|
||||||
|
completedBriefIds: Set<string>;
|
||||||
|
}): OrchestratorAction {
|
||||||
|
const gate = GATE_STATUSES[input.status];
|
||||||
|
if (gate) {
|
||||||
|
return {
|
||||||
|
type: "pause",
|
||||||
|
reason: `Awaiting human gate ${gate}`,
|
||||||
|
gate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === "cancelled" || input.status === "released" || input.status === "blocked") {
|
||||||
|
return { type: "noop", reason: `Terminal or blocked status: ${input.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === "draft") {
|
||||||
|
for (const step of PLANNING_PIPELINE) {
|
||||||
|
if (input.skipFlags.includes(step.skipFlag)) continue;
|
||||||
|
if (!input.completedRoles.has(step.roleId)) {
|
||||||
|
return {
|
||||||
|
type: "dispatch",
|
||||||
|
roleId: step.roleId,
|
||||||
|
convoyId: input.convoyId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "transition",
|
||||||
|
actor: "role-architect",
|
||||||
|
toStatus: "convoy_review",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === "ready" || input.status === "in_progress") {
|
||||||
|
const eligible = briefsReadyToDispatch(input.briefs, input.completedBriefIds);
|
||||||
|
if (eligible.length > 0) {
|
||||||
|
const nextBrief = eligible.sort((a, b) => a.briefNumber - b.briefNumber)[0]!;
|
||||||
|
return {
|
||||||
|
type: "dispatch",
|
||||||
|
roleId: "role-implementer",
|
||||||
|
convoyId: input.convoyId,
|
||||||
|
briefId: nextBrief.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allBriefsDone =
|
||||||
|
input.briefs.length > 0 && input.briefs.every((brief) => isBriefComplete(brief));
|
||||||
|
if (allBriefsDone && input.status === "in_progress") {
|
||||||
|
return {
|
||||||
|
type: "transition",
|
||||||
|
actor: "role-implementer",
|
||||||
|
toStatus: "pr_review",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.briefs.length === 0) {
|
||||||
|
return { type: "noop", reason: "No implementer briefs defined yet" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "noop", reason: "Waiting for implementer briefs to complete" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "noop", reason: `No driver rule for status ${input.status}` };
|
||||||
|
}
|
||||||
13
packages/shared/src/orchestrator/index.ts
Normal file
13
packages/shared/src/orchestrator/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export {
|
||||||
|
ORCHESTRATOR_QUEUE_KEY,
|
||||||
|
orchestratorJobSchema,
|
||||||
|
orchestratorJobTypeSchema,
|
||||||
|
type OrchestratorJob,
|
||||||
|
type OrchestratorJobType,
|
||||||
|
} from "./job";
|
||||||
|
|
||||||
|
export {
|
||||||
|
planOrchestratorAction,
|
||||||
|
type BriefSummary,
|
||||||
|
type OrchestratorAction,
|
||||||
|
} from "./convoy-driver";
|
||||||
19
packages/shared/src/orchestrator/job.ts
Normal file
19
packages/shared/src/orchestrator/job.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const orchestratorJobTypeSchema = z.enum(["role_dispatch", "stall_check"]);
|
||||||
|
|
||||||
|
export type OrchestratorJobType = z.infer<typeof orchestratorJobTypeSchema>;
|
||||||
|
|
||||||
|
export const orchestratorJobSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
type: orchestratorJobTypeSchema,
|
||||||
|
workspaceId: z.string().uuid(),
|
||||||
|
convoyId: z.string().uuid(),
|
||||||
|
roleId: z.string().min(1).max(64),
|
||||||
|
briefId: z.string().uuid().optional(),
|
||||||
|
enqueuedAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrchestratorJob = z.infer<typeof orchestratorJobSchema>;
|
||||||
|
|
||||||
|
export const ORCHESTRATOR_QUEUE_KEY = "echodo:orchestrator:jobs";
|
||||||
|
|
@ -3,7 +3,7 @@ kind: epic
|
||||||
slug: apps-orchestrator
|
slug: apps-orchestrator
|
||||||
title: apps/orchestrator — role dispatch and gate pauses
|
title: apps/orchestrator — role dispatch and gate pauses
|
||||||
plan_slug: automation-foundation
|
plan_slug: automation-foundation
|
||||||
status: draft
|
status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
|
|
@ -47,12 +47,13 @@ Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `Reso
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Dry-run: orchestrator logs next role for a convoy without worker.
|
- [x] Dry-run: orchestrator logs next role for a convoy without worker.
|
||||||
- [ ] Live: one implementer brief dispatched and paused correctly at gate.
|
- [ ] Live: one implementer brief dispatched and paused correctly at gate.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
_To be decomposed when epic graduates to `ready`._
|
- [x] [Task-shared-orchestrator-driver](./Task-shared-orchestrator-driver.md)
|
||||||
|
- [x] [Task-apps-orchestrator-service](./Task-apps-orchestrator-service.md)
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: apps-orchestrator-service
|
||||||
|
title: apps/orchestrator Redis tick loop and dry-run mode
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-orchestrator
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-07"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
`@tasks/orchestrator` package: env validation, DB convoy loader, Redis enqueue, poll loop, stall detector stub.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `ORCHESTRATOR_DRY_RUN=1` logs next role without worker
|
||||||
|
- [x] Live mode enqueues `role_dispatch` jobs when `REDIS_URL` is set
|
||||||
|
- [x] `docs/orchestrator.md` documents run commands
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: shared-orchestrator-driver
|
||||||
|
title: Shared planOrchestratorAction driver and job schemas
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-orchestrator
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-07-07"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Pure convoy driver in `@tasks/shared/orchestrator` with vitest coverage for planning, gates, and implementer dispatch.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Pauses at gate statuses
|
||||||
|
- [x] Serial planning roles respect `skip` flags
|
||||||
|
- [x] `orchestratorJobSchema` + `ORCHESTRATOR_QUEUE_KEY`
|
||||||
|
|
@ -167,7 +167,7 @@ flowchart LR
|
||||||
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
||||||
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
|
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
|
||||||
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | done | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
|
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | done | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
|
||||||
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | done | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
||||||
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |
|
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |
|
||||||
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 |
|
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 |
|
||||||
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 |
|
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 |
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,40 @@ importers:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/orchestrator:
|
||||||
|
dependencies:
|
||||||
|
'@tasks/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database
|
||||||
|
'@tasks/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.38.0
|
||||||
|
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.4.2
|
||||||
|
version: 5.10.1
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.0
|
||||||
|
version: 3.25.76
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.0
|
||||||
|
version: 22.19.15
|
||||||
|
tsup:
|
||||||
|
specifier: ^8.3.5
|
||||||
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.2
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.9
|
||||||
|
version: 2.1.9(@types/node@22.19.15)
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/openai':
|
'@ai-sdk/openai':
|
||||||
|
|
@ -8315,8 +8349,8 @@ snapshots:
|
||||||
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
eslint: 9.39.4(jiti@1.21.7)
|
eslint: 9.39.4(jiti@1.21.7)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7))
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7))
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
|
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
|
||||||
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7))
|
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7))
|
||||||
|
|
@ -8335,7 +8369,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -8346,22 +8380,22 @@ snapshots:
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
eslint: 9.39.4(jiti@1.21.7)
|
eslint: 9.39.4(jiti@1.21.7)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|
@ -8372,7 +8406,7 @@ snapshots:
|
||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.39.4(jiti@1.21.7)
|
eslint: 9.39.4(jiti@1.21.7)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue