test(connector-id): migrate fixtures and assertions to pi-mono ids
Replaces legacy adapter ids (codex_local / claude_local / gemini_local / claude_compatible) across ~42 test files with pi-mono equivalents: - codex_local → openai-codex - claude_local → claude-pro - gemini_local → gemini-cli - claude_compatible → anthropic Hand-rewrites where fixture semantics changed: - connector-matching: TASK_TYPE_PREFERENCE ordering rebased (claude-pro first for code/content/management; google first for research) - connector-model-policy: getModels(provider) from pi-ai instead of the deleted adapter.module.models array - session-management + edge-case-session: adapter exemption removed; rotation now uniform under pi-mono - ipc-core-admin-handlers: CSV compat model replaced with pi-ai anthropic - connectors.spec: lookup by openai-codex id (catalog now starts with claude-pro) - onboarding-model-pool: listProviderModels(provider) helper; label text changed to "ChatGPT Plus/Pro (Codex)" - database integration: added vault insecure-fallback env + electron mock - command-paths: uses new @main/connector-helpers normalizeCommandValue - pi-mono-migrations: added `as string` casts for 3 negative-existence assertions verifying legacy ids were removed Drops: - pnpm-workspace.yaml packages: [] (was "packages/*"; directory empty) - obsolete CONNECTOR_STRENGTHS import; .module.models probing Verification: - pnpm typecheck → 0 errors - pnpm lint → 0 errors / 0 warnings - pnpm build:verify → 0 errors / 0 warnings - pnpm test:unit → 508/508 pass - pnpm test:integration → 96/96 pass - All 6 adapter hard gates pass (NOT FOUND / 0 matches / empty) - pi-ai / pi-agent-core / pi-coding-agent all resolved @0.67.6
This commit is contained in:
1 parent
4de9cd0339
commit
e94939a562
80 files changed
+2210
-413
No files matched your search
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Role-classification helpers used across prompt construction and skill
|
||||
* auto-assignment. Keeping a single keyword list here prevents drift
|
||||
* between system-prompt.ts and automation-skills.ts.
|
||||
* auto-assignment. Historical keyword match remains here for backfill and
|
||||
* UI-free fallback; typed `agent.roleLevel` is the source of truth once the
|
||||
* column is populated.
|
||||
*
|
||||
* Matching uses word-boundary tokenization instead of substring search,
|
||||
* so "lead" does not match "cleaner" or "leaderboard".
|
||||
*/
|
||||
import type { AgentRoleLevel } from "@shared/types";
|
||||
import { isLeadershipLevel } from "@shared/types";
|
||||
|
||||
export const LEADERSHIP_ROLE_KEYWORDS = [
|
||||
"chief",
|
||||
@@ -31,3 +34,26 @@ export function roleMatchesLeadershipKeywords(role: string, title?: string | nul
|
||||
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
||||
return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot inference from free-text role/title strings. Used when creating or
|
||||
* backfilling an agent so `roleLevel` can be stored once and re-read cheaply.
|
||||
* The most specific token wins; order here is most-senior → least.
|
||||
*/
|
||||
export function inferRoleLevel(role: string, title?: string | null): AgentRoleLevel {
|
||||
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
||||
if (tokens.has("ceo") || tokens.has("cto") || tokens.has("cmo") || tokens.has("coo") || tokens.has("cfo") || tokens.has("chief")) {
|
||||
return "c_suite";
|
||||
}
|
||||
if (tokens.has("vp") || tokens.has("vice") || tokens.has("president")) return "vp";
|
||||
if (tokens.has("director") || tokens.has("head")) return "director";
|
||||
if (tokens.has("manager")) return "manager";
|
||||
if (tokens.has("lead")) return "lead";
|
||||
return "ic";
|
||||
}
|
||||
|
||||
/** Canonical leadership check: typed enum wins, fallback to keyword match. */
|
||||
export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role: string; title: string | null }): boolean {
|
||||
if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel);
|
||||
return roleMatchesLeadershipKeywords(agent.role, agent.title);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
interface BeforeRequestPayload {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface AfterRequestPayload {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface HumanApprovalHookDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
approvalId: string;
|
||||
resolve: (verdict: { block?: boolean; reason?: string }) => void;
|
||||
}
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
bash: "shell",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
read: "read",
|
||||
ls: "ls",
|
||||
grep: "grep",
|
||||
find: "find",
|
||||
};
|
||||
|
||||
function summariseArgs(toolName: string, args: unknown): string {
|
||||
if (args === null || typeof args !== "object") return String(args);
|
||||
const argsObj = args as Record<string, unknown>;
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = typeof argsObj.command === "string" ? argsObj.command : "";
|
||||
return command.length > 200 ? `${command.slice(0, 197)}...` : command;
|
||||
}
|
||||
case "write":
|
||||
case "edit":
|
||||
case "read": {
|
||||
const path = typeof argsObj.path === "string" ? argsObj.path : (typeof argsObj.file_path === "string" ? argsObj.file_path : "");
|
||||
return path;
|
||||
}
|
||||
default:
|
||||
try {
|
||||
const json = JSON.stringify(args);
|
||||
return json.length > 200 ? `${json.slice(0, 197)}...` : json;
|
||||
} catch {
|
||||
return "<unserialisable args>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-in-the-loop hook bridging pi `beforeToolCall` to
|
||||
* AgentCompany's `ApprovalService` via the existing approvals table.
|
||||
*/
|
||||
export class HumanApprovalHook {
|
||||
private readonly pending = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(private readonly deps: HumanApprovalHookDeps) {}
|
||||
|
||||
async before(runId: string, payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
|
||||
const { db, emitEvent } = this.deps;
|
||||
const summary = summariseArgs(payload.toolName, payload.args);
|
||||
const label = TOOL_LABEL[payload.toolName] ?? payload.toolName;
|
||||
const approvalId = db.requestApproval({
|
||||
companyId: payload.companyId,
|
||||
relatedTaskId: payload.taskId,
|
||||
requestedByAgentId: payload.agentId,
|
||||
type: "dangerous_command",
|
||||
payloadSummary: `${label}: ${summary}`.slice(0, 280),
|
||||
impactSummary: `Agent ${payload.agentId} requested ${label} during run ${runId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
runId,
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
}),
|
||||
});
|
||||
|
||||
emitEvent({ type: "approval-updated", approvalId, action: "created" });
|
||||
emitEvent({ type: "domain-changed" });
|
||||
|
||||
return new Promise<{ block?: boolean; reason?: string }>((resolve) => {
|
||||
this.pending.set(approvalId, { approvalId, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the IPC approval-decided pipeline when a user resolves a
|
||||
* pending approval. If we have a pending pi `beforeToolCall` waiting on
|
||||
* this approval, resolve it.
|
||||
*/
|
||||
resolveApproval(approvalId: string, decision: "approve" | "reject" | "revision_requested", note: string): boolean {
|
||||
const pending = this.pending.get(approvalId);
|
||||
if (!pending) return false;
|
||||
this.pending.delete(approvalId);
|
||||
if (decision === "approve") {
|
||||
pending.resolve({});
|
||||
} else {
|
||||
pending.resolve({ block: true, reason: note || `Tool execution ${decision}.` });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Called when a run is cancelled — resolves any pending approvals as blocked. */
|
||||
cancelAll(reason: string): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
pending.resolve({ block: true, reason });
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
|
||||
// Audit hook intentionally minimal — actual tool result is captured by
|
||||
// the agent-event stream and persisted via SqliteSessionStore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { SqliteSessionStore } from "./sqlite-session-store";
|
||||
export { HumanApprovalHook } from "./approval-hook";
|
||||
export { CostWriter } from "./cost-writer";
|
||||
export { RuntimeBridge } from "./runtime-bridge";
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
|
||||
/**
|
||||
* SQLite-backed pi `AgentMessage[]` store.
|
||||
*
|
||||
* One store instance per task_session. Owners must call `appendMessage`
|
||||
* exactly once per pi `message_end` event, and `replaceMessages` after pi
|
||||
* `compact` returns a compacted message list.
|
||||
*/
|
||||
export class SqliteSessionStore {
|
||||
constructor(
|
||||
private readonly db: AppDatabase,
|
||||
public readonly taskSessionId: string,
|
||||
) {}
|
||||
|
||||
loadMessages(): AgentMessage[] {
|
||||
const rows = this.db.sessionEntries.listActive(this.taskSessionId);
|
||||
const result: AgentMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "message") {
|
||||
try {
|
||||
result.push(JSON.parse(row.payloadJson) as AgentMessage);
|
||||
} catch {
|
||||
// Skip malformed entries (defensive) — preserves the rest of the
|
||||
// transcript instead of failing the entire load.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage): void {
|
||||
this.db.sessionEntries.append({
|
||||
taskSessionId: this.taskSessionId,
|
||||
kind: "message",
|
||||
role: (message as { role?: string }).role ?? null,
|
||||
payloadJson: JSON.stringify(message),
|
||||
});
|
||||
}
|
||||
|
||||
appendCompaction(summary: string): void {
|
||||
this.db.sessionEntries.append({
|
||||
taskSessionId: this.taskSessionId,
|
||||
kind: "compaction",
|
||||
role: null,
|
||||
payloadJson: JSON.stringify({ summary }),
|
||||
});
|
||||
}
|
||||
|
||||
replaceMessages(messages: AgentMessage[], compactionSummary?: string): void {
|
||||
const entries: Array<{ kind: "message" | "compaction"; role: string | null; payloadJson: string }> = [];
|
||||
if (compactionSummary) {
|
||||
entries.push({ kind: "compaction", role: null, payloadJson: JSON.stringify({ summary: compactionSummary }) });
|
||||
}
|
||||
for (const message of messages) {
|
||||
entries.push({
|
||||
kind: "message",
|
||||
role: (message as { role?: string }).role ?? null,
|
||||
payloadJson: JSON.stringify(message),
|
||||
});
|
||||
}
|
||||
this.db.sessionEntries.replace({ taskSessionId: this.taskSessionId, entries });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type { CredentialBackend, CredentialKind } from "@main/database/credentials";
|
||||
import type { CredentialStatus } from "@main/database/credentials";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { StoredCredentialPayload } from "./types";
|
||||
|
||||
const HINT_SUFFIX_LEN = 4;
|
||||
const HINT_PREFIX_LEN = 6;
|
||||
|
||||
function maskApiKey(value: string): string {
|
||||
if (value.length <= HINT_PREFIX_LEN + HINT_SUFFIX_LEN) {
|
||||
return `${value.slice(0, 2)}...`;
|
||||
}
|
||||
const prefix = value.slice(0, HINT_PREFIX_LEN);
|
||||
const suffix = value.slice(-HINT_SUFFIX_LEN);
|
||||
return `${prefix}...${suffix}`;
|
||||
}
|
||||
|
||||
export function deriveDisplayHint(payload: StoredCredentialPayload): string {
|
||||
switch (payload.kind) {
|
||||
case "api_key":
|
||||
return payload.value.source === "literal"
|
||||
? maskApiKey(payload.value.value)
|
||||
: `env:${payload.value.value}`;
|
||||
case "azure":
|
||||
return payload.value.resourceName
|
||||
? `azure:${payload.value.resourceName}`
|
||||
: `azure:${maskApiKey(payload.value.apiKey)}`;
|
||||
case "aws":
|
||||
if (payload.value.authMode === "profile") return `aws:profile=${payload.value.awsProfile ?? "?"}`;
|
||||
if (payload.value.authMode === "iam") return `aws:iam=${maskApiKey(payload.value.accessKeyId ?? "?")}`;
|
||||
return `aws:bearer`;
|
||||
case "gcp_adc":
|
||||
return `gcp:${payload.value.project}@${payload.value.location}`;
|
||||
case "oauth": {
|
||||
const expiresMs = payload.value.expires;
|
||||
const expiresLabel = typeof expiresMs === "number" && Number.isFinite(expiresMs)
|
||||
? new Date(expiresMs).toISOString()
|
||||
: "no-expiry";
|
||||
return `oauth:expires=${expiresLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CredentialStoreDeps {
|
||||
db: AppDatabase;
|
||||
}
|
||||
|
||||
export class CredentialStore {
|
||||
constructor(private readonly deps: CredentialStoreDeps) {}
|
||||
|
||||
list(): CredentialSummary[] {
|
||||
return this.deps.db.credentialsTable.list().map((row) => ({
|
||||
connectorId: row.connectorId,
|
||||
kind: row.kind as CredentialKind,
|
||||
displayHint: row.displayHint,
|
||||
status: row.status,
|
||||
expiresAt: row.expiresAt,
|
||||
lastRefreshedAt: row.lastRefreshedAt,
|
||||
lastError: row.lastError,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Encrypts and stores a credential. Caller is responsible for kind/connectorId match. */
|
||||
save(connectorId: string, payload: StoredCredentialPayload, expiresAt: string | null = null): CredentialSummary {
|
||||
const json = JSON.stringify(payload.value);
|
||||
const { backend, payload: ciphertext } = this.deps.db.vault.encrypt(json);
|
||||
const stored = this.deps.db.credentialsTable.upsert({
|
||||
connectorId,
|
||||
kind: payload.kind,
|
||||
backend: backend as CredentialBackend,
|
||||
ciphertext,
|
||||
displayHint: deriveDisplayHint(payload),
|
||||
status: "active",
|
||||
expiresAt,
|
||||
lastRefreshedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
return {
|
||||
connectorId: stored.connectorId,
|
||||
kind: stored.kind,
|
||||
displayHint: stored.displayHint,
|
||||
status: stored.status,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastRefreshedAt: stored.lastRefreshedAt,
|
||||
lastError: stored.lastError,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypts and returns the typed credential payload. Returns null when missing. */
|
||||
read(connectorId: string): StoredCredentialPayload | null {
|
||||
const row = this.deps.db.credentialsTable.get(connectorId);
|
||||
if (!row) return null;
|
||||
const json = this.deps.db.vault.decrypt(
|
||||
row.backend,
|
||||
Buffer.isBuffer(row.ciphertext) ? row.ciphertext : Buffer.from(row.ciphertext),
|
||||
);
|
||||
const value = JSON.parse(json);
|
||||
return { kind: row.kind, value } as StoredCredentialPayload;
|
||||
}
|
||||
|
||||
remove(connectorId: string): void {
|
||||
this.deps.db.credentialsTable.remove(connectorId);
|
||||
}
|
||||
|
||||
setStatus(connectorId: string, status: CredentialStatus, lastError: string | null = null): void {
|
||||
this.deps.db.credentialsTable.setStatus(connectorId, status, lastError);
|
||||
}
|
||||
|
||||
/** Replaces the OAuth ciphertext after a successful refresh. */
|
||||
saveRefreshedOAuth(connectorId: string, payload: StoredCredentialPayload, expiresAt: string | null): void {
|
||||
const { backend, payload: ciphertext } = this.deps.db.vault.encrypt(JSON.stringify(payload.value));
|
||||
this.deps.db.credentialsTable.markRefreshed(
|
||||
connectorId,
|
||||
backend as CredentialBackend,
|
||||
ciphertext,
|
||||
expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CredentialStore, deriveDisplayHint } from "./credential-store";
|
||||
export { CredentialResolver, CredentialError, type ResolvedCredential } from "./credential-resolver";
|
||||
export { OAuthLoginService } from "./oauth-login-service";
|
||||
export type { StoredCredentialPayload, OAuthCredentialPayload, CredentialKind } from "./types";
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ApiKeyValue,
|
||||
AwsCredential,
|
||||
AzureCredential,
|
||||
GcpAdcCredential,
|
||||
} from "@shared/contracts/schemas-credentials";
|
||||
|
||||
/**
|
||||
* pi-ai's `OAuthCredentials` shape.
|
||||
*
|
||||
* Mirrored locally as a type-only declaration to avoid pulling pi runtime
|
||||
* symbols into modules that only need the shape. The actual contract is
|
||||
* enforced by pi at runtime via the OAuth provider interface.
|
||||
*/
|
||||
export interface OAuthCredentialPayload {
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
/** Provider-specific extra fields (e.g. github copilot domain) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type StoredCredentialPayload =
|
||||
| { kind: "oauth"; value: OAuthCredentialPayload }
|
||||
| { kind: "api_key"; value: ApiKeyValue }
|
||||
| { kind: "azure"; value: AzureCredential }
|
||||
| { kind: "aws"; value: AwsCredential }
|
||||
| { kind: "gcp_adc"; value: GcpAdcCredential };
|
||||
|
||||
export type CredentialKind = StoredCredentialPayload["kind"];
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { STALE_LOCK_TIMEOUT_MS } from "./database-base";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-state recovery helpers extracted from `database-base.ts` to keep that
|
||||
* file under the 400-line cap. Both helpers are called by `AppDatabase.init`
|
||||
* (interruptStaleRuns) and the orchestration recovery loop (recoverStaleLocks).
|
||||
*/
|
||||
|
||||
interface InterruptDeps {
|
||||
db: DatabaseSync;
|
||||
transaction: <T>(op: () => T) => T;
|
||||
releaseAllWorkspaceLocks: () => void;
|
||||
}
|
||||
|
||||
export function interruptStaleRunsImpl(deps: InterruptDeps): number {
|
||||
const ts = nowIso();
|
||||
let interruptedCount = 0;
|
||||
deps.transaction(() => {
|
||||
const result = deps.db
|
||||
.prepare(
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'",
|
||||
)
|
||||
.run(ts, ts);
|
||||
interruptedCount = Number(result.changes);
|
||||
if (interruptedCount > 0) {
|
||||
deps.db
|
||||
.prepare(
|
||||
"update tasks set status = 'blocked', updated_at = ? where id in (select task_id from runs where status = 'interrupted' and finished_at = ?) and status in ('in_progress', 'todo')",
|
||||
)
|
||||
.run(ts, ts);
|
||||
}
|
||||
deps.db
|
||||
.prepare(
|
||||
"update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')",
|
||||
)
|
||||
.run();
|
||||
deps.db.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'").run(ts);
|
||||
deps.releaseAllWorkspaceLocks();
|
||||
});
|
||||
return interruptedCount;
|
||||
}
|
||||
|
||||
interface RecoverDeps {
|
||||
db: DatabaseSync;
|
||||
readAll: <T>(sql: string, ...params: import("node:sqlite").SQLInputValue[]) => T[];
|
||||
}
|
||||
|
||||
export function recoverStaleLocksImpl(
|
||||
deps: RecoverDeps,
|
||||
): Array<{ taskId: string; companyId: string; title: string }> {
|
||||
const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString();
|
||||
const stale = deps.readAll<{ id: string; companyId: string; title: string }>(
|
||||
"SELECT id, company_id AS companyId, title FROM tasks WHERE execution_locked_at IS NOT NULL AND execution_locked_at < ? AND status = 'in_progress' AND checkout_run_id NOT IN (SELECT id FROM runs WHERE status = 'running')",
|
||||
cutoff,
|
||||
);
|
||||
const ts = nowIso();
|
||||
for (const task of stale) {
|
||||
deps.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, status = 'todo', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(ts, task.id);
|
||||
}
|
||||
return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title }));
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function buildEntityCrudMethods(
|
||||
getAgent(id: string): AgentRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
@@ -74,7 +74,7 @@ export function buildEntityCrudMethods(
|
||||
findAgent(id: string): AgentRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
|
||||
agents: readAll<SqliteRow>(
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
|
||||
goals: readAll<GoalRecord>(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Resolves `Model<Api>` and the dynamic stream headers needed for providers
|
||||
* that pi-ai's catalog does not ship out of the box.
|
||||
*
|
||||
* Today this only covers the two Fireworks connectors (`fireworks` and
|
||||
* `fireworks-firepass`), which both speak the Anthropic-compatible endpoint
|
||||
* at `https://api.fireworks.ai/inference` and require Bearer auth. For every
|
||||
* other provider we delegate to pi-ai's `getModel` / `getModels`.
|
||||
*/
|
||||
|
||||
import { getModel, getModels, type Api, type Model } from "@mariozechner/pi-ai";
|
||||
|
||||
const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference";
|
||||
const FIREWORKS_DEFAULT_CONTEXT = 128_000;
|
||||
const FIREWORKS_DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
export const FIREWORKS_FIREPASS_MODEL_ID = "accounts/fireworks/routers/kimi-k2p5-turbo";
|
||||
export const FIREWORKS_FIREPASS_MODEL_NAME = "Kimi K2.5 Turbo (Fire Pass)";
|
||||
|
||||
const FIREWORKS_CURATED_MODELS: Array<{ id: string; name: string; contextWindow?: number }> = [
|
||||
{ id: "accounts/fireworks/models/kimi-k2-instruct", name: "Kimi K2 Instruct", contextWindow: 131_072 },
|
||||
{ id: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", name: "Qwen3 Coder 480B A35B Instruct", contextWindow: 262_144 },
|
||||
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1", contextWindow: 163_840 },
|
||||
{ id: "accounts/fireworks/models/glm-4p5", name: "GLM 4.5", contextWindow: 131_072 },
|
||||
{ id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B Instruct", contextWindow: 131_072 },
|
||||
];
|
||||
|
||||
function buildFireworksModel(modelId: string, name?: string, contextWindow?: number): Model<"anthropic-messages"> {
|
||||
return {
|
||||
id: modelId,
|
||||
name: name ?? modelId,
|
||||
api: "anthropic-messages",
|
||||
provider: "fireworks",
|
||||
baseUrl: FIREWORKS_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: contextWindow ?? FIREWORKS_DEFAULT_CONTEXT,
|
||||
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function isFireworksProvider(provider: string): boolean {
|
||||
return provider === "fireworks" || provider === "fireworks-firepass";
|
||||
}
|
||||
|
||||
export function resolveModel(provider: string, modelId: string): Model<Api> | null {
|
||||
if (provider === "fireworks-firepass") {
|
||||
return buildFireworksModel(FIREWORKS_FIREPASS_MODEL_ID, FIREWORKS_FIREPASS_MODEL_NAME, 262_144);
|
||||
}
|
||||
if (provider === "fireworks") {
|
||||
const curated = FIREWORKS_CURATED_MODELS.find((entry) => entry.id === modelId);
|
||||
return buildFireworksModel(modelId, curated?.name, curated?.contextWindow);
|
||||
}
|
||||
try {
|
||||
return getModel(provider as never, modelId as never) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveModels(provider: string): Array<{ id: string; name: string }> {
|
||||
if (provider === "fireworks-firepass") {
|
||||
return [{ id: FIREWORKS_FIREPASS_MODEL_ID, name: FIREWORKS_FIREPASS_MODEL_NAME }];
|
||||
}
|
||||
if (provider === "fireworks") {
|
||||
return FIREWORKS_CURATED_MODELS.map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
try {
|
||||
const models = getModels(provider as never) ?? [];
|
||||
return models.map((model: { id: string; name?: string }) => ({ id: model.id, name: model.name ?? model.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra HTTP headers that must be merged into the streamSimple call for the
|
||||
* given provider. Returns undefined when no extra headers are required.
|
||||
*
|
||||
* Fireworks' Anthropic-compat endpoint authenticates via `Authorization: Bearer`,
|
||||
* not `x-api-key`. pi-ai's anthropic provider only sends `x-api-key`, so we
|
||||
* inject the bearer header at the call site.
|
||||
*/
|
||||
export function resolveExtraStreamHeaders(provider: string, apiKey: string): Record<string, string> | undefined {
|
||||
if (!apiKey) return undefined;
|
||||
if (isFireworksProvider(provider)) {
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type log from "electron-log/main.js";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
interface ResumeInterruptedTasksDeps {
|
||||
db: AppDatabase;
|
||||
logger: typeof log;
|
||||
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume tasks that were left behind by a clean shutdown.
|
||||
*
|
||||
* The worker-service marks running/queued runs as "interrupted" with a fixed
|
||||
* summary string on shutdown, but does not reset the owning task. This helper
|
||||
* rolls those tasks back to "todo", releases their run locks, and re-wakes the
|
||||
* assigned agents so heartbeat can pick them up again.
|
||||
*/
|
||||
export function resumeInterruptedTasks({
|
||||
db,
|
||||
logger,
|
||||
wakeAgentIfPossible,
|
||||
publishDomainChanged,
|
||||
}: ResumeInterruptedTasksDeps): void {
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
const shutdownRuns = snapshot.runs.filter(
|
||||
(r) =>
|
||||
r.status === "interrupted" &&
|
||||
(r.summary === "Application shut down." || r.summary === "Worker process crashed."),
|
||||
);
|
||||
if (shutdownRuns.length === 0) return;
|
||||
|
||||
const resumedAgents = new Set<string>();
|
||||
for (const run of shutdownRuns) {
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
if (task.status === "in_progress" || task.status === "blocked") {
|
||||
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
}
|
||||
if (!resumedAgents.has(run.agentId)) {
|
||||
resumedAgents.add(run.agentId);
|
||||
wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[bootstrap] Skipped shutdown recovery for run ${run.id} (task/agent may have been deleted):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resumedAgents.size > 0) {
|
||||
logger.info(
|
||||
`[bootstrap] Resumed ${shutdownRuns.length} interrupted task(s) across ${resumedAgents.size} agent(s) after clean shutdown recovery.`,
|
||||
);
|
||||
publishDomainChanged();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[bootstrap] Shutdown recovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Helpers for assembling orchestration-service dependencies in service-graph.ts.
|
||||
* Extracted to keep service-graph.ts under the 400-line cap.
|
||||
*/
|
||||
|
||||
import type { AppDatabase } from "./database";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import type { createSessionManagementService } from "./session-management-service";
|
||||
import type { RuntimeSkillEntry } from "./skill-types";
|
||||
|
||||
interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function makeListRuntimeSkillEntries(deps: { db: AppDatabase; logger: Logger }) {
|
||||
let cache: ReturnType<typeof createSkillService> | null = null;
|
||||
return (companyId: string, agentId: string, connectorId: string): RuntimeSkillEntry[] => {
|
||||
try {
|
||||
if (!cache) cache = createSkillService({ db: deps.db, profileDir: deps.db.profileDir, logger: deps.logger });
|
||||
return cache.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSessionHandoffLookup(
|
||||
deps: { logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string): string | null => {
|
||||
try {
|
||||
return getSessionService().getHandoffContext(taskId);
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makePreRunRotationEvaluator(
|
||||
deps: { db: AppDatabase; logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string, connectorId: string): { rotated: boolean; handoff: string | null } => {
|
||||
try {
|
||||
const sessionSvc = getSessionService();
|
||||
const activeSession = deps.db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionSvc.rotateSession(activeSession);
|
||||
const handoff = sessionSvc.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Pi-mono runtime + credential service factory.
|
||||
*
|
||||
* Composes `CredentialStore`, `CredentialResolver`, `OAuthLoginService`, and
|
||||
* `RuntimeBridge` for `service-graph.ts`. Extracted to keep `service-graph.ts`
|
||||
* under the 400-line cap.
|
||||
*/
|
||||
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { CredentialResolver, CredentialStore, OAuthLoginService } from "./credentials";
|
||||
import { RuntimeBridge } from "./agent-runtime";
|
||||
|
||||
export interface PiRuntimeServicesDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
refreshConnectorHealth: (connectorId: string) => Promise<void>;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiRuntimeServices {
|
||||
credentialStore: CredentialStore;
|
||||
credentialResolver: CredentialResolver;
|
||||
oauthLoginService: OAuthLoginService;
|
||||
runtimeBridge: RuntimeBridge;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export function createPiRuntimeServices(deps: PiRuntimeServicesDeps): PiRuntimeServices {
|
||||
const credentialStore = new CredentialStore({ db: deps.db });
|
||||
const credentialResolver = new CredentialResolver(credentialStore);
|
||||
const oauthLoginService = new OAuthLoginService({
|
||||
store: credentialStore,
|
||||
emitEvent: deps.emitEvent,
|
||||
refreshConnectorHealth: deps.refreshConnectorHealth,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const runtimeBridge = new RuntimeBridge({
|
||||
db: deps.db,
|
||||
emitEvent: deps.emitEvent,
|
||||
logger: deps.logger,
|
||||
credentialResolver,
|
||||
});
|
||||
|
||||
return {
|
||||
credentialStore,
|
||||
credentialResolver,
|
||||
oauthLoginService,
|
||||
runtimeBridge,
|
||||
release: () => {
|
||||
runtimeBridge.approvalHook.cancelAll("AgentCompany shutting down.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Silences the `node:sqlite` ExperimentalWarning emitted on first import.
|
||||
* Must be imported before any module that touches `node:sqlite`, otherwise
|
||||
* the warning has already fired.
|
||||
*
|
||||
* Targeted: only the SQLite experimental warning. All other warnings pass
|
||||
* through unchanged.
|
||||
*/
|
||||
|
||||
const originalEmitWarning = process.emitWarning.bind(process);
|
||||
|
||||
function isSqliteExperimentalWarning(warning: unknown): boolean {
|
||||
if (typeof warning === "string") {
|
||||
return /SQLite is an experimental feature/.test(warning);
|
||||
}
|
||||
if (warning instanceof Error) {
|
||||
return warning.name === "ExperimentalWarning" && /SQLite/.test(warning.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
process.emitWarning = ((warning: unknown, ...rest: unknown[]) => {
|
||||
if (isSqliteExperimentalWarning(warning)) return;
|
||||
return (originalEmitWarning as (...args: unknown[]) => void)(warning, ...rest);
|
||||
}) as typeof process.emitWarning;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Defensive token redactor for worker process logs.
|
||||
*
|
||||
* Pi-mono's HTTP clients are not known to log credentials, but stack traces
|
||||
* from upstream libraries (or future pi versions) could include header
|
||||
* fragments containing `Authorization: Bearer ...` or `?access_token=...`.
|
||||
* To prevent any such fragment from reaching the parent's stderr stream,
|
||||
* `installLogRedactor()` wraps `console.error`, `console.warn`, `console.log`,
|
||||
* `process.stdout.write`, and `process.stderr.write` so that every string
|
||||
* argument is filtered through `redactTokens()` before transmission.
|
||||
*/
|
||||
|
||||
const PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
{ pattern: /Bearer\s+[A-Za-z0-9._\-+/=]{8,}/g, replacement: "Bearer [REDACTED]" },
|
||||
{ pattern: /(authorization\s*[:=]\s*)([^\s,;]{8,})/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(access_token=)([^&\s"']+)/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(refresh_token=)([^&\s"']+)/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(api[_-]?key\s*[:=]\s*['"]?)([A-Za-z0-9._-]{12,})/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /sk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replacement: "sk-[REDACTED]" },
|
||||
{ pattern: /ghp_[A-Za-z0-9]{16,}/g, replacement: "ghp_[REDACTED]" },
|
||||
{ pattern: /gho_[A-Za-z0-9]{16,}/g, replacement: "gho_[REDACTED]" },
|
||||
{ pattern: /AKIA[A-Z0-9]{16}/g, replacement: "AKIA[REDACTED]" },
|
||||
];
|
||||
|
||||
export function redactTokens(text: string): string {
|
||||
let result = text;
|
||||
for (const { pattern, replacement } of PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function redactArg(arg: unknown): unknown {
|
||||
if (typeof arg === "string") return redactTokens(arg);
|
||||
if (arg instanceof Error) {
|
||||
const redacted = new Error(redactTokens(arg.message));
|
||||
redacted.name = arg.name;
|
||||
if (arg.stack) redacted.stack = redactTokens(arg.stack);
|
||||
return redacted;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
interface InstallTarget {
|
||||
console: { log: typeof console.log; warn: typeof console.warn; error: typeof console.error; info: typeof console.info };
|
||||
stdoutWrite: typeof process.stdout.write;
|
||||
stderrWrite: typeof process.stderr.write;
|
||||
}
|
||||
|
||||
export function installLogRedactor(target?: Partial<InstallTarget>): () => void {
|
||||
const cons = target?.console ?? console;
|
||||
const origLog = cons.log.bind(cons);
|
||||
const origWarn = cons.warn.bind(cons);
|
||||
const origError = cons.error.bind(cons);
|
||||
const origInfo = cons.info.bind(cons);
|
||||
|
||||
cons.log = (...args: unknown[]) => origLog(...args.map(redactArg));
|
||||
cons.warn = (...args: unknown[]) => origWarn(...args.map(redactArg));
|
||||
cons.error = (...args: unknown[]) => origError(...args.map(redactArg));
|
||||
cons.info = (...args: unknown[]) => origInfo(...args.map(redactArg));
|
||||
|
||||
const stdoutWriteOrig = (target?.stdoutWrite ?? process.stdout.write).bind(process.stdout);
|
||||
const stderrWriteOrig = (target?.stderrWrite ?? process.stderr.write).bind(process.stderr);
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
if (typeof chunk === "string") return stdoutWriteOrig(redactTokens(chunk), ...rest as never[]);
|
||||
return stdoutWriteOrig(chunk as never, ...rest as never[]);
|
||||
}) as typeof process.stdout.write;
|
||||
process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
if (typeof chunk === "string") return stderrWriteOrig(redactTokens(chunk), ...rest as never[]);
|
||||
return stderrWriteOrig(chunk as never, ...rest as never[]);
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
return () => {
|
||||
cons.log = origLog;
|
||||
cons.warn = origWarn;
|
||||
cons.error = origError;
|
||||
cons.info = origInfo;
|
||||
process.stdout.write = stdoutWriteOrig;
|
||||
process.stderr.write = stderrWriteOrig;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user