feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rank-Yu
2026-07-27 23:43:24 +08:00
committed by GitHub
parent e1141ca010
commit 46463bee26
61 changed files with 3411 additions and 188 deletions
+40 -1
View File
@@ -625,6 +625,29 @@ export interface TaskCreateRequest {
* (in queue order, one at a time). The response then carries `queued: true`.
*/
queueIfBusy?: boolean;
/**
* Present = goal mode: the input's text becomes the objective (leading `[use_skills]`
* blocks and the like are stripped from the recorded objective; the round-1 message keeps
* them) and the server loops the Session until the goal reaches a terminal state.
* `budget` is the token budget (uncached input + output); omitted or -1 = unlimited.
*/
goal?: { budget?: number };
}
/** Goal-mode run state (from goal_state; the chat page's banner restores from the latest row). */
export interface GoalStateView {
objective: string;
status: "active" | "complete" | "blocked" | "budget_limited" | "aborted";
/** Token budget; -1 = unlimited. */
budget: number;
used: number;
rounds: number;
updatedAt: string;
}
export interface GoalResponse {
/** The Session's most recent goal run; null if it never ran one. */
goal: GoalStateView | null;
}
export interface TaskCreateResponse {
@@ -694,7 +717,23 @@ export type ServerEvent =
sessionId: string;
source: SessionSource;
}
| ScheduleServerEvent;
| ScheduleServerEvent
| GoalServerEvent;
/** Goal-mode progress on the session channel (the chat page drives its goal banner from these). */
export type GoalServerEvent =
/** A goal run began (published before the first round). */
| { type: "goal_started"; sessionId: string; objective: string; budget: number }
/** A round is starting; `used` is the runner's accounting up to this point. */
| { type: "goal_round"; sessionId: string; round: number; used: number; budget: number }
/** The goal reached a terminal state. */
| {
type: "goal_finished";
sessionId: string;
outcome: "complete" | "blocked" | "budget_limited" | "aborted";
rounds: number;
used: number;
};
/** Schedule notification (user-level event stream; firing and delivery are notified via /api/events). */
export type ScheduleServerEvent =
+8
View File
@@ -19,6 +19,7 @@ import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
import { ErrorsRepo } from "./db/repos/errors.js";
import { MembersRepo } from "./db/repos/members.js";
import { ProjectsRepo } from "./db/repos/projects.js";
import { GoalsRepo } from "./db/repos/goals.js";
import { SchedulesRepo } from "./db/repos/schedules.js";
import { SessionsRepo } from "./db/repos/sessions.js";
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
@@ -103,6 +104,8 @@ export interface AppDeps {
benchmarks: BenchmarkService;
snapshots: SnapshotService;
schedulesRepo: SchedulesRepo;
goalsRepo: GoalsRepo;
errorsRepo: ErrorsRepo;
scheduler: Scheduler;
channels: ChannelHub;
manager: SessionManager;
@@ -140,6 +143,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const errorsRepo = new ErrorsRepo(db);
const prefsRepo = new UiPrefsRepo(db);
const schedulesRepo = new SchedulesRepo(db);
const goalsRepo = new GoalsRepo(db);
const projectConfigService = new ProjectConfigService(config.root);
const agentConfigService = new AgentConfigService(config.root);
@@ -186,6 +190,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
errors,
titles,
log,
goals: goalsRepo,
});
managerRef = manager;
@@ -199,6 +204,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
usage: usageRepo,
errors: errorsRepo,
schedules: schedulesRepo,
goals: goalsRepo,
projectConfig: projectConfigService,
manager,
});
@@ -261,6 +267,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
benchmarks,
snapshots,
schedulesRepo,
goalsRepo,
errorsRepo,
scheduler,
channels,
manager,
+7
View File
@@ -212,6 +212,13 @@ export class ErrorsRepo {
}));
}
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
deleteByAgent(projectId: string, agentId: string): void {
this.db
.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?")
.run(projectId, agentId);
}
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
+116
View File
@@ -0,0 +1,116 @@
/**
* Repo for goal-mode runtime state: one row per goal run, keyed by autoincrement id (a
* Session may run goals repeatedly; the latest row is what the UI shows). The on-disk
* GOAL.yaml is the model-facing protocol; this table is the server-side record the banner
* restores from and the terminal outcome lands in (including the server-only `aborted`,
* which never appears in the file — the on-disk status stays `active` for resuming).
*/
import type { DatabaseSync } from "node:sqlite";
export type GoalRowStatus = "active" | "complete" | "blocked" | "budget_limited" | "aborted";
export interface GoalStateRow {
id: number;
sessionId: string;
projectId: string;
agentId: string;
objective: string;
status: GoalRowStatus;
budget: number;
used: number;
rounds: number;
createdAt: string;
updatedAt: string;
}
function mapRow(r: Record<string, unknown>): GoalStateRow {
return {
id: Number(r.id),
sessionId: r.session_id as string,
projectId: r.project_id as string,
agentId: r.agent_id as string,
objective: r.objective as string,
status: r.status as GoalRowStatus,
budget: Number(r.budget),
used: Number(r.used),
rounds: Number(r.rounds),
createdAt: r.created_at as string,
updatedAt: r.updated_at as string,
};
}
export class GoalsRepo {
constructor(private readonly db: DatabaseSync) {}
/** Register a new goal run (status active, counters at zero); returns the new row id. */
create(args: {
sessionId: string;
projectId: string;
agentId: string;
objective: string;
budget: number;
}): number {
const now = new Date().toISOString();
const result = this.db
.prepare(
`INSERT INTO goal_state
(session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`,
)
.run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
return Number(result.lastInsertRowid);
}
/** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
progress(id: number, rounds: number, used: number): void {
this.db
.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?")
.run(rounds, used, new Date().toISOString(), id);
}
/** Record the terminal outcome. */
finish(id: number, status: GoalRowStatus, rounds: number, used: number): void {
this.db
.prepare(
"UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?",
)
.run(status, rounds, used, new Date().toISOString(), id);
}
/** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
latestForSession(sessionId: string): GoalStateRow | null {
const r = this.db
.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1")
.get(sessionId);
return r ? mapRow(r as Record<string, unknown>) : null;
}
deleteBySession(sessionId: string): void {
this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
}
deleteByAgent(projectId: string, agentId: string): void {
this.db
.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?")
.run(projectId, agentId);
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
}
/**
* Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
* (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
* would then restore a forever-"running" goal. Called once at boot, before the server accepts
* connections (nothing can be running yet), so every remaining `active` row is an orphan:
* mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
* Returns the number of rows reconciled.
*/
abortOrphanedActive(): number {
const result = this.db
.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'")
.run(new Date().toISOString());
return Number(result.changes);
}
}
@@ -185,6 +185,12 @@ export class SchedulesRepo {
return removed;
}
deleteByAgent(projectId: string, agentId: string): void {
this.db
.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?")
.run(projectId, agentId);
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
}
+14
View File
@@ -96,6 +96,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
PRIMARY KEY (project_id, agent_id, name)
);
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
session_id TEXT NOT NULL,
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
objective TEXT NOT NULL,
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
rounds INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
CREATE TABLE IF NOT EXISTS ui_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
@@ -76,6 +76,12 @@ export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
await settleWithin(runnings, 5000);
await deps.agentService.deleteAgent(projectId, agentId);
deps.sessionsRepo.deleteByAgent(projectId, agentId);
// Per-Agent runtime state keyed on the now-removed sessions/agent: drop it so nothing is
// orphaned (session ids are never reused, so a leftover row is dead weight). Usage records
// are deliberately kept — historical stats survive Agent deletion (see deleteAgent).
deps.goalsRepo.deleteByAgent(projectId, agentId);
deps.schedulesRepo.deleteByAgent(projectId, agentId);
deps.errorsRepo.deleteByAgent(projectId, agentId);
} finally {
deps.manager.endAgentDeletion(projectId, agentId);
}
+65 -2
View File
@@ -15,6 +15,7 @@ import type { OmniMessage, ThinkingLevelName } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
FilesStatResponse,
GoalResponse,
MessagesLiveTail,
MessagesResponse,
ServerEvent,
@@ -102,6 +103,28 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
});
}
/**
* Validate the optional `goal` field of a task request: absent = a regular task (null);
* present = goal mode with a token budget (a positive integer, or -1/omitted = unlimited).
* The input text is the objective — skills ride the text itself as a `[use_skills]` block,
* exactly like a regular task's message.
*/
function parseGoalField(body: Record<string, unknown>): { budget: number } | null {
const goal = body.goal;
if (goal === undefined) return null;
if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
throw badRequest("goal must be an object.");
}
const budget = (goal as Record<string, unknown>).budget;
if (
budget !== undefined &&
(typeof budget !== "number" || !Number.isInteger(budget) || (budget <= 0 && budget !== -1))
) {
throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
}
return { budget: (budget as number | undefined) ?? -1 };
}
/** Agent-level entry: /api/projects/:p/agents/:a/sessions. */
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
@@ -290,6 +313,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
{ recursive: true, force: true },
);
deps.sessionsRepo.deleteById(row.sessionId);
deps.goalsRepo.deleteBySession(row.sessionId);
// Drop the derived-origin entry along with the Session (bulk Agent/Project deletion
// may leave stale entries; session ids are never reused, so they are never matched).
deps.sessionSources.delete(row.sessionId);
@@ -389,10 +413,31 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
app.post("/:sessionId/tasks", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
const input = parseTaskInput(body);
const goal = parseGoalField(body);
// Per-turn thinking level (optional): validated against the five names; omitted follows
// the session's default. A queued follow-up keeps its level for its auto-start.
// the session's default. In goal mode it rides every round of the goal; a queued
// follow-up keeps its level for its auto-start.
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
if (goal) {
// Goal mode: the input must be plain non-empty text (its marker-stripped text becomes
// the objective, re-injected every round — images have no place in the protocol).
const input = parseTaskInput(body);
const text = input
.filter((m) => (m.payload as { type?: string }).type === "text")
.map((m) => (m.payload as { text: string }).text)
.join("\n")
.trim();
if (!text || input.some((m) => (m.payload as { type?: string }).type !== "text")) {
throw badRequest("goal mode requires text-only input (the objective).");
}
const { sessionId } = await deps.manager.startGoal(row.sessionId, {
input,
budget: goal.budget,
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
});
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
}
const input = parseTaskInput(body);
// Follow-up queue: with queueIfBusy, a busy session enqueues the input instead of 409
// (auto-starts as an ordinary next task once idle; the response says which happened).
const queueIfBusy = body.queueIfBusy === true;
@@ -417,6 +462,24 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
return c.body(null, 202);
});
// The Session's most recent goal run (for restoring the chat page's goal banner on load).
app.get("/:sessionId/goal", (c) => {
const row = resolveSession(c);
const g = deps.goalsRepo.latestForSession(row.sessionId);
return c.json({
goal: g
? {
objective: g.objective,
status: g.status,
budget: g.budget,
used: g.used,
rounds: g.rounds,
updatedAt: g.updatedAt,
}
: null,
} satisfies GoalResponse);
});
app.post("/:sessionId/approvals/:toolCallId", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
+6
View File
@@ -27,6 +27,12 @@ await deps.authService.seedAdmin();
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
await deps.scheduler.start();
// Goal mode runs only in SessionManager memory: a hard crash (SIGKILL, power loss) can leave
// goal_state rows stuck `active` with no runner behind them. Reconcile them to `aborted` now —
// nothing is running yet, so any `active` row is a crash orphan — so the chat banner never
// restores a phantom "running" goal. GOAL.yaml on disk stays `active` as the resume point.
deps.goalsRepo.abortOrphanedActive();
// On a loopback bind the App is canonicalized onto one name (`localhost`) and its
// counterpart is reserved for previews, so advertise the canonical name — the other one
// only 302s back here for App routes (see the canonical-host guard in app.ts).
+185 -1
View File
@@ -30,7 +30,11 @@ import path from "node:path";
import {
createAgent,
findLatestTraceFile,
goalFinishedOf,
goalTokenDelta,
isGoalRoundInput,
isSessionMeta,
stripLeadingMarkerBlocks,
tracesDir,
} from "@prismshadow/penguin-core";
import type {
@@ -44,6 +48,7 @@ import type {
} from "@prismshadow/penguin-core";
import type { ServerEvent, SessionStatus } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import type { GoalsRepo } from "../db/repos/goals.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
import { ApprovalRegistry, makeApprove } from "./approvals.js";
import type { PendingApproval } from "./approvals.js";
@@ -72,7 +77,13 @@ export interface RuntimeSession {
readonly sessionId: string;
run(
newMessages: OmniMessage[],
opts: { approve: ApproveFn; signal: AbortSignal; thinkingLevel?: ThinkingLevelName },
opts: {
approve: ApproveFn;
signal: AbortSignal;
thinkingLevel?: ThinkingLevelName;
/** Present = goal mode: core loops rounds inside this one run (see core SessionRunOptions). */
goal?: { budget?: number };
},
): AsyncGenerator<OmniMessage>;
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
@@ -192,6 +203,8 @@ export interface SessionManagerDeps {
/** Error persistence (optional: without it, only logs — same as before this was wired up). */
errors?: ErrorSink;
log?: (line: string) => void;
/** Goal run-state persistence (optional like `titles`: without it, goals run but leave no restorable record). */
goals?: GoalsRepo;
}
/** One queued follow-up task (`queueIfBusy`): the task input plus the per-turn thinking level it was posted with. */
@@ -467,6 +480,177 @@ export class SessionManager {
});
}
/**
* Start a goal run: like startTask, but the run is core goal mode — one
* `session.run(input, { goal })` call loops rounds until a terminal state, and the
* Session stays `running` for the whole goal (every round), so the existing abort
* endpoint interrupts the entire loop and schedules queue behind it as usual. Round
* inputs are yielded by core and published like any streamed message; progress
* additionally goes out as goal_* server events and into goal_state (when a repo is
* wired).
*/
async startGoal(
sessionId: string,
args: {
/** Round-1 input (text-only, route-validated); its marker-stripped text is the objective. */
input: OmniMessage[];
budget: number;
/** Optional per-goal thinking level: rides every round's Task (route-validated). */
thinkingLevel?: ThinkingLevelName;
},
): Promise<{ sessionId: string }> {
return this.withLock(sessionId, async () => {
this.assertOpen();
this.assertAgentNotDeleting(sessionId);
this.assertSessionNotDeleting(sessionId);
const entry = await this.ensureEntry(sessionId);
this.assertIdle(entry);
// The objective is the user's own text (leading skill-invocation blocks stripped) —
// the same derivation core records in GOAL.yaml; used for the run-state row, the
// goal_started event, and as title material.
const text = args.input
.filter(isPlainText("user"))
.map((m) => m.payload.text)
.join("\n");
const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
const ac = new AbortController();
entry.status = "running";
entry.abort = ac;
entry.lastActivityMs = Date.now();
this.publishState(entry, "running");
const approve = makeApprove({
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
toolPermission: (name) => entry.session.toolPermission(name),
registry: entry.approvals,
publishRequest: (pending) =>
this.publishEvent(entry, {
type: "approval_request",
toolCall: pending.toolCall,
...(pending.origin !== undefined ? { origin: pending.origin } : {}),
}),
});
const goalId = this.deps.goals?.create({
sessionId: entry.sessionId,
projectId: entry.projectId,
agentId: entry.agentId,
objective,
budget: args.budget,
});
this.publishEvent(entry, {
type: "goal_started",
sessionId: entry.sessionId,
objective,
budget: args.budget,
});
const gen = this.goalStream(entry, {
input: args.input,
budget: args.budget,
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
approve,
signal: ac.signal,
...(goalId !== undefined ? { goalId } : {}),
});
// The objective doubles as the title material (same role as a task's input text).
entry.running = this.drive(entry, gen, { userExcerpt: objective });
return { sessionId: entry.sessionId };
});
}
/**
* Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
* inputs) become goal_round events + goal_state refreshes, and the terminal
* `goal_finished` event message becomes the goal_finished server event + the run-state
* row's final status. Token numbers mirror core's own accounting (same
* `goalTokenDelta`), so the UI shows exactly what the budget check uses.
*/
private async *goalStream(
entry: RuntimeEntry,
args: {
input: OmniMessage[];
budget: number;
thinkingLevel?: ThinkingLevelName;
approve: ApproveFn;
signal: AbortSignal;
goalId?: number;
},
): AsyncGenerator<OmniMessage> {
const gen = entry.session.run(args.input, {
approve: args.approve,
signal: args.signal,
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
goal: { budget: args.budget },
});
let round = 0;
let used = 0;
let finished = false;
try {
for await (const msg of gen) {
used += goalTokenDelta(msg);
if (isGoalRoundInput(msg)) {
round++;
if (args.goalId !== undefined) this.deps.goals?.progress(args.goalId, round, used);
this.publishEvent(entry, {
type: "goal_round",
sessionId: entry.sessionId,
round,
used,
budget: args.budget,
});
}
const outcome = goalFinishedOf(msg);
if (outcome) {
finished = true;
if (args.goalId !== undefined) {
this.deps.goals?.finish(
args.goalId,
outcome.outcome,
outcome.rounds,
outcome.tokensUsed,
);
}
this.publishEvent(entry, {
type: "goal_finished",
sessionId: entry.sessionId,
outcome: outcome.outcome,
rounds: outcome.rounds,
used: outcome.tokensUsed,
});
}
yield msg;
}
if (!finished) {
// Defensive: core always ends a goal stream with goal_finished; a stream that
// didn't is a cut-off run — close the row so the UI never shows a forever-active
// goal.
this.finishAborted(entry, args.goalId, round, used);
}
} catch (err) {
// Core throws only on infrastructure failures (e.g. GOAL.yaml writes): close the
// run state as aborted, then let drive's defensive catch record the error. Guarded on
// `finished`: a throw after the terminal event must not overwrite the row's real
// outcome (repo.finish is an unconditional UPDATE) or publish a contradicting event.
if (!finished) this.finishAborted(entry, args.goalId, round, used);
throw err;
}
}
/** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
private finishAborted(
entry: RuntimeEntry,
goalId: number | undefined,
round: number,
used: number,
): void {
if (goalId !== undefined) this.deps.goals?.finish(goalId, "aborted", round, used);
this.publishEvent(entry, {
type: "goal_finished",
sessionId: entry.sessionId,
outcome: "aborted",
rounds: round,
used,
});
}
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
private launchTask(
entry: RuntimeEntry,
@@ -17,6 +17,7 @@ import { HttpError } from "../http/errors.js";
import type { AgentsRepo } from "../db/repos/agents.js";
import type { ErrorsRepo } from "../db/repos/errors.js";
import type { MembersRepo } from "../db/repos/members.js";
import type { GoalsRepo } from "../db/repos/goals.js";
import type { ProjectRow, ProjectsRepo } from "../db/repos/projects.js";
import type { SessionsRepo } from "../db/repos/sessions.js";
import type { SchedulesRepo } from "../db/repos/schedules.js";
@@ -53,6 +54,7 @@ export interface ProjectServiceDeps {
usage: UsageRepo;
errors: ErrorsRepo;
schedules: SchedulesRepo;
goals: GoalsRepo;
projectConfig: ProjectConfigService;
manager: SessionManager;
}
@@ -290,6 +292,7 @@ export class ProjectService {
this.deps.usage.deleteByProject(projectId);
this.deps.errors.deleteByProject(projectId);
this.deps.schedules.deleteByProject(projectId);
this.deps.goals.deleteByProject(projectId);
await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
}