From 79073edce5e67de92482abab493a44dea84b1803 Mon Sep 17 00:00:00 2001 From: JulioMCruz Date: Fri, 17 Jul 2026 11:03:05 -0400 Subject: [PATCH] Gate tasks behind plan approval --- .env.example | 4 +++ src/config.ts | 4 +++ src/projectChat.ts | 45 ++++++++++++++++++++++++++++++ src/tools/createTask.ts | 9 ++++++ src/tools/postProjectMessage.ts | 29 +++++++++++-------- src/tools/proposePlan.ts | 49 +++++++++++++++++++++++++++++---- 6 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 src/projectChat.ts diff --git a/.env.example b/.env.example index 3c377be..77d17d8 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,7 @@ AUDIT_ENABLED=true # (never anonymous). Generate with: openssl rand -hex 32 # Should match PERKOS_METRICS_TOKEN on the Grafana Alloy scraper. PERKOS_METRICS_TOKEN= + +# ---- Project chat service ingress ---- +PERKOS_CHAT_INTERNAL_URL=http://perkos-chat:6070 +CHAT_INTERNAL_API_KEY= diff --git a/src/config.ts b/src/config.ts index 64fbcd7..a145301 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,10 @@ const schema = z.object({ // Bearer token gating /metrics. When unset, /metrics returns 503 — // never anonymous. Generate with `openssl rand -hex 32`. PERKOS_METRICS_TOKEN: z.string().optional(), + + // --- Project chat service ingress --- + PERKOS_CHAT_INTERNAL_URL: z.string().url().default("http://perkos-chat:6070"), + CHAT_INTERNAL_API_KEY: z.string().optional(), }); export type Config = z.infer; diff --git a/src/projectChat.ts b/src/projectChat.ts new file mode 100644 index 0000000..8d06722 --- /dev/null +++ b/src/projectChat.ts @@ -0,0 +1,45 @@ +import { config } from "./config.js"; + +function senderIdentity(value?: string): string { + if (!value) return "agent:unknown"; + if (value.startsWith("agent:") || value.startsWith("service:")) return value; + return `agent:${value}`; +} + +export async function postProjectChat(input: { + wallet: string; + projectId: string; + convId?: string; + sender?: string; + text: string; + targets?: string[]; + event?: Record; +}): Promise<{ id: string; delivered: number }> { + if (!config.CHAT_INTERNAL_API_KEY) { + throw new Error("CHAT_INTERNAL_API_KEY is not configured"); + } + const response = await fetch(`${config.PERKOS_CHAT_INTERNAL_URL}/internal/messages`, { + method: "POST", + headers: { + authorization: `Bearer ${config.CHAT_INTERNAL_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + walletAddress: input.wallet, + convId: input.convId ?? `project-${input.projectId}`, + from: senderIdentity(input.sender), + text: input.text, + targets: input.targets, + event: input.event, + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + id?: string; + delivered?: number; + error?: { message?: string }; + }; + if (!response.ok || !payload.id) { + throw new Error(payload.error?.message ?? `PerkOS-Chat returned ${response.status}`); + } + return { id: payload.id, delivered: payload.delivered ?? 0 }; +} diff --git a/src/tools/createTask.ts b/src/tools/createTask.ts index ff1ab49..c0b7f2f 100644 --- a/src/tools/createTask.ts +++ b/src/tools/createTask.ts @@ -74,6 +74,15 @@ export const createTask: Tool = { message: `No project "${args.projectId}" for this wallet.`, }; } + const workflow = project.data()?.workflow as { phase?: string } | undefined; + if (workflow?.phase === "planning" || workflow?.phase === "awaiting_approval") { + return { + ok: false, + errorClass: "FORBIDDEN", + message: + "This project plan has not been approved. Add planTask blocks and call proposePlan; createTask is enabled after user approval.", + }; + } // Validate parents exist in THIS project (prevents typo'd dependency ids // from blocking a task forever). diff --git a/src/tools/postProjectMessage.ts b/src/tools/postProjectMessage.ts index 570ca0b..9048c0b 100644 --- a/src/tools/postProjectMessage.ts +++ b/src/tools/postProjectMessage.ts @@ -3,16 +3,15 @@ * * Posts a message into a project's chat — how a worker notifies the PM * ("task X done") or the PM broadcasts to the team. Shows up live in the - * app's project Chat tab (useProjectMessages onSnapshot). Wallet from + * app's project Chat tab through PerkOS-Chat. Wallet from * JWT; project must belong to the calling wallet. `from` is recorded as * "agent" with the caller's conv-derived identity. */ import { z } from "zod"; -import { FieldValue } from "firebase-admin/firestore"; - import { db } from "../firestore.js"; +import { postProjectChat } from "../projectChat.js"; import type { Tool } from "./types.js"; const InputSchema = z @@ -47,15 +46,23 @@ export const postProjectMessage: Tool = { message: `No project "${args.projectId}" for this wallet.`, }; } - const msgRef = projectRef.collection("messages").doc(); - await msgRef.set({ - from: "agent", + const message = await postProjectChat({ + wallet: ctx.wallet, + projectId: args.projectId, + convId: (project.data()?.chatConvId as string | undefined) ?? undefined, + sender: ctx.convId, text: args.text, - // convId is "-" / "agent:" style; surface a - // best-effort sender label for the chat UI. - agentName: ctx.convId ?? "agent", - createdAt: FieldValue.serverTimestamp(), + targets: Array.from( + new Set( + [ + `user:${ctx.wallet}`, + project.data()?.pmAgent + ? `agent:${String(project.data()?.pmAgent)}` + : null, + ].filter((identity): identity is string => Boolean(identity)), + ), + ), }); - return { ok: true, data: { messageId: msgRef.id } }; + return { ok: true, data: { messageId: message.id, delivered: message.delivered } }; }, }; diff --git a/src/tools/proposePlan.ts b/src/tools/proposePlan.ts index aca7c53..65c921e 100644 --- a/src/tools/proposePlan.ts +++ b/src/tools/proposePlan.ts @@ -14,6 +14,7 @@ import { FieldValue } from "firebase-admin/firestore"; import { docIdSchema, ensureDoc, projectIdSchema } from "./docShared.js"; import { logActivity } from "../activityEvents.js"; +import { postProjectChat } from "../projectChat.js"; import type { Tool } from "./types.js"; const InputSchema = z @@ -61,14 +62,28 @@ export const proposePlan: Tool = { }; } - await docRef.set( + const now = FieldValue.serverTimestamp(); + const projectRef = docRef.parent.parent!; + const batch = projectRef.firestore.batch(); + batch.set( + docRef, + { status: "plan_proposed", revision: FieldValue.increment(1), updatedAt: now }, + { merge: true }, + ); + batch.set( + projectRef, { - status: "plan_proposed", - revision: FieldValue.increment(1), - updatedAt: FieldValue.serverTimestamp(), + workflow: { + phase: "awaiting_approval", + planId: refs.docId, + taskIds: [], + updatedAt: now, + }, + updatedAt: now, }, { merge: true }, ); + await batch.commit(); // Notify the team in THIS doc's discussion. await docRef.collection("messages").doc().set({ @@ -78,6 +93,24 @@ export const proposePlan: Tool = { createdAt: FieldValue.serverTimestamp(), }); + const chatDelivered = await postProjectChat({ + wallet: ctx.wallet, + projectId: args.projectId, + convId: ((await projectRef.get()).data()?.chatConvId as string | undefined) ?? undefined, + sender: ctx.convId, + targets: [`user:${ctx.wallet}`], + text: `Plan proposed with ${tasks} task${tasks === 1 ? "" : "s"}. Review the plan and approve it before work starts.`, + event: { + domain: "project_workflow", + type: "plan_proposed", + projectId: args.projectId, + phase: "awaiting_approval", + planId: refs.docId, + actor: ctx.convId ?? "agent:unknown", + data: { groups, tasks }, + }, + }).then(() => true).catch(() => false); + // Activity feed + the dashboard's "Waiting on you" queue. logActivity(ctx.wallet, { actorType: "agent", @@ -90,7 +123,13 @@ export const proposePlan: Tool = { return { ok: true, - data: { docId: refs.docId, status: "plan_proposed", groups, tasks }, + data: { + docId: refs.docId, + status: "plan_proposed", + groups, + tasks, + chatDelivered, + }, }; }, };