Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
158 lines
5.4 KiB
TypeScript
158 lines
5.4 KiB
TypeScript
import { isAbsolute, relative, resolve } from "node:path";
|
|
import type { AppDatabase } from "@main/database";
|
|
|
|
/**
|
|
* Auto-registration of filesystem artifacts produced by pi's write / edit
|
|
* tools. Every successful tool call that mutates a workspace file is
|
|
* projected into a `work_products` row so the desktop UI sees what the
|
|
* agent actually produced — the old behavior required the agent to call
|
|
* `POST /api/documents` manually, which was routinely forgotten and left
|
|
* dozens of files on disk invisible to users.
|
|
*
|
|
* Registration is idempotent: the natural identity of an artifact is
|
|
* `(companyId, provider, externalId)` where externalId is the workspace-
|
|
* relative path. Re-writing a file updates the existing row instead of
|
|
* creating duplicates.
|
|
*
|
|
* This module is pure data-structure plumbing — it holds no state and
|
|
* does no I/O beyond the single SQLite upsert. The RuntimeBridge owns
|
|
* the call site.
|
|
*/
|
|
|
|
/** Paths we never register — tool output that is never a deliverable. */
|
|
const IGNORED_PATH_SEGMENTS: readonly string[] = [
|
|
"/.git/",
|
|
"/node_modules/",
|
|
"/.next/",
|
|
"/dist/",
|
|
"/build/",
|
|
"/out/",
|
|
"/.cache/",
|
|
"/.pnpm-store/",
|
|
"/.DS_Store",
|
|
];
|
|
const IGNORED_EXACT_NAMES: ReadonlySet<string> = new Set([
|
|
".DS_Store",
|
|
"package-lock.json",
|
|
"pnpm-lock.yaml",
|
|
"yarn.lock",
|
|
".gitignore",
|
|
]);
|
|
/** Extensions we never register as work products (binaries, build output). */
|
|
const IGNORED_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
".lock",
|
|
".log",
|
|
".map",
|
|
".min.js",
|
|
".min.css",
|
|
]);
|
|
|
|
export type ArtifactProjectorToolEvent = {
|
|
toolName: string;
|
|
args: unknown;
|
|
result: unknown;
|
|
isError: boolean;
|
|
};
|
|
|
|
export type ArtifactProjectorRunContext = {
|
|
runId: string;
|
|
agentId: string;
|
|
companyId: string;
|
|
taskId: string | null;
|
|
workspacePath: string | null;
|
|
};
|
|
|
|
type WriteArgs = { path: unknown; content?: unknown };
|
|
type EditArgs = { path: unknown };
|
|
|
|
/** Normalises pi tool-call args to the file path the agent touched. */
|
|
function extractToolPath(toolName: string, args: unknown): string | null {
|
|
if (!args || typeof args !== "object") return null;
|
|
if (toolName === "write") {
|
|
const p = (args as WriteArgs).path;
|
|
return typeof p === "string" && p.length > 0 ? p : null;
|
|
}
|
|
if (toolName === "edit") {
|
|
const p = (args as EditArgs).path;
|
|
return typeof p === "string" && p.length > 0 ? p : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Returns true when the path is inside the workspace and not on the ignore list. */
|
|
function isRegistrablePath(absPath: string, workspacePath: string): boolean {
|
|
const rel = relative(workspacePath, absPath);
|
|
if (rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
const probePath = `/${rel}/`;
|
|
if (IGNORED_PATH_SEGMENTS.some((seg) => probePath.includes(seg))) return false;
|
|
const lastSlash = rel.lastIndexOf("/");
|
|
const basename = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
|
|
if (IGNORED_EXACT_NAMES.has(basename)) return false;
|
|
const dot = basename.lastIndexOf(".");
|
|
if (dot > 0) {
|
|
const ext = basename.slice(dot).toLowerCase();
|
|
if (IGNORED_EXTENSIONS.has(ext)) return false;
|
|
// Catch compound extensions like ".min.js" by checking the last two dots.
|
|
const prevDot = basename.lastIndexOf(".", dot - 1);
|
|
if (prevDot > 0) {
|
|
const compound = basename.slice(prevDot).toLowerCase();
|
|
if (IGNORED_EXTENSIONS.has(compound)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export type ProjectionOutcome =
|
|
| { kind: "skipped"; reason: string }
|
|
| { kind: "inserted"; workProductId: string; relativePath: string }
|
|
| { kind: "updated"; workProductId: string; relativePath: string };
|
|
|
|
/**
|
|
* Upsert a work_products row for a successful write/edit tool call.
|
|
* Returns an outcome describing whether the artifact was inserted,
|
|
* updated, or skipped (with reason). Returns "skipped" for every
|
|
* non-applicable case so the caller can log a single stream of events.
|
|
*/
|
|
export function projectToolExecutionToWorkProduct(
|
|
db: AppDatabase,
|
|
ctx: ArtifactProjectorRunContext,
|
|
event: ArtifactProjectorToolEvent,
|
|
): ProjectionOutcome {
|
|
if (event.isError) return { kind: "skipped", reason: "tool_error" };
|
|
if (event.toolName !== "write" && event.toolName !== "edit") {
|
|
return { kind: "skipped", reason: "unsupported_tool" };
|
|
}
|
|
if (!ctx.workspacePath) return { kind: "skipped", reason: "no_workspace" };
|
|
|
|
const rawPath = extractToolPath(event.toolName, event.args);
|
|
if (!rawPath) return { kind: "skipped", reason: "missing_path" };
|
|
|
|
const absPath = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspacePath, rawPath);
|
|
if (!isRegistrablePath(absPath, ctx.workspacePath)) {
|
|
return { kind: "skipped", reason: "path_ignored" };
|
|
}
|
|
|
|
const relativePath = relative(ctx.workspacePath, absPath);
|
|
const lastSlash = relativePath.lastIndexOf("/");
|
|
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
|
|
|
|
const provider = "filesystem";
|
|
const existing = db.findWorkProductByExternalId(ctx.companyId, provider, relativePath);
|
|
if (existing) {
|
|
db.updateWorkProduct(existing.id, ctx.companyId, { title });
|
|
return { kind: "updated", workProductId: existing.id, relativePath };
|
|
}
|
|
|
|
const workProductId = db.createWorkProduct({
|
|
companyId: ctx.companyId,
|
|
taskId: ctx.taskId,
|
|
type: "file",
|
|
provider,
|
|
externalId: relativePath,
|
|
title,
|
|
url: null,
|
|
createdByRunId: ctx.runId,
|
|
});
|
|
return { kind: "inserted", workProductId, relativePath };
|
|
}
|