mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
fix(inbox): separate automatic recovery cleanup from user participation
Keep exact workspace owner verification holds stable and let sandbox agents inspect authorized issue activity. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4b958ecbd8
commit
697e132ca4
13 files changed
+194
-8
No files matched your search
@@ -9,6 +9,15 @@ A completed workspace barrier is reread
|
||||
under ownership before export, and a committed coordinator cannot be overwritten
|
||||
by a late failure receipt.
|
||||
|
||||
An unverified workspace owner keeps one board recovery action for its exact run
|
||||
and owner fingerprint. Repeated recovery checks do not consume attempts or
|
||||
rewrite that action. Task completion or cancellation does not prove the old
|
||||
physical writer stopped, so reads and recovery sweeps retain this action while
|
||||
the matching run still carries that owner receipt. After the receipt is cleared
|
||||
or replaced, ordinary stale-action cleanup applies. Cleanup is attributed to
|
||||
the system; historical cleanup from page reads does not add tasks to a person's
|
||||
inbox or refresh issue activity timestamps.
|
||||
|
||||
The lock transaction holds no row locks. Ordinary progress and finalization
|
||||
receipts remain visible through the normal database pool. A dedicated connection outside the application pool is
|
||||
reserved for the duration of copyback and closed when it settles; even a one-connection application pool remains available for progress writes. Its run profile carries
|
||||
|
||||
@@ -1501,6 +1501,7 @@ describe("sandbox callback bridge", () => {
|
||||
{ method: "GET", path: "/api/goals/goal-1" },
|
||||
{ method: "GET", path: "/api/issues/issue-1" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/activity" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/comments" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/comments" },
|
||||
@@ -1547,6 +1548,10 @@ describe("sandbox callback bridge", () => {
|
||||
}
|
||||
|
||||
const denied: Array<{ method: string; path: string }> = [
|
||||
{ method: "POST", path: "/api/issues/issue-1/activity" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/activity/extra" },
|
||||
{ method: "GET", path: "/api/companies/co-1/activity" },
|
||||
{ method: "GET", path: "/api/companies/co-1/audit/agent-actions" },
|
||||
{ method: "GET", path: "/api/companies/co-1/agent-commentary" },
|
||||
{ method: "POST", path: "/api/companies/co-1/agent-commentary/other" },
|
||||
{ method: "POST", path: "/api/companies/co-1/email/inboxes" },
|
||||
|
||||
@@ -162,6 +162,8 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
|
||||
// Issue lifecycle: read context, checkout, update, comment, document, release
|
||||
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
|
||||
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
|
||||
// The controller still checks company membership and issue read authority.
|
||||
{ method: "GET", path: /^\/api\/issues\/[^/]+\/activity$/ },
|
||||
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
|
||||
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
|
||||
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
|
||||
|
||||
@@ -126,6 +126,33 @@ describe("activity routes", () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("lets an agent read activity for an authorized issue", async () => {
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-1", companyId: "company-1", projectId: null, parentId: null,
|
||||
assigneeAgentId: "agent-1", assigneeUserId: null, status: "done",
|
||||
});
|
||||
mockActivityService.forIssue.mockResolvedValue([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
|
||||
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "issue:read", resource: expect.objectContaining({ companyId: "company-1", issueId: "issue-1" }),
|
||||
}));
|
||||
expect(mockActivityService.forIssue).toHaveBeenCalledWith("issue-1");
|
||||
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
|
||||
});
|
||||
|
||||
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
|
||||
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getByIdentifier.mockResolvedValue(issue);
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false });
|
||||
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
|
||||
expect(res.status).toBe(kind === "company" ? 404 : 403);
|
||||
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns redacted all-actors rows to a basic company reader", async () => {
|
||||
mockAgentActionAuditService.list.mockResolvedValue({
|
||||
items: [{
|
||||
|
||||
@@ -38,6 +38,8 @@ import { deliverReconciledExecutions } from "../services/execution-recovery-reso
|
||||
import { workspaceOperationService, isNativeWorkspaceFinalizationOperationActive } from "../services/workspace-operations.js";
|
||||
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
|
||||
import { recoveryService } from "../services/recovery/service.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { withNativeWorkspaceFinalizationOwnership } from "../services/native-runtime/native-workspace-finalization-ownership.js";
|
||||
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/successful-run-handoff.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -2479,6 +2481,51 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
trigger: "read_projection",
|
||||
recoveryActionId: action.id,
|
||||
});
|
||||
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")).toMatchObject({
|
||||
actorType: "system", actorId: "recovery", agentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["done", "cancelled"])("keeps one workspace owner hold across reads and sweeps of a %s task", async (status) => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
const runId = randomUUID();
|
||||
const owner = { token: randomUUID(), hostname: "old-controller.invalid", pid: 42, processStartedAt: new Date(0).toISOString() };
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId, companyId, agentId: coderId, nativeIssueId: sourceIssueId,
|
||||
runtimeMode: "native", status: "failed", finishedAt: new Date(),
|
||||
runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner },
|
||||
});
|
||||
await db.update(issues).set({ status }).where(eq(issues.id, sourceIssueId));
|
||||
const userId = "board-reader";
|
||||
const app = createApp({ type: "board", source: "local_implicit", userId });
|
||||
const copyback = vi.fn();
|
||||
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
|
||||
let firstAction: typeof issueRecoveryActions.$inferSelect | undefined;
|
||||
for (let tick = 0; tick < 3; tick += 1) {
|
||||
expect(await withNativeWorkspaceFinalizationOwnership({ db, companyId, runId }, copyback)).toEqual({ acquired: false });
|
||||
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
|
||||
expect(detail.body.activeRecoveryAction).toMatchObject({
|
||||
cause: "native_workspace_finalization_owner_unverified", status: "active", attemptCount: 1,
|
||||
});
|
||||
await request(app).get(`/api/companies/${companyId}/issues?view=compact`).expect(200);
|
||||
await request(app).get(`/api/issues/${sourceIssueId}/recovery-actions`).expect(200);
|
||||
await recovery.reconcileStrandedAssignedIssues();
|
||||
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, sourceIssueId));
|
||||
expect(actions).toHaveLength(1);
|
||||
firstAction ??= actions[0]!;
|
||||
expect(actions[0]).toEqual(firstAction);
|
||||
}
|
||||
expect(copyback).not.toHaveBeenCalled();
|
||||
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId))).toHaveLength(0);
|
||||
|
||||
// Only clearing the exact physical owner receipt makes this hold stale.
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, runId));
|
||||
const cleared = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
|
||||
expect(cleared.body.activeRecoveryAction).toBeNull();
|
||||
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId));
|
||||
expect(rows).toEqual([expect.objectContaining({ actorType: "system", actorId: "recovery", action: "issue.recovery_action_resolved" })]);
|
||||
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps active recovery visible when a plain comment does not create a live path", async () => {
|
||||
|
||||
@@ -668,6 +668,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores historical reader-attributed recovery cleanup without hiding explicit recovery decisions", async () => {
|
||||
const companyId = await seedAssignableAgentCompany();
|
||||
const userId = "board-user";
|
||||
const old = new Date("2026-03-26T10:00:00Z");
|
||||
const recent = new Date("2026-03-26T15:00:00Z");
|
||||
const variants = [
|
||||
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "read_projection" }, counts: false },
|
||||
{ id: randomUUID(), details: null, counts: true },
|
||||
{ id: randomUUID(), details: { outcome: "restored" }, counts: true },
|
||||
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "issue_update" }, counts: true },
|
||||
];
|
||||
await db.insert(issues).values(variants.map(({ id }) => ({
|
||||
id, companyId, title: "Recovery audit", status: "done", updatedAt: old,
|
||||
})));
|
||||
await db.insert(activityLog).values(variants.map(({ id, details }) => ({
|
||||
companyId, actorType: "user", actorId: userId, action: "issue.recovery_action_resolved",
|
||||
entityType: "issue", entityId: id, details, createdAt: recent,
|
||||
})));
|
||||
await db.insert(issueComments).values(variants.map(({ id }) => ({
|
||||
companyId, issueId: id, authorUserId: "other-user", body: "An older update", createdAt: old,
|
||||
})));
|
||||
|
||||
const expectedIds = variants.filter(v => v.counts).map(v => v.id).sort();
|
||||
expect((await svc.list(companyId, { touchedByUserId: userId })).map(v => v.id).sort()).toEqual(expectedIds);
|
||||
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(expectedIds.length);
|
||||
const rows = await svc.list(companyId, {});
|
||||
for (const variant of variants) {
|
||||
expect(rows.find(v => v.id === variant.id)?.lastActivityAt).toEqual(variant.counts ? recent : old);
|
||||
}
|
||||
// Audit evidence is preserved, including the wrongly attributed old row.
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(variants.length);
|
||||
});
|
||||
|
||||
function agentRow(companyId: string, input: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { enqueueFastResponse } from "../services/fast-responses.js";
|
||||
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
|
||||
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js";
|
||||
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
|
||||
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
@@ -4343,6 +4344,10 @@ export function issueRoutes(
|
||||
: input.activeRecoveryAction;
|
||||
if (!activeRecoveryAction) return null;
|
||||
|
||||
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, activeRecoveryAction)) {
|
||||
return activeRecoveryAction;
|
||||
}
|
||||
|
||||
const resolutionNote = await classifySourceRecoveryRevalidation(input);
|
||||
if (!resolutionNote) return activeRecoveryAction;
|
||||
|
||||
@@ -4356,13 +4361,12 @@ export function issueRoutes(
|
||||
});
|
||||
if (!resolved) return activeRecoveryAction;
|
||||
|
||||
const actor = input.actor;
|
||||
await logActivity(db, {
|
||||
companyId: input.issue.companyId,
|
||||
actorType: actor?.actorType ?? "system",
|
||||
actorId: actor?.actorId ?? "system",
|
||||
agentId: actor?.agentId ?? null,
|
||||
runId: actor?.runId ?? null,
|
||||
actorType: "system",
|
||||
actorId: "recovery",
|
||||
agentId: null,
|
||||
runId: input.actor?.runId ?? null,
|
||||
action: "issue.recovery_action_resolved",
|
||||
entityType: "issue",
|
||||
entityId: input.issue.id,
|
||||
|
||||
@@ -2834,6 +2834,16 @@ const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [
|
||||
"issue.work_product_updated",
|
||||
] as const;
|
||||
|
||||
// Old read projections attributed this automatic cleanup to the reader. Keep
|
||||
// the audit rows, but do not count them as participation or issue activity.
|
||||
function automaticReadRecoveryCleanupCondition() {
|
||||
return sql<boolean>`(
|
||||
${activityLog.action} = 'issue.recovery_action_resolved'
|
||||
AND COALESCE(${activityLog.details}->>'source', '') = 'source_revalidation'
|
||||
AND COALESCE(${activityLog.details}->>'trigger', '') = 'read_projection'
|
||||
)`;
|
||||
}
|
||||
|
||||
function touchedByUserCondition(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
@@ -2847,6 +2857,7 @@ function touchedByUserCondition(companyId: string, userId: string) {
|
||||
AND ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.actorType} = 'user'
|
||||
AND ${activityLog.actorId} = ${userId}
|
||||
AND NOT ${automaticReadRecoveryCleanupCondition()}
|
||||
AND ${activityLog.action} IN (${sql.join(
|
||||
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
|
||||
(action) => sql`${action}`,
|
||||
@@ -2952,6 +2963,7 @@ function issueLatestLogAtExpr(companyId: string) {
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND NOT ${automaticReadRecoveryCleanupCondition()}
|
||||
AND ${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||
sql`, `,
|
||||
@@ -5127,6 +5139,7 @@ async function lastActivityStatsForIssues(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, issueIdChunk),
|
||||
sql`NOT ${automaticReadRecoveryCleanupCondition()}`,
|
||||
sql`${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map(
|
||||
(action) => sql`${action}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
@@ -74,11 +75,18 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
|
||||
const prior = readOwner(rawPrior);
|
||||
if (rawPrior != null) {
|
||||
const requireStopVerification = async () => {
|
||||
if (run.issueId) await issueRecoveryActionService(input.db).upsertSourceScoped({
|
||||
const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior });
|
||||
const recovery = issueRecoveryActionService(input.db);
|
||||
const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null;
|
||||
// Rechecking the same physical owner is not a new recovery attempt.
|
||||
if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) {
|
||||
return { acquired: false } as const;
|
||||
}
|
||||
if (run.issueId) await recovery.upsertSourceScoped({
|
||||
companyId: input.companyId, sourceIssueId: run.issueId,
|
||||
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId,
|
||||
cause: "native_workspace_finalization_owner_unverified",
|
||||
fingerprint: nativeSha256({ runId: input.runId, owner: rawPrior }),
|
||||
cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED,
|
||||
fingerprint,
|
||||
evidence: { runId: input.runId, owner: rawPrior },
|
||||
nextAction: "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.",
|
||||
wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { isUuidLike, type IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export const NATIVE_WORKSPACE_OWNER_UNVERIFIED = "native_workspace_finalization_owner_unverified";
|
||||
|
||||
/** A terminal task does not prove that an older controller's copyback stopped. */
|
||||
export async function hasUnverifiedNativeWorkspaceFinalizationOwner(
|
||||
db: Db,
|
||||
action: Pick<IssueRecoveryAction, "companyId" | "sourceIssueId" | "cause" | "fingerprint" | "evidence">,
|
||||
): Promise<boolean> {
|
||||
if (action.cause !== NATIVE_WORKSPACE_OWNER_UNVERIFIED) return false;
|
||||
const runId = action.evidence?.runId;
|
||||
if (typeof runId !== "string" || !isUuidLike(runId)) return false;
|
||||
const [run] = await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, action.companyId),
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.nativeIssueId, action.sourceIssueId),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
)).limit(1);
|
||||
const owner = run?.profile?.nativeWorkspaceFinalizationOwner;
|
||||
return owner != null && action.fingerprint === nativeSha256({ runId, owner });
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
|
||||
import { budgetService } from "../budgets.js";
|
||||
import { unadmittedChatWakeupCondition } from "../durable-chat-wakeup.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../native-runtime/native-workspace-finalization-recovery-state.js";
|
||||
import {
|
||||
legacyExecutionNeedsReconciliationWithEvidence,
|
||||
terminalizeLegacyExecution,
|
||||
@@ -3428,6 +3429,10 @@ export function recoveryService(
|
||||
issueIds: [] as string[],
|
||||
};
|
||||
for (const { action, issue } of rows) {
|
||||
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, action)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const wakePolicy = parseObject(action.wakePolicy);
|
||||
const wakePolicyType = readNonEmptyString(wakePolicy.type);
|
||||
if (
|
||||
|
||||
@@ -131,6 +131,8 @@ If already checked out by you, returns normally. If owned by another agent: `409
|
||||
|
||||
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
|
||||
|
||||
For attribution questions, read `GET /api/issues/{issueId}/activity`. Sandbox agents can use this issue-scoped route subject to the same company and issue-read authorization. Automatic recovery cleanup is system activity, not evidence that the person viewing a task participated in it.
|
||||
|
||||
If the run prompt includes a Paperclip wake payload, inspect that section before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
|
||||
|
||||
Use comments incrementally:
|
||||
@@ -702,6 +704,7 @@ If `plan` already exists, first `GET /api/issues/{issueId}/documents/plan` and r
|
||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||
| Compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
||||
| Issue activity and attribution | `GET /api/issues/:issueId/activity` |
|
||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||
| Get comments / delta / single | `GET /api/issues/:issueId/comments[?after=:commentId&order=asc]` • `/comments/:commentId` |
|
||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||
|
||||
@@ -107,6 +107,12 @@ POST /api/companies/company-1/exports
|
||||
}
|
||||
```
|
||||
|
||||
### Issue Activity (`GET /api/issues/:issueId/activity`)
|
||||
|
||||
Returns the issue's activity history, including actor, action, timestamp, and sanitized details. Sandbox agents can call this read-only route for issues their identity may read. Company boundaries and issue-read authorization still apply; this does not grant company-wide audit access.
|
||||
|
||||
An automatic stale-recovery cancellation has `action: "issue.recovery_action_resolved"` and `details.source: "source_revalidation"`. Page reads use `details.trigger: "read_projection"`. These cancellations belong to the system. Historical reader-attributed rows with that source and trigger do not count as inbox participation or recent issue activity.
|
||||
|
||||
### Issue with Ancestors (`GET /api/issues/:issueId`)
|
||||
|
||||
Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy.
|
||||
|
||||
Reference in new issue
Block a user