bopodev-api 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/app.ts +11 -1
- package/src/http.ts +39 -0
- package/src/lib/comment-recipients.ts +105 -0
- package/src/lib/hiring-delegate.ts +7 -6
- package/src/lib/instance-paths.ts +11 -0
- package/src/realtime/attention.ts +47 -0
- package/src/realtime/governance.ts +11 -3
- package/src/realtime/heartbeat-runs.ts +33 -11
- package/src/realtime/hub.ts +34 -2
- package/src/realtime/office-space.ts +17 -1
- package/src/routes/agents.ts +81 -12
- package/src/routes/attention.ts +112 -0
- package/src/routes/companies.ts +13 -5
- package/src/routes/goals.ts +10 -2
- package/src/routes/governance.ts +5 -0
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +62 -1
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +3 -1
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +391 -0
- package/src/services/budget-service.ts +99 -2
- package/src/services/comment-recipient-dispatch-service.ts +158 -0
- package/src/services/governance-service.ts +233 -9
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +930 -49
- package/src/services/memory-file-service.ts +513 -35
- package/src/services/plugin-runtime.ts +33 -1
- package/src/services/template-apply-service.ts +37 -2
- package/src/worker/scheduler.ts +46 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"nanoid": "^5.1.5",
|
|
18
18
|
"ws": "^8.19.0",
|
|
19
19
|
"zod": "^4.1.5",
|
|
20
|
-
"bopodev-
|
|
21
|
-
"bopodev-db": "0.1.
|
|
22
|
-
"bopodev-
|
|
20
|
+
"bopodev-contracts": "0.1.25",
|
|
21
|
+
"bopodev-db": "0.1.25",
|
|
22
|
+
"bopodev-agent-sdk": "0.1.25"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/cors": "^2.8.19",
|
package/src/app.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
|
|
|
7
7
|
import type { AppContext } from "./context";
|
|
8
8
|
import { createAgentsRouter } from "./routes/agents";
|
|
9
9
|
import { createAuthRouter } from "./routes/auth";
|
|
10
|
+
import { createAttentionRouter } from "./routes/attention";
|
|
10
11
|
import { createCompaniesRouter } from "./routes/companies";
|
|
11
12
|
import { createGoalsRouter } from "./routes/goals";
|
|
12
13
|
import { createGovernanceRouter } from "./routes/governance";
|
|
@@ -46,7 +47,15 @@ export function createApp(ctx: AppContext) {
|
|
|
46
47
|
},
|
|
47
48
|
credentials: true,
|
|
48
49
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
49
|
-
allowedHeaders: [
|
|
50
|
+
allowedHeaders: [
|
|
51
|
+
"content-type",
|
|
52
|
+
"x-company-id",
|
|
53
|
+
"authorization",
|
|
54
|
+
"x-client-trace-id",
|
|
55
|
+
"x-bopo-actor-token",
|
|
56
|
+
"x-request-id",
|
|
57
|
+
"x-bopodev-run-id"
|
|
58
|
+
]
|
|
50
59
|
})
|
|
51
60
|
);
|
|
52
61
|
app.use(express.json());
|
|
@@ -106,6 +115,7 @@ export function createApp(ctx: AppContext) {
|
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
app.use("/auth", createAuthRouter(ctx));
|
|
118
|
+
app.use("/attention", createAttentionRouter(ctx));
|
|
109
119
|
app.use("/companies", createCompaniesRouter(ctx));
|
|
110
120
|
app.use("/projects", createProjectsRouter(ctx));
|
|
111
121
|
app.use("/issues", createIssuesRouter(ctx));
|
package/src/http.ts
CHANGED
|
@@ -4,6 +4,45 @@ export function sendOk<T>(res: Response, data: T) {
|
|
|
4
4
|
return res.status(200).json({ ok: true, data });
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export function sendOkValidated(
|
|
8
|
+
res: Response,
|
|
9
|
+
schema: {
|
|
10
|
+
safeParse: (value: unknown) => {
|
|
11
|
+
success: boolean;
|
|
12
|
+
data?: unknown;
|
|
13
|
+
error?: { issues?: Array<{ path?: unknown[]; message?: string }> };
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
data: unknown,
|
|
17
|
+
resourceName: string
|
|
18
|
+
) {
|
|
19
|
+
const normalized = normalizeContractData(data);
|
|
20
|
+
const parsed = schema.safeParse(normalized);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
return sendError(
|
|
23
|
+
res,
|
|
24
|
+
`Contract mismatch for ${resourceName}: ${(parsed.error?.issues ?? [])
|
|
25
|
+
.map((issue) => `${(issue.path ?? []).join(".") || "<root>"} ${issue.message ?? "Invalid value"}`)
|
|
26
|
+
.join("; ")}`,
|
|
27
|
+
500
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return sendOk(res, parsed.data ?? normalized);
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
export function sendError(res: Response, message: string, code = 400) {
|
|
8
34
|
return res.status(code).json({ ok: false, error: message });
|
|
9
35
|
}
|
|
36
|
+
|
|
37
|
+
function normalizeContractData(value: unknown) {
|
|
38
|
+
const serialized = JSON.stringify(value, (_key, entry) => {
|
|
39
|
+
if (entry instanceof Date) {
|
|
40
|
+
return entry.toISOString();
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
43
|
+
});
|
|
44
|
+
if (serialized === undefined) {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
return JSON.parse(serialized);
|
|
48
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type CommentRecipientType = "agent" | "board" | "member";
|
|
2
|
+
|
|
3
|
+
export type CommentRecipientInput = {
|
|
4
|
+
recipientType: CommentRecipientType;
|
|
5
|
+
recipientId?: string | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PersistedCommentRecipient = {
|
|
9
|
+
recipientType: CommentRecipientType;
|
|
10
|
+
recipientId: string | null;
|
|
11
|
+
deliveryStatus: "pending" | "dispatched" | "failed" | "skipped";
|
|
12
|
+
dispatchedRunId: string | null;
|
|
13
|
+
dispatchedAt: string | null;
|
|
14
|
+
acknowledgedAt: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function dedupeCommentRecipients(recipients: CommentRecipientInput[]) {
|
|
18
|
+
const result: Array<{ recipientType: CommentRecipientType; recipientId: string | null }> = [];
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
for (const recipient of recipients) {
|
|
21
|
+
const recipientId = recipient.recipientId?.trim() ? recipient.recipientId.trim() : null;
|
|
22
|
+
if (recipient.recipientType !== "board" && !recipientId) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const key = `${recipient.recipientType}:${recipientId ?? "__all__"}`;
|
|
26
|
+
if (seen.has(key)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
seen.add(key);
|
|
30
|
+
result.push({
|
|
31
|
+
recipientType: recipient.recipientType,
|
|
32
|
+
recipientId
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeRecipientsForPersistence(
|
|
39
|
+
recipients: Array<{ recipientType: CommentRecipientType; recipientId: string | null }>
|
|
40
|
+
): PersistedCommentRecipient[] {
|
|
41
|
+
return recipients.map((recipient) => ({
|
|
42
|
+
recipientType: recipient.recipientType,
|
|
43
|
+
recipientId: recipient.recipientId ?? null,
|
|
44
|
+
// Non-agent recipients are terminal at creation time; they are not dispatch targets.
|
|
45
|
+
deliveryStatus: recipient.recipientType === "agent" ? "pending" : "skipped",
|
|
46
|
+
dispatchedRunId: null,
|
|
47
|
+
dispatchedAt: null,
|
|
48
|
+
acknowledgedAt: null
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseIssueCommentRecipients(raw: string | null): PersistedCommentRecipient[] {
|
|
53
|
+
if (!raw) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
58
|
+
if (!Array.isArray(parsed)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return parsed
|
|
62
|
+
.map((entry) => {
|
|
63
|
+
if (!entry || typeof entry !== "object") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const candidate = entry as Record<string, unknown>;
|
|
67
|
+
const recipientTypeRaw = String(candidate.recipientType ?? "").trim();
|
|
68
|
+
if (recipientTypeRaw !== "agent" && recipientTypeRaw !== "board" && recipientTypeRaw !== "member") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const deliveryStatusRaw = String(candidate.deliveryStatus ?? "").trim();
|
|
72
|
+
const deliveryStatus =
|
|
73
|
+
deliveryStatusRaw === "pending" ||
|
|
74
|
+
deliveryStatusRaw === "dispatched" ||
|
|
75
|
+
deliveryStatusRaw === "failed" ||
|
|
76
|
+
deliveryStatusRaw === "skipped"
|
|
77
|
+
? deliveryStatusRaw
|
|
78
|
+
: "pending";
|
|
79
|
+
const recipientId =
|
|
80
|
+
typeof candidate.recipientId === "string" && candidate.recipientId.trim().length > 0
|
|
81
|
+
? candidate.recipientId.trim()
|
|
82
|
+
: null;
|
|
83
|
+
return {
|
|
84
|
+
recipientType: recipientTypeRaw as CommentRecipientType,
|
|
85
|
+
recipientId,
|
|
86
|
+
deliveryStatus,
|
|
87
|
+
dispatchedRunId:
|
|
88
|
+
typeof candidate.dispatchedRunId === "string" && candidate.dispatchedRunId.trim().length > 0
|
|
89
|
+
? candidate.dispatchedRunId.trim()
|
|
90
|
+
: null,
|
|
91
|
+
dispatchedAt:
|
|
92
|
+
typeof candidate.dispatchedAt === "string" && candidate.dispatchedAt.trim().length > 0
|
|
93
|
+
? candidate.dispatchedAt.trim()
|
|
94
|
+
: null,
|
|
95
|
+
acknowledgedAt:
|
|
96
|
+
typeof candidate.acknowledgedAt === "string" && candidate.acknowledgedAt.trim().length > 0
|
|
97
|
+
? candidate.acknowledgedAt.trim()
|
|
98
|
+
: null
|
|
99
|
+
} satisfies PersistedCommentRecipient;
|
|
100
|
+
})
|
|
101
|
+
.filter(Boolean) as PersistedCommentRecipient[];
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -2,6 +2,7 @@ type AgentLike = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
name: string;
|
|
4
4
|
role: string;
|
|
5
|
+
roleKey?: string | null;
|
|
5
6
|
status: string;
|
|
6
7
|
canHireAgents?: boolean | null;
|
|
7
8
|
};
|
|
@@ -12,6 +13,7 @@ export type HiringDelegateResolution =
|
|
|
12
13
|
agentId: string;
|
|
13
14
|
name: string;
|
|
14
15
|
role: string;
|
|
16
|
+
roleKey?: string | null;
|
|
15
17
|
};
|
|
16
18
|
reason: "ceo_with_hiring_capability" | "first_hiring_capable_agent";
|
|
17
19
|
}
|
|
@@ -32,18 +34,16 @@ export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolu
|
|
|
32
34
|
}
|
|
33
35
|
const normalized = eligible.map((agent) => ({
|
|
34
36
|
...agent,
|
|
35
|
-
normalizedRole: agent.role.trim().toLowerCase(),
|
|
36
37
|
normalizedName: agent.name.trim().toLowerCase()
|
|
37
38
|
}));
|
|
38
|
-
const ceo =
|
|
39
|
-
normalized.find((agent) => agent.normalizedRole === "ceo") ??
|
|
40
|
-
normalized.find((agent) => agent.normalizedName === "ceo");
|
|
39
|
+
const ceo = normalized.find((agent) => agent.roleKey === "ceo") ?? normalized.find((agent) => agent.normalizedName === "ceo");
|
|
41
40
|
if (ceo) {
|
|
42
41
|
return {
|
|
43
42
|
delegate: {
|
|
44
43
|
agentId: ceo.id,
|
|
45
44
|
name: ceo.name,
|
|
46
|
-
role: ceo.role
|
|
45
|
+
role: ceo.role,
|
|
46
|
+
roleKey: ceo.roleKey ?? null
|
|
47
47
|
},
|
|
48
48
|
reason: "ceo_with_hiring_capability"
|
|
49
49
|
};
|
|
@@ -53,7 +53,8 @@ export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolu
|
|
|
53
53
|
delegate: {
|
|
54
54
|
agentId: fallback.id,
|
|
55
55
|
name: fallback.name,
|
|
56
|
-
role: fallback.role
|
|
56
|
+
role: fallback.role,
|
|
57
|
+
roleKey: fallback.roleKey ?? null
|
|
57
58
|
},
|
|
58
59
|
reason: "first_hiring_capable_agent"
|
|
59
60
|
};
|
|
@@ -78,6 +78,17 @@ export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
|
|
|
78
78
|
return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export function resolveCompanyMemoryRootPath(companyId: string) {
|
|
82
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
83
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "memory");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveProjectMemoryRootPath(companyId: string, projectId: string) {
|
|
87
|
+
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
88
|
+
const safeProjectId = assertPathSegment(projectId, "projectId");
|
|
89
|
+
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "projects", safeProjectId, "memory");
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
export function resolveAgentDurableMemoryPath(companyId: string, agentId: string) {
|
|
82
93
|
return join(resolveAgentMemoryRootPath(companyId, agentId), "life");
|
|
83
94
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
import { listBoardAttentionItems } from "../services/attention-service";
|
|
4
|
+
import type { RealtimeHub } from "./hub";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ACTOR_ID = "local-board";
|
|
7
|
+
|
|
8
|
+
export async function loadAttentionRealtimeSnapshot(
|
|
9
|
+
db: BopoDb,
|
|
10
|
+
companyId: string
|
|
11
|
+
): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
|
|
12
|
+
const items = await listBoardAttentionItems(db, companyId, DEFAULT_ACTOR_ID);
|
|
13
|
+
return createAttentionRealtimeEvent(companyId, {
|
|
14
|
+
type: "attention.snapshot",
|
|
15
|
+
items
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createAttentionRealtimeEvent(
|
|
20
|
+
companyId: string,
|
|
21
|
+
event: Extract<RealtimeEventEnvelope, { channel: "attention" }>["event"]
|
|
22
|
+
): Extract<RealtimeMessage, { kind: "event" }> {
|
|
23
|
+
return {
|
|
24
|
+
kind: "event",
|
|
25
|
+
companyId,
|
|
26
|
+
channel: "attention",
|
|
27
|
+
event
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function publishAttentionSnapshot(
|
|
32
|
+
db: BopoDb,
|
|
33
|
+
realtimeHub: RealtimeHub | undefined,
|
|
34
|
+
companyId: string,
|
|
35
|
+
actorId = DEFAULT_ACTOR_ID
|
|
36
|
+
) {
|
|
37
|
+
if (!realtimeHub) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const items = await listBoardAttentionItems(db, companyId, actorId);
|
|
41
|
+
realtimeHub.publish(
|
|
42
|
+
createAttentionRealtimeEvent(companyId, {
|
|
43
|
+
type: "attention.snapshot",
|
|
44
|
+
items
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
ApprovalActionSchema,
|
|
3
|
+
ApprovalRequestSchema,
|
|
4
|
+
type ApprovalRequest,
|
|
5
|
+
type RealtimeEventEnvelope,
|
|
6
|
+
type RealtimeMessage
|
|
7
|
+
} from "bopodev-contracts";
|
|
2
8
|
import { listApprovalRequests, type BopoDb } from "bopodev-db";
|
|
3
9
|
|
|
4
10
|
export async function loadGovernanceRealtimeSnapshot(db: BopoDb, companyId: string): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
|
|
@@ -33,13 +39,15 @@ export function serializeStoredApproval(approval: {
|
|
|
33
39
|
createdAt: Date;
|
|
34
40
|
resolvedAt: Date | null;
|
|
35
41
|
}): ApprovalRequest {
|
|
42
|
+
const actionParsed = ApprovalActionSchema.safeParse(approval.action);
|
|
43
|
+
const statusParsed = ApprovalRequestSchema.shape.status.safeParse(approval.status);
|
|
36
44
|
return {
|
|
37
45
|
id: approval.id,
|
|
38
46
|
companyId: approval.companyId,
|
|
39
47
|
requestedByAgentId: approval.requestedByAgentId,
|
|
40
|
-
action:
|
|
48
|
+
action: actionParsed.success ? actionParsed.data : "override_budget",
|
|
41
49
|
payload: parsePayload(approval.payloadJson),
|
|
42
|
-
status:
|
|
50
|
+
status: statusParsed.success ? statusParsed.data : "pending",
|
|
43
51
|
createdAt: approval.createdAt.toISOString(),
|
|
44
52
|
resolvedAt: approval.resolvedAt?.toISOString() ?? null
|
|
45
53
|
};
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
HeartbeatRunRealtimeEvent,
|
|
3
|
+
HeartbeatRunTranscriptEventKind,
|
|
4
|
+
HeartbeatRunTranscriptSignalLevel,
|
|
5
|
+
HeartbeatRunTranscriptSource,
|
|
3
6
|
RealtimeEventEnvelope,
|
|
4
7
|
RealtimeMessage
|
|
5
8
|
} from "bopodev-contracts";
|
|
9
|
+
import {
|
|
10
|
+
HeartbeatRunSchema,
|
|
11
|
+
HeartbeatRunTranscriptEventKindSchema,
|
|
12
|
+
HeartbeatRunTranscriptSignalLevelSchema,
|
|
13
|
+
HeartbeatRunTranscriptSourceSchema
|
|
14
|
+
} from "bopodev-contracts";
|
|
6
15
|
import { listHeartbeatRunMessagesForRuns, listHeartbeatRuns, type BopoDb } from "bopodev-db";
|
|
7
16
|
|
|
8
17
|
export function createHeartbeatRunsRealtimeEvent(
|
|
@@ -33,20 +42,13 @@ export async function loadHeartbeatRunsRealtimeSnapshot(
|
|
|
33
42
|
id: message.id,
|
|
34
43
|
runId: message.runId,
|
|
35
44
|
sequence: message.sequence,
|
|
36
|
-
kind: message.kind
|
|
37
|
-
| "system"
|
|
38
|
-
| "assistant"
|
|
39
|
-
| "thinking"
|
|
40
|
-
| "tool_call"
|
|
41
|
-
| "tool_result"
|
|
42
|
-
| "result"
|
|
43
|
-
| "stderr",
|
|
45
|
+
kind: parseTranscriptKind(message.kind),
|
|
44
46
|
label: message.label,
|
|
45
47
|
text: message.text,
|
|
46
48
|
payload: message.payloadJson,
|
|
47
|
-
signalLevel: (message.signalLevel
|
|
49
|
+
signalLevel: parseTranscriptSignalLevel(message.signalLevel),
|
|
48
50
|
groupKey: message.groupKey,
|
|
49
|
-
source: (message.source
|
|
51
|
+
source: parseTranscriptSource(message.source),
|
|
50
52
|
createdAt: message.createdAt.toISOString()
|
|
51
53
|
})),
|
|
52
54
|
nextCursor: result.nextCursor
|
|
@@ -57,7 +59,7 @@ export async function loadHeartbeatRunsRealtimeSnapshot(
|
|
|
57
59
|
type: "runs.snapshot",
|
|
58
60
|
runs: runs.map((run) => ({
|
|
59
61
|
runId: run.id,
|
|
60
|
-
status: run.status
|
|
62
|
+
status: parseRunStatus(run.status),
|
|
61
63
|
message: run.message ?? null,
|
|
62
64
|
startedAt: run.startedAt.toISOString(),
|
|
63
65
|
finishedAt: run.finishedAt?.toISOString() ?? null
|
|
@@ -76,3 +78,23 @@ function createRealtimeEvent(
|
|
|
76
78
|
...envelope
|
|
77
79
|
};
|
|
78
80
|
}
|
|
81
|
+
|
|
82
|
+
function parseRunStatus(value: string) {
|
|
83
|
+
const parsed = HeartbeatRunSchema.shape.status.safeParse(value);
|
|
84
|
+
return parsed.success ? parsed.data : "failed";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTranscriptKind(value: string): HeartbeatRunTranscriptEventKind {
|
|
88
|
+
const parsed = HeartbeatRunTranscriptEventKindSchema.safeParse(value);
|
|
89
|
+
return parsed.success ? parsed.data : "system";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseTranscriptSignalLevel(value: string | null): HeartbeatRunTranscriptSignalLevel | undefined {
|
|
93
|
+
const parsed = HeartbeatRunTranscriptSignalLevelSchema.safeParse(value);
|
|
94
|
+
return parsed.success ? parsed.data : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseTranscriptSource(value: string | null): HeartbeatRunTranscriptSource | undefined {
|
|
98
|
+
const parsed = HeartbeatRunTranscriptSourceSchema.safeParse(value);
|
|
99
|
+
return parsed.success ? parsed.data : undefined;
|
|
100
|
+
}
|
package/src/realtime/hub.ts
CHANGED
|
@@ -140,7 +140,7 @@ function canSubscribeToCompany(
|
|
|
140
140
|
) {
|
|
141
141
|
const deploymentMode = resolveDeploymentMode();
|
|
142
142
|
const tokenSecret = process.env.BOPO_AUTH_TOKEN_SECRET?.trim() || "";
|
|
143
|
-
const tokenIdentity = tokenSecret ? verifyActorToken(
|
|
143
|
+
const tokenIdentity = tokenSecret ? verifyActorToken(readRealtimeToken(requestUrl, headers), tokenSecret) : null;
|
|
144
144
|
if (tokenIdentity) {
|
|
145
145
|
if (tokenIdentity.type === "board") {
|
|
146
146
|
return true;
|
|
@@ -169,7 +169,11 @@ function canSubscribeToCompany(
|
|
|
169
169
|
return actorCompanies.includes(companyId);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
function
|
|
172
|
+
function readRealtimeToken(requestUrl: string | undefined, headers: Record<string, string | string[] | undefined>) {
|
|
173
|
+
const fromProtocol = readRealtimeTokenFromSubprotocolHeader(readHeader(headers, "sec-websocket-protocol"));
|
|
174
|
+
if (fromProtocol) {
|
|
175
|
+
return fromProtocol;
|
|
176
|
+
}
|
|
173
177
|
if (!requestUrl) {
|
|
174
178
|
return null;
|
|
175
179
|
}
|
|
@@ -178,6 +182,34 @@ function readRealtimeTokenFromUrl(requestUrl: string | undefined) {
|
|
|
178
182
|
return token && token.length > 0 ? token : null;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
function readRealtimeTokenFromSubprotocolHeader(value: string | undefined) {
|
|
186
|
+
if (!value) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const protocolEntries = value
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((entry) => entry.trim())
|
|
192
|
+
.filter((entry) => entry.length > 0);
|
|
193
|
+
for (const protocol of protocolEntries) {
|
|
194
|
+
if (!protocol.startsWith("bopo-token.")) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const encodedToken = protocol.slice("bopo-token.".length);
|
|
198
|
+
if (!encodedToken) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const decoded = decodeURIComponent(encodedToken).trim();
|
|
203
|
+
if (decoded) {
|
|
204
|
+
return decoded;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
181
213
|
function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
|
|
182
214
|
const value = headers[name];
|
|
183
215
|
if (Array.isArray(value)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
2
|
import type { OfficeOccupant, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
|
|
3
|
+
import { AGENT_ROLE_LABELS, AgentRoleKeySchema } from "bopodev-contracts";
|
|
3
4
|
import {
|
|
4
5
|
agents,
|
|
5
6
|
approvalRequests,
|
|
@@ -376,7 +377,7 @@ function deriveHireCandidateOccupant(approval: {
|
|
|
376
377
|
|
|
377
378
|
const payload = parsePayload(approval.payloadJson);
|
|
378
379
|
const name = typeof payload.name === "string" ? payload.name : "Pending hire";
|
|
379
|
-
const role =
|
|
380
|
+
const role = resolvePayloadRoleLabel(payload);
|
|
380
381
|
const providerType =
|
|
381
382
|
typeof payload.providerType === "string" ? normalizeProviderType(payload.providerType) : null;
|
|
382
383
|
|
|
@@ -431,6 +432,7 @@ function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
|
|
|
431
432
|
value === "codex" ||
|
|
432
433
|
value === "cursor" ||
|
|
433
434
|
value === "opencode" ||
|
|
435
|
+
value === "gemini_cli" ||
|
|
434
436
|
value === "openai_api" ||
|
|
435
437
|
value === "anthropic_api" ||
|
|
436
438
|
value === "http" ||
|
|
@@ -446,6 +448,20 @@ function formatActionLabel(action: string) {
|
|
|
446
448
|
.join(" ");
|
|
447
449
|
}
|
|
448
450
|
|
|
451
|
+
function resolvePayloadRoleLabel(payload: Record<string, unknown>) {
|
|
452
|
+
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
453
|
+
if (title) {
|
|
454
|
+
return title;
|
|
455
|
+
}
|
|
456
|
+
const roleKeyValue = typeof payload.roleKey === "string" ? payload.roleKey.trim().toLowerCase() : "";
|
|
457
|
+
const parsedRoleKey = roleKeyValue ? AgentRoleKeySchema.safeParse(roleKeyValue) : null;
|
|
458
|
+
if (parsedRoleKey?.success) {
|
|
459
|
+
return AGENT_ROLE_LABELS[parsedRoleKey.data];
|
|
460
|
+
}
|
|
461
|
+
const role = typeof payload.role === "string" ? payload.role.trim() : "";
|
|
462
|
+
return role || null;
|
|
463
|
+
}
|
|
464
|
+
|
|
449
465
|
function parsePayload(payloadJson: string): Record<string, unknown> {
|
|
450
466
|
try {
|
|
451
467
|
const parsed = JSON.parse(payloadJson) as unknown;
|