mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Revert "PAP-2731 Implement sky explicit-wait UI surfaces"
This reverts commit 08a9e0caf0.
This commit is contained in:
1 parent
0a345e9648
commit
caf37a355d
43 files changed
+181
-4777
No files matched your search
@@ -1,29 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { filterIssuesByLookback, issueLookbackTimestamp } from "./preview-legacy-invalid-execution-dispositions.ts";
|
||||
|
||||
describe("legacy execution-disposition preview lookback", () => {
|
||||
it("scopes default repair candidates by issue creation time, not recent updates", () => {
|
||||
const cutoff = new Date("2026-04-27T12:00:00.000Z");
|
||||
const oldButRecentlyUpdated = {
|
||||
id: "old",
|
||||
title: "old blocked issue",
|
||||
status: "blocked" as const,
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-29T12:00:00.000Z",
|
||||
};
|
||||
const newlyCreated = {
|
||||
id: "new",
|
||||
title: "new blocked issue",
|
||||
status: "blocked" as const,
|
||||
createdAt: "2026-04-28T12:00:00.000Z",
|
||||
updatedAt: "2026-04-28T12:00:00.000Z",
|
||||
};
|
||||
|
||||
assert.equal(issueLookbackTimestamp(oldButRecentlyUpdated)?.toISOString(), "2026-04-20T12:00:00.000Z");
|
||||
assert.deepEqual(filterIssuesByLookback([oldButRecentlyUpdated, newlyCreated], cutoff).map((issue) => issue.id), [
|
||||
"new",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,390 +0,0 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
type IssueStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
||||
|
||||
type InvalidDisposition = {
|
||||
kind: "invalid";
|
||||
reason: string;
|
||||
suggestedCorrection: string;
|
||||
};
|
||||
|
||||
type IssueSummary = {
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
createdAt?: string | Date | null;
|
||||
updatedAt?: string | Date | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
executionDisposition?: { kind: string } | InvalidDisposition;
|
||||
blockerAttention?: {
|
||||
state?: string | null;
|
||||
reason?: string | null;
|
||||
sampleBlockerIdentifier?: string | null;
|
||||
sampleStalledBlockerIdentifier?: string | null;
|
||||
nextActionOwner?: unknown;
|
||||
nextActionHint?: string | null;
|
||||
} | null;
|
||||
blockedBy?: Array<{
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PreviewGroup = {
|
||||
reason: string;
|
||||
suggestedCorrection: string;
|
||||
status: string;
|
||||
owner: string;
|
||||
blockerAttentionSample: string;
|
||||
proposedAction: string;
|
||||
issues: IssueSummary[];
|
||||
};
|
||||
|
||||
const NON_TERMINAL_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
||||
const PAGE_LIMIT = 1000;
|
||||
const DEFAULT_LOOKBACK_HOURS = 48;
|
||||
|
||||
function readFlag(name: string): string | null {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0) return null;
|
||||
const value = process.argv[index + 1];
|
||||
return value && !value.startsWith("--") ? value : null;
|
||||
}
|
||||
|
||||
function requireConfig(name: string, value: string | null | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readPositiveIntegerFlag(name: string): number | null {
|
||||
const raw = readFlag(name);
|
||||
if (raw === null) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function dateValue(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function issueLookbackTimestamp(issue: IssueSummary): Date | null {
|
||||
return dateValue(issue.createdAt);
|
||||
}
|
||||
|
||||
export function filterIssuesByLookback(issues: IssueSummary[], cutoff: Date | null): IssueSummary[] {
|
||||
if (!cutoff) return issues;
|
||||
return issues.filter((issue) => {
|
||||
const timestamp = issueLookbackTimestamp(issue);
|
||||
return timestamp ? timestamp >= cutoff : false;
|
||||
});
|
||||
}
|
||||
|
||||
function ownerKey(issue: IssueSummary): string {
|
||||
if (issue.assigneeAgentId && issue.assigneeUserId) {
|
||||
return `dual:agent:${issue.assigneeAgentId}:user:${issue.assigneeUserId}`;
|
||||
}
|
||||
if (issue.assigneeAgentId) return `agent:${issue.assigneeAgentId}`;
|
||||
if (issue.assigneeUserId) return `user:${issue.assigneeUserId}`;
|
||||
return "unassigned";
|
||||
}
|
||||
|
||||
function blockerOwnerKey(issue: IssueSummary): string {
|
||||
if (issue.assigneeAgentId) return `agent:${issue.assigneeAgentId}`;
|
||||
if (issue.assigneeUserId) return `user:${issue.assigneeUserId}`;
|
||||
return "unassigned";
|
||||
}
|
||||
|
||||
function sampleValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.type === "string" && typeof record.agentId === "string") {
|
||||
return `${record.type}:${record.agentId}`;
|
||||
}
|
||||
if (typeof record.type === "string" && typeof record.userId === "string") {
|
||||
return `${record.type}:${record.userId}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function blockerAttentionSample(issue: IssueSummary): string {
|
||||
const attention = issue.blockerAttention;
|
||||
if (attention && attention.state && attention.state !== "none") {
|
||||
const parts = [
|
||||
attention.state,
|
||||
attention.reason,
|
||||
attention.sampleBlockerIdentifier ? `sample:${attention.sampleBlockerIdentifier}` : null,
|
||||
attention.sampleStalledBlockerIdentifier ? `stalled:${attention.sampleStalledBlockerIdentifier}` : null,
|
||||
attention.nextActionOwner ? `owner:${sampleValue(attention.nextActionOwner)}` : null,
|
||||
attention.nextActionHint ? `hint:${attention.nextActionHint}` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
const sampleBlocker = issue.blockedBy?.[0];
|
||||
if (sampleBlocker) {
|
||||
return `blocker:${sampleBlocker.identifier ?? sampleBlocker.id} status:${sampleBlocker.status} owner:${blockerOwnerKey(sampleBlocker)}`;
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
function hasSafeCancelledBlockerCorrection(issue: IssueSummary): boolean {
|
||||
if (issue.status !== "blocked") return false;
|
||||
const blockers = issue.blockedBy ?? [];
|
||||
const cancelledBlockerCount = blockers.filter((blocker) => blocker.status === "cancelled").length;
|
||||
const remainingUnresolvedBlockerCount = blockers.filter(
|
||||
(blocker) => blocker.status !== "cancelled" && blocker.status !== "done",
|
||||
).length;
|
||||
return cancelledBlockerCount > 0 && remainingUnresolvedBlockerCount > 0;
|
||||
}
|
||||
|
||||
function proposedAction(reason: string, issue: IssueSummary): string {
|
||||
if (reason === "blocked_by_cancelled_issue" && hasSafeCancelledBlockerCorrection(issue)) {
|
||||
return "apply-safe: remove cancelled blocker links while preserving remaining unresolved blockers";
|
||||
}
|
||||
switch (reason) {
|
||||
case "dual_assignee":
|
||||
return "follow-up: choose exactly one owner and clear the other assignee";
|
||||
case "invalid_review_participant":
|
||||
return "follow-up: repair the execution participant or replace it with a live wait path";
|
||||
case "in_review_without_action_path":
|
||||
return "follow-up: add a typed review/approval/interaction, assign a human owner, or move to an actionable status";
|
||||
case "blocked_by_invalid_issue":
|
||||
return "follow-up: repair the invalid blocker leaf first";
|
||||
case "blocked_by_cancelled_issue":
|
||||
return "follow-up: replace the cancelled blocker or define a new owner/action path";
|
||||
case "blocked_by_unassigned_issue":
|
||||
return "follow-up: assign the blocker, replace it, or escalate to a human owner";
|
||||
case "blocked_by_resting_issue":
|
||||
return "follow-up: give the blocker a lawful next action, replace it, or escalate";
|
||||
case "blocked_without_action_path":
|
||||
return "follow-up: add first-class blockers, a live wait primitive, or an external owner/action";
|
||||
default:
|
||||
return "follow-up: inspect and define the lawful next action";
|
||||
}
|
||||
}
|
||||
|
||||
let apiUrl: string;
|
||||
let apiKey: string;
|
||||
let runId: string | undefined;
|
||||
|
||||
function configureApiFromEnvironment() {
|
||||
apiUrl = requireConfig(
|
||||
"PAPERCLIP_API_URL or --api-url",
|
||||
readFlag("--api-url") ?? process.env.PAPERCLIP_API_URL,
|
||||
);
|
||||
apiKey = requireConfig("PAPERCLIP_API_KEY", process.env.PAPERCLIP_API_KEY);
|
||||
runId = process.env.PAPERCLIP_RUN_ID?.trim() || undefined;
|
||||
}
|
||||
|
||||
async function requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = new URL(path, apiUrl);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...(runId ? { "X-Paperclip-Run-Id": runId } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${method} ${url.pathname}${url.search} failed: ${response.status} ${text}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function listNonTerminalIssues(companyId: string): Promise<IssueSummary[]> {
|
||||
const result: IssueSummary[] = [];
|
||||
for (const status of NON_TERMINAL_STATUSES) {
|
||||
for (let offset = 0; ; offset += PAGE_LIMIT) {
|
||||
const query = new URLSearchParams({
|
||||
status,
|
||||
includeBlockedBy: "true",
|
||||
limit: String(PAGE_LIMIT),
|
||||
offset: String(offset),
|
||||
});
|
||||
const page = await requestJson<IssueSummary[]>("GET", `/api/companies/${companyId}/issues?${query}`);
|
||||
result.push(...page);
|
||||
if (page.length < PAGE_LIMIT) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildGroups(issues: IssueSummary[]): PreviewGroup[] {
|
||||
const groups = new Map<string, PreviewGroup>();
|
||||
for (const issue of issues) {
|
||||
const disposition = issue.executionDisposition;
|
||||
if (disposition?.kind !== "invalid") continue;
|
||||
const invalid = disposition as InvalidDisposition;
|
||||
const action = proposedAction(invalid.reason, issue);
|
||||
const sample = blockerAttentionSample(issue);
|
||||
const key = [
|
||||
invalid.reason,
|
||||
invalid.suggestedCorrection,
|
||||
issue.status,
|
||||
ownerKey(issue),
|
||||
sample,
|
||||
action,
|
||||
].join("\u0000");
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.issues.push(issue);
|
||||
} else {
|
||||
groups.set(key, {
|
||||
reason: invalid.reason,
|
||||
suggestedCorrection: invalid.suggestedCorrection,
|
||||
status: issue.status,
|
||||
owner: ownerKey(issue),
|
||||
blockerAttentionSample: sample,
|
||||
proposedAction: action,
|
||||
issues: [issue],
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const byReason = a.reason.localeCompare(b.reason);
|
||||
if (byReason !== 0) return byReason;
|
||||
const byStatus = a.status.localeCompare(b.status);
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return b.issues.length - a.issues.length;
|
||||
});
|
||||
}
|
||||
|
||||
function formatIssue(issue: IssueSummary): string {
|
||||
return `${issue.identifier ?? issue.id} (${issue.status}, ${ownerKey(issue)}): ${issue.title}`;
|
||||
}
|
||||
|
||||
function printPreview(
|
||||
companyId: string,
|
||||
totalFetched: number,
|
||||
totalScanned: number,
|
||||
cutoff: Date | null,
|
||||
groups: PreviewGroup[],
|
||||
appliedCount: number,
|
||||
) {
|
||||
const invalidCount = groups.reduce((sum, group) => sum + group.issues.length, 0);
|
||||
const safeCandidateCount = groups.reduce(
|
||||
(sum, group) => sum + group.issues.filter(hasSafeCancelledBlockerCorrection).length,
|
||||
0,
|
||||
);
|
||||
const byReason = new Map<string, number>();
|
||||
for (const group of groups) {
|
||||
byReason.set(group.reason, (byReason.get(group.reason) ?? 0) + group.issues.length);
|
||||
}
|
||||
|
||||
console.log(`# Legacy execution-disposition reconciliation preview`);
|
||||
console.log("");
|
||||
console.log(`Company: ${companyId}`);
|
||||
console.log(`Lookback: ${cutoff ? `created since ${cutoff.toISOString()}` : "all non-terminal issues"}`);
|
||||
console.log(`Non-terminal issues fetched: ${totalFetched}`);
|
||||
console.log(`Non-terminal issues scanned: ${totalScanned}`);
|
||||
console.log(`Invalid issues found: ${invalidCount}`);
|
||||
console.log(`Safe cancelled-blocker corrections available: ${safeCandidateCount}`);
|
||||
console.log(`Safe corrections applied: ${appliedCount}`);
|
||||
console.log("");
|
||||
if (groups.length === 0) {
|
||||
console.log("No invalid non-terminal issue dispositions found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("## Counts by invalid reason");
|
||||
for (const [reason, count] of [...byReason.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
console.log(`- ${reason}: ${count}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
for (const group of groups) {
|
||||
console.log(`## ${group.reason} (${group.issues.length})`);
|
||||
console.log(`- Status: ${group.status}`);
|
||||
console.log(`- Owner: ${group.owner}`);
|
||||
console.log(`- Blocker-attention sample: ${group.blockerAttentionSample}`);
|
||||
console.log(`- Suggested correction: ${group.suggestedCorrection}`);
|
||||
console.log(`- Proposed action: ${group.proposedAction}`);
|
||||
console.log("- Sample issues:");
|
||||
for (const issue of group.issues.slice(0, 5)) {
|
||||
console.log(` - ${formatIssue(issue)}`);
|
||||
}
|
||||
if (group.issues.length > 5) {
|
||||
console.log(` - ... ${group.issues.length - 5} more`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
async function applySafeCorrections(groups: PreviewGroup[]): Promise<number> {
|
||||
let applied = 0;
|
||||
for (const group of groups) {
|
||||
for (const issue of group.issues) {
|
||||
if (!hasSafeCancelledBlockerCorrection(issue)) continue;
|
||||
const remainingBlockerIds = (issue.blockedBy ?? [])
|
||||
.filter((blocker) => blocker.status !== "cancelled" && blocker.status !== "done")
|
||||
.map((blocker) => blocker.id);
|
||||
if (remainingBlockerIds.length === 0) continue;
|
||||
await requestJson("PATCH", `/api/issues/${issue.id}`, {
|
||||
blockedByIssueIds: remainingBlockerIds,
|
||||
comment:
|
||||
"Legacy execution-disposition reconciliation: removed cancelled blocker links while preserving remaining unresolved blockers.",
|
||||
});
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
configureApiFromEnvironment();
|
||||
const companyId = requireConfig(
|
||||
"PAPERCLIP_COMPANY_ID or --company",
|
||||
readFlag("--company") ?? process.env.PAPERCLIP_COMPANY_ID,
|
||||
);
|
||||
const applySafe = process.argv.includes("--apply-safe");
|
||||
const allIssues = process.argv.includes("--all");
|
||||
const lookbackHours = allIssues ? null : (readPositiveIntegerFlag("--since-hours") ?? DEFAULT_LOOKBACK_HOURS);
|
||||
const cutoff = lookbackHours === null ? null : new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
|
||||
|
||||
let fetchedIssues = await listNonTerminalIssues(companyId);
|
||||
let issues = filterIssuesByLookback(fetchedIssues, cutoff);
|
||||
let groups = buildGroups(issues);
|
||||
let appliedCount = 0;
|
||||
|
||||
if (applySafe) {
|
||||
appliedCount = await applySafeCorrections(groups);
|
||||
fetchedIssues = await listNonTerminalIssues(companyId);
|
||||
issues = filterIssuesByLookback(fetchedIssues, cutoff);
|
||||
groups = buildGroups(issues);
|
||||
}
|
||||
|
||||
printPreview(companyId, fetchedIssues.length, issues.length, cutoff, groups, appliedCount);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Legacy execution-disposition reconciliation preview failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user