Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof schema>;
Expand Down
45 changes: 45 additions & 0 deletions src/projectChat.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}): 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 };
}
9 changes: 9 additions & 0 deletions src/tools/createTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export const createTask: Tool<typeof InputSchema> = {
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).
Expand Down
29 changes: 18 additions & 11 deletions src/tools/postProjectMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,15 +46,23 @@ export const postProjectMessage: Tool<typeof InputSchema> = {
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 "<kind>-<wallet>" / "agent:<name>" 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 } };
},
};
49 changes: 44 additions & 5 deletions src/tools/proposePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,14 +62,28 @@ export const proposePlan: Tool<typeof InputSchema> = {
};
}

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({
Expand All @@ -78,6 +93,24 @@ export const proposePlan: Tool<typeof InputSchema> = {
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",
Expand All @@ -90,7 +123,13 @@ export const proposePlan: Tool<typeof InputSchema> = {

return {
ok: true,
data: { docId: refs.docId, status: "plan_proposed", groups, tasks },
data: {
docId: refs.docId,
status: "plan_proposed",
groups,
tasks,
chatDelivered,
},
};
},
};
Loading