Skip to content

Commit 94d3ec4

Browse files
tashfeenahmedclaude
andcommitted
Add profile editing, daily mission-driven goal planning, and a 3-state theme toggle
Settings' profile section was read-only despite the columns existing — add PATCH /users/me (name/handle/email with uniqueness checks) and POST /auth/change-password, plus the editable forms in Settings. The workspace mission previously only flavored agent prompts; now a daily repeatable job (mission sweep on the goal queue) reads each auto-planning workspace's mission, reviews existing projects/goals, and proposes up to MISSION_GOALS_PER_RUN new goals filed under best-fit projects — capped by MISSION_MAX_OPEN_GOALS so a busy board gets no new intent. Created goals flow through the existing auto-planner into tasks. The 24h repeat is kept across deploys (not removed/re-added) so restarts can't postpone it forever. Theme: the toggle offered only Light/Dark, so once clicked there was no way back to following the OS. Now Auto/Light/Dark, with a matchMedia listener that re-themes live on OS appearance changes while in Auto. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1c617df commit 94d3ec4

8 files changed

Lines changed: 499 additions & 42 deletions

File tree

api/src/agents/goal-planner-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { db } from "../db/index.js";
55
import { goals, tasks, workspaces, goalLedgers, members } from "../db/schema.js";
66
import { GOAL_QUEUE, type GoalPlanJob, enqueueGoalPlan } from "../lib/goal-queue.js";
77
import { planGoal } from "../lib/planner.js";
8+
import { runMissionPlanning } from "../lib/mission-planner.js";
89
import { bumpStall, assessProgress, writeProgressAssessment } from "../lib/ledger-core.js";
910
import { notify } from "../lib/notifications.js";
1011

@@ -288,6 +289,7 @@ export function startGoalPlanWorker(): Worker<GoalPlanJob> {
288289
GOAL_QUEUE,
289290
async (job) => {
290291
if (job.data.kind === "sweep") return handleSweep();
292+
if (job.data.kind === "mission") return runMissionPlanning();
291293
if (job.data.kind === "plan" && job.data.goalId) return handlePlan(job.data.goalId);
292294
},
293295
{ connection: redis, concurrency: 3 },

api/src/lib/goal-queue.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { Queue } from "bullmq";
22
import { redis } from "./redis.js";
33

4-
// Queue that drives automatic goal planning. Two job shapes:
4+
// Queue that drives automatic goal planning. Three job shapes:
55
// { kind: "plan", goalId, workspaceId } — decompose one goal (debounced on create)
66
// { kind: "sweep" } — periodic reconcile of all goals
7+
// { kind: "mission" } — daily mission → new-goal proposals
78
export const GOAL_QUEUE = "goal-plans";
89

910
export interface GoalPlanJob {
10-
kind: "plan" | "sweep";
11+
kind: "plan" | "sweep" | "mission";
1112
goalId?: string;
1213
workspaceId?: string;
1314
}
@@ -53,3 +54,28 @@ export async function scheduleGoalSweep(): Promise<void> {
5354
{ repeat: { every: SWEEP_EVERY_MS }, jobId: SWEEP_KEY },
5455
);
5556
}
57+
58+
const MISSION_KEY = "mission-sweep";
59+
const MISSION_EVERY_MS = Number(process.env.MISSION_SWEEP_EVERY_MS ?? 86_400_000); // daily
60+
61+
// Install the repeatable mission planner (daily by default). Called once at
62+
// worker boot. Unlike the 3-min sweeper, a 24h repeat must NOT be removed and
63+
// re-added on every boot — that resets the countdown, and frequent deploys
64+
// would postpone the daily run forever. Keep an existing repeat that already
65+
// matches the interval; replace only when the interval changed. (BullMQ fires
66+
// the first repeat one full interval after install — set
67+
// MISSION_SWEEP_EVERY_MS low to exercise it sooner.)
68+
export async function scheduleMissionSweep(): Promise<void> {
69+
let keep = false;
70+
for (const r of await goalQueue.getRepeatableJobs()) {
71+
if (r.name !== MISSION_KEY) continue;
72+
if (Number(r.every) === MISSION_EVERY_MS) keep = true;
73+
else await goalQueue.removeRepeatableByKey(r.key);
74+
}
75+
if (keep) return;
76+
await goalQueue.add(
77+
MISSION_KEY,
78+
{ kind: "mission" },
79+
{ repeat: { every: MISSION_EVERY_MS }, jobId: MISSION_KEY },
80+
);
81+
}

api/src/lib/mission-planner.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { and, eq, inArray } from "drizzle-orm";
2+
import { z } from "zod";
3+
import { db } from "../db/index.js";
4+
import { goals, workspaces, workspaceMembers, members } from "../db/schema.js";
5+
import { chatJson, plannerEnabled } from "./completion.js";
6+
import { createGoal } from "./goals-core.js";
7+
import { notify } from "./notifications.js";
8+
9+
// ─────────────────────────────────────────────────────────────────────────
10+
// The mission planner — a daily pass that turns the workspace MISSION into
11+
// fresh goals. Where the goal planner decomposes one goal into tasks, this
12+
// sits one tier up: it reads the mission, looks at the projects and goals
13+
// that already exist, and proposes the next few goals worth pursuing —
14+
// attached to the best-fit existing project. Created goals are ordinary
15+
// `open` goals, so the existing auto-planner immediately decomposes them
16+
// into tasks and the board starts moving.
17+
// ─────────────────────────────────────────────────────────────────────────
18+
19+
// New goals per workspace per run — deliberately small so a mission drips
20+
// steady work onto the board instead of flooding it.
21+
const GOALS_PER_RUN = Number(process.env.MISSION_GOALS_PER_RUN ?? 2);
22+
// Backpressure: skip a workspace that already has this many non-done goals.
23+
// The mission shouldn't pile new intent onto a board the team can't clear.
24+
const MAX_OPEN_GOALS = Number(process.env.MISSION_MAX_OPEN_GOALS ?? 12);
25+
26+
const ProposalSchema = z.object({
27+
goals: z
28+
.array(
29+
z.object({
30+
title: z.string().min(1).max(300),
31+
description: z.string().max(4000).optional().default(""),
32+
// Exact title of an EXISTING project to file the goal under ("" = none fits).
33+
project: z.string().max(300).optional().default(""),
34+
rationale: z.string().max(500).optional().default(""),
35+
}),
36+
)
37+
.max(10),
38+
});
39+
40+
function buildMessages(
41+
mission: string,
42+
projects: Array<{ title: string }>,
43+
existingTitles: string[],
44+
): Array<{ role: "system" | "user"; content: string }> {
45+
const system = [
46+
"You are the strategy manager for a workspace of AI agents and humans. Once a day you review the workspace MISSION and propose the next goals worth pursuing.",
47+
`Propose at most ${GOALS_PER_RUN} new goals. Quality over quantity — if the existing goals already cover the mission's next steps, return an empty list.`,
48+
"Each goal must be a concrete, finishable outcome that advances the mission (not a vague theme, not a task — a goal a small team completes in days).",
49+
"Never duplicate or trivially rephrase an EXISTING goal. Build on what exists: prefer the natural next step after the goals already there.",
50+
"If a PROJECT clearly covers the goal, set `project` to that project's exact title; otherwise leave it empty.",
51+
"Return ONLY a JSON object of this exact shape, no prose, no markdown fence:",
52+
'{"goals":[{"title":"...","description":"what done looks like","project":"exact project title or empty","rationale":"why this is the next move for the mission"}]}',
53+
].join("\n");
54+
55+
const user = [
56+
`WORKSPACE MISSION: ${mission}`,
57+
"",
58+
"PROJECTS:",
59+
projects.length ? projects.map((p) => `- ${p.title}`).join("\n") : "(none yet)",
60+
"",
61+
"EXISTING GOALS (do not duplicate):",
62+
existingTitles.length ? existingTitles.map((t) => `- ${t}`).join("\n") : "(none yet)",
63+
].join("\n");
64+
65+
return [
66+
{ role: "system", content: system },
67+
{ role: "user", content: user },
68+
];
69+
}
70+
71+
// The workspace's first human admin — the actor the daily goals are created
72+
// and owned by, so stall/plan-failure notifications have a human target.
73+
async function findAdminMember(workspaceId: string): Promise<string | null> {
74+
const admins = await db
75+
.select({ userId: workspaceMembers.userId })
76+
.from(workspaceMembers)
77+
.where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.role, "admin")));
78+
if (!admins.length) return null;
79+
const [m] = await db
80+
.select({ id: members.id })
81+
.from(members)
82+
.where(
83+
and(
84+
eq(members.workspaceId, workspaceId),
85+
eq(members.kind, "user"),
86+
inArray(
87+
members.refId,
88+
admins.map((a) => a.userId),
89+
),
90+
),
91+
)
92+
.limit(1);
93+
return m?.id ?? null;
94+
}
95+
96+
const norm = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " ");
97+
98+
// One workspace: mission → up to GOALS_PER_RUN new goals. Returns #created.
99+
async function planWorkspace(ws: { id: string; mission: string }): Promise<number> {
100+
const rows = await db
101+
.select({ id: goals.id, title: goals.title, kind: goals.kind, status: goals.status })
102+
.from(goals)
103+
.where(eq(goals.workspaceId, ws.id));
104+
105+
const live = rows.filter((g) => g.status !== "archived");
106+
const openCount = live.filter((g) => g.kind === "goal" && g.status !== "done").length;
107+
if (openCount >= MAX_OPEN_GOALS) {
108+
console.log(`[mission-planner] ${ws.id}: ${openCount} open goals ≥ cap ${MAX_OPEN_GOALS}, skipping`);
109+
return 0;
110+
}
111+
112+
const projects = live.filter((g) => g.kind === "project" && g.status !== "done");
113+
// Feed every non-archived title (done included) as dedupe context — a goal
114+
// finished last week shouldn't be re-proposed this week.
115+
const existingTitles = live.map((g) => g.title).slice(0, 200);
116+
117+
const raw = await chatJson<unknown>(buildMessages(ws.mission, projects, existingTitles), {
118+
temperature: 0.3,
119+
maxTokens: 2000,
120+
timeoutMs: 150_000,
121+
});
122+
const parsed = ProposalSchema.safeParse(raw);
123+
if (!parsed.success) {
124+
console.error(
125+
`[mission-planner] proposal failed for ${ws.id}: ` +
126+
(raw === null ? "chatJson returned null" : `schema rejected: ${JSON.stringify(raw).slice(0, 200)}`),
127+
);
128+
return 0;
129+
}
130+
131+
const actor = await findAdminMember(ws.id);
132+
if (!actor) {
133+
console.error(`[mission-planner] ${ws.id}: no human admin member, skipping`);
134+
return 0;
135+
}
136+
137+
const seen = new Set(live.map((g) => norm(g.title)));
138+
const projectByTitle = new Map(projects.map((p) => [norm(p.title), p]));
139+
const createdTitles: string[] = [];
140+
for (const p of parsed.data.goals.slice(0, GOALS_PER_RUN)) {
141+
if (seen.has(norm(p.title))) continue; // model ignored the dedupe instruction
142+
const project = projectByTitle.get(norm(p.project));
143+
const body = [p.description, p.rationale ? `_Why now: ${p.rationale}_` : ""]
144+
.filter(Boolean)
145+
.join("\n\n");
146+
const r = await createGoal(
147+
{ title: p.title, bodyMd: body, parentGoalId: project?.id ?? null, kind: "goal" },
148+
actor,
149+
ws.id,
150+
);
151+
if ("error" in r) {
152+
console.error(`[mission-planner] create failed for "${p.title}": ${r.error}`);
153+
continue;
154+
}
155+
seen.add(norm(p.title));
156+
createdTitles.push(p.title + (project ? ` (→ ${project.title})` : ""));
157+
}
158+
159+
if (createdTitles.length) {
160+
await notify({
161+
workspaceId: ws.id,
162+
memberId: actor,
163+
kind: "system",
164+
title: `Daily planning added ${createdTitles.length} goal${createdTitles.length > 1 ? "s" : ""} from your mission`,
165+
body: createdTitles.join(" · "),
166+
link: `/goals`,
167+
}).catch(() => {});
168+
}
169+
return createdTitles.length;
170+
}
171+
172+
// Entry point for the repeatable "mission" job: every auto-planning workspace
173+
// with a non-empty mission gets its daily goal proposals.
174+
export async function runMissionPlanning(): Promise<void> {
175+
if (!plannerEnabled()) return;
176+
const wss = await db
177+
.select({ id: workspaces.id, mission: workspaces.mission })
178+
.from(workspaces)
179+
.where(eq(workspaces.autoPlan, "auto"));
180+
let total = 0;
181+
for (const ws of wss) {
182+
if (!ws.mission.trim()) continue;
183+
total += await planWorkspace(ws).catch((e) => {
184+
console.error(`[mission-planner] workspace ${ws.id} failed`, e);
185+
return 0;
186+
});
187+
}
188+
console.log(`[mission-planner] run complete: ${total} goal(s) created across ${wss.length} workspace(s)`);
189+
}

api/src/routes/auth.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ const LoginBody = z.object({
4747

4848
const InviteBody = z.object({ email: z.string().email() });
4949

50+
const UpdateMeBody = z.object({
51+
name: z.string().min(1).max(100).optional(),
52+
handle: z
53+
.string()
54+
.min(2)
55+
.max(40)
56+
.regex(/^[a-z0-9][a-z0-9._-]*$/i)
57+
.optional(),
58+
email: z.string().email().max(255).optional(),
59+
});
60+
61+
const ChangePasswordBody = z.object({
62+
currentPassword: z.string().min(1),
63+
newPassword: z.string().min(8),
64+
});
65+
5066
const AcceptInviteBody = z.object({
5167
token: z.string().min(10),
5268
name: z.string().min(1).max(100),
@@ -209,6 +225,52 @@ export default async function authRoutes(app: FastifyInstance): Promise<void> {
209225
};
210226
});
211227

228+
// ─────────── profile: edit the caller's own account ──────────
229+
app.patch("/users/me", { preHandler: requireAuth }, async (req, reply) => {
230+
const body = UpdateMeBody.parse(req.body);
231+
const { user } = req.auth!;
232+
233+
// Uniqueness checks only when the value actually changes, so a no-op save
234+
// can't 409 against the caller's own row.
235+
if (body.email && body.email !== user.email) {
236+
const [exists] = await db
237+
.select({ id: users.id })
238+
.from(users)
239+
.where(eq(users.email, body.email))
240+
.limit(1);
241+
if (exists) return reply.code(409).send({ error: "email_in_use" });
242+
}
243+
if (body.handle && body.handle !== user.handle) {
244+
const [exists] = await db
245+
.select({ id: users.id })
246+
.from(users)
247+
.where(eq(users.handle, body.handle))
248+
.limit(1);
249+
if (exists) return reply.code(409).send({ error: "handle_in_use" });
250+
}
251+
252+
const patch: Partial<typeof users.$inferInsert> = {};
253+
if (body.name !== undefined) patch.name = body.name;
254+
if (body.handle !== undefined) patch.handle = body.handle;
255+
if (body.email !== undefined) patch.email = body.email;
256+
if (Object.keys(patch).length) {
257+
await db.update(users).set(patch).where(eq(users.id, user.id));
258+
}
259+
const [u] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
260+
return { ok: true, user: publicUser(u) };
261+
});
262+
263+
app.post("/auth/change-password", { preHandler: requireAuth }, async (req, reply) => {
264+
const body = ChangePasswordBody.parse(req.body);
265+
const { user } = req.auth!;
266+
const [u] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
267+
if (!u || !(await verifyPassword(body.currentPassword, u.passwordHash)))
268+
return reply.code(401).send({ error: "wrong_password" });
269+
const passwordHash = await hashPassword(body.newPassword);
270+
await db.update(users).set({ passwordHash }).where(eq(users.id, user.id));
271+
return { ok: true };
272+
});
273+
212274
// ─────────── invites: always scoped to the caller's current workspace ──
213275
app.post("/auth/invite", { preHandler: requireAuth }, async (req, reply) => {
214276
const body = InviteBody.parse(req.body);

api/src/worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { materialiseScheduledRun, cancelAgentHeartbeat } from "./agents/schedule
2020
import { publishToConversation, publishGlobal } from "./lib/events.js";
2121
import { exportRunTrace } from "./lib/tracing.js";
2222
import { startGoalPlanWorker } from "./agents/goal-planner-worker.js";
23-
import { scheduleGoalSweep } from "./lib/goal-queue.js";
23+
import { scheduleGoalSweep, scheduleMissionSweep } from "./lib/goal-queue.js";
2424

2525
const worker = new Worker<AgentJobPayload>(
2626
AGENT_QUEUE,
@@ -309,3 +309,6 @@ console.log(`[worker] circlechat agent-runs worker up, concurrency=10`);
309309
// plus a repeatable sweeper that reconciles unplanned/stuck goals.
310310
startGoalPlanWorker();
311311
scheduleGoalSweep().catch((e) => console.error("[goal-planner] sweep schedule failed", e));
312+
// Daily mission → goals pass: proposes the next goals from the workspace
313+
// mission and files them under projects (see lib/mission-planner.ts).
314+
scheduleMissionSweep().catch((e) => console.error("[mission-planner] schedule failed", e));

0 commit comments

Comments
 (0)