Merge remote-tracking branch 'origin/master' into codex/dismiss-composer-requests
* origin/master: feat: track git workspace restore points and apply their retention (#17241) feat(ui): Artifacts V1 behind an experimental setting — Artifacts page, panel tab and task thread (#17228) feat: add a library to capture and restore git workspace restore points (#17238) feat(connections): add Unikraft Cloud connection (#17139) feat(ui): add the artifact directions lab with the MVP V1 kit (#17213) fix(ui): remove the provider trace run button (#17211) fix(ui): keep task tabs steady when switching views (#17068) # Conflicts: # packages/db/src/migrations/meta/0372_snapshot.json # packages/db/src/migrations/meta/_journal.json
No files matched your search
@@ -23,6 +23,24 @@
|
||||
"cd /Users/tonio/Projects/paperclip-cloud/.worktrees/deploy-stack-master/web && npx vite --port 4173 --strictPort"
|
||||
],
|
||||
"port": 4173
|
||||
},
|
||||
{
|
||||
"name": "artifact-directions-dev",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"cd ui && npx vite --config vite.artifact-directions.config.mjs --port 5180 --strictPort"
|
||||
],
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "artifact-directions",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"cd ui && export ARTIFACT_DIRECTIONS_OUT_DIR=node_modules/.artifact-directions-dist && npx vite build --config vite.artifact-directions.config.mjs && npx vite preview --config vite.artifact-directions.config.mjs --port 5190 --strictPort"
|
||||
],
|
||||
"port": 5190
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2583,6 +2583,10 @@ Environment overrides:
|
||||
negative, empty, or non-numeric value falls back to the default.
|
||||
`PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED=false` turns expiry off, and
|
||||
`PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DRY_RUN=true` only reports candidates.
|
||||
- `PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS=<days>` sets how long a git workspace
|
||||
restore point is kept after its issue tree closes (see
|
||||
`doc/recoverable-disk-cleanup.md`). The default is `90` and the minimum is `1`. A
|
||||
negative, empty, or non-numeric value falls back to the default.
|
||||
|
||||
Without `PAPERCLIP_DB_BACKUP_ALERT_FILE`, health checks look for
|
||||
`db-backup-to-s3.failure` in the backup directory, beside the backup directory,
|
||||
|
||||
@@ -11,7 +11,8 @@ ledger; the counts above are not restated. Later additions: [Neon](./NEON.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-02), [Superagent](./SUPERAGENT.md)
|
||||
(`mcp-api-key`; 2026-10-06), [Gauge](./GAUGE.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-08), [Ditto](./DITTO.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-10).
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-10), [Unikraft](./UNIKRAFT.md)
|
||||
(`api-token`; 2026-10-15).
|
||||
|
||||
## Shared credential failure
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Long-term memory: [Experimental memory connectors](./MEMORY.md).
|
||||
|
||||
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
|
||||
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Braintrust](./BRAINTRUST.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [Ditto](./DITTO.md),
|
||||
[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
|
||||
[Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
|
||||
[Enterpret](./ENTERPRET.md). Optional credential custody:
|
||||
[Vercel Connect](./VERCEL-CONNECT.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Unikraft connection
|
||||
|
||||
Unikraft Cloud runs unikernel micro-VMs ("instances") across regional metros.
|
||||
Its connection uses the normal Apps connection, selected personal/company
|
||||
credential, agent installation, action policy, gateway, revocation and audit
|
||||
paths. The API token stays in the instance vault. The connector does not borrow
|
||||
`unikraft login` state, `UKC_TOKEN` environment variables, or another
|
||||
connection's credential.
|
||||
|
||||
Sign-in tokens are never reused as resource tokens; id.paperclip.ing never
|
||||
stores resource tokens; no connections hub on the ID service.
|
||||
|
||||
## Vendor
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Product | Unikraft Cloud (formerly KraftCloud) |
|
||||
| API reference | https://unikraft.com/docs/api/platform/v1 |
|
||||
| Specification | https://github.com/unikraft-cloud/openapi (`platform.json`, `plugins/sandbox.json`) |
|
||||
| Metros | https://unikraft.com/docs/platform/metros |
|
||||
| Tokens | https://unikraft.com/docs/sdks/overview#authentication |
|
||||
| Console | https://console.unikraft.com |
|
||||
| Hosted MCP server | None. Unikraft's MCP guides describe hosting your own MCP server on Unikraft. |
|
||||
|
||||
## Transport and auth
|
||||
|
||||
| Axis | Value |
|
||||
| --- | --- |
|
||||
| Transport | `rest_api`, executed by Paperclip's native provider bridge |
|
||||
| Authentication | `api_key`, sent as `Authorization: Bearer <token>` |
|
||||
| OAuth client ownership | Not applicable; Unikraft offers no third-party OAuth |
|
||||
| Credential source | Paperclip vault |
|
||||
| Grant identity | `organization` or `user` |
|
||||
|
||||
Unikraft Cloud has no global platform endpoint. Each metro serves its own API at
|
||||
`https://api.<metro>.unikraft.cloud/v1/...`. The reviewed specification
|
||||
publishes five metros: `dal`, `fra`, `sfo`, `sin` and `was`. Every action accepts
|
||||
an optional `metro` argument limited to that list; actions that omit it use the
|
||||
connection's default metro (Frankfurt unless changed under **Advanced**). The
|
||||
token is never sent to any other host. Legacy `*.kraft.cloud` hosts, which
|
||||
Unikraft documents as nearing end of life, are not reachable through this
|
||||
connection.
|
||||
|
||||
The saved connection URL, `https://controlplane.unikraft.cloud/v1`, identifies
|
||||
the connection only. Actions use the metro hosts above.
|
||||
|
||||
## Connection flow (mandatory)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Operator
|
||||
participant UI as Paperclip UI
|
||||
participant Server as Paperclip server
|
||||
participant Vault as Instance vault
|
||||
participant Metro as Unikraft metro API
|
||||
actor Agent
|
||||
Operator->>UI: Connectors, Unikraft, Add key
|
||||
UI->>Server: POST /api/companies/{companyId}/tools/apps/connect (galleryKey unikraft, token, optional default metro)
|
||||
Server->>Vault: Store the token as a company or user secret
|
||||
Server->>Metro: GET /v1/users/quotas with the stored token
|
||||
Metro-->>Server: status success
|
||||
Server-->>UI: Generated catalog of 105 actions
|
||||
Operator->>UI: Choose agents and action permissions
|
||||
Agent->>Server: Gateway tool call, for example unikraft_get_instances
|
||||
Server->>Server: Check grant, revocation, policy and saved contract
|
||||
Server->>Metro: Request to api.{metro}.unikraft.cloud with the vaulted token
|
||||
Metro-->>Server: JSON result
|
||||
Server-->>Agent: Result with the token redacted
|
||||
```
|
||||
|
||||
Paperclip endpoints used: `GET /api/companies/{companyId}/tools/apps/unikraft/preflight`,
|
||||
`POST /api/companies/{companyId}/tools/apps/connect`,
|
||||
`POST /api/companies/{companyId}/tools/apps/{connectionId}/finish`,
|
||||
`GET /api/tool-connections/{connectionId}/catalog`, and the run-bound tool gateway.
|
||||
There is no OAuth handoff, so `GET /api/tools/oauth/callback` is not used.
|
||||
|
||||
## Administrator setup (mandatory)
|
||||
|
||||
1. Sign in to the [Unikraft Cloud console](https://console.unikraft.com) and
|
||||
create an API token for the organization agents should manage.
|
||||
2. In Paperclip choose **Connectors → Unikraft → Add key**, choose who can use
|
||||
the credential, and paste the token.
|
||||
3. Optional: under **Advanced**, change the default metro.
|
||||
4. On the saved connection's **Permissions** screen, choose agents and set
|
||||
actions to Allowed, Ask first, or Off.
|
||||
|
||||
A token acts for its whole organization in every metro. Unikraft does not offer
|
||||
narrower token permissions, so agent assignment and action permissions enforce
|
||||
the capability boundary. Revoke a token in the Unikraft console, then remove or
|
||||
reconnect the Paperclip connection.
|
||||
|
||||
## Resource filters
|
||||
|
||||
There are no provider-enforced resource filters. `requiredResourceFilters`
|
||||
(`organization`, `metro`, `instance`) is reviewed metadata. The bridge enforces
|
||||
only that requests go to a published metro and that path arguments cannot
|
||||
traverse to another route.
|
||||
|
||||
## Actions
|
||||
|
||||
The catalog contains **105 actions** generated from Unikraft's published
|
||||
specifications at commit `5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b`:
|
||||
89 platform API operations and 16 sandbox plugin operations.
|
||||
|
||||
| Group | Examples | Classification |
|
||||
| --- | --- | --- |
|
||||
| Reads, logs, metrics, history and waits | `unikraft_get_instances`, `unikraft_get_instance_logs_by_uuid`, `unikraft_wait_instances`, `unikraft_sandbox_read_file` | Read |
|
||||
| Creation, updates, start, attach, clone and pinning | `unikraft_create_instance`, `unikraft_update_service_group_by_uuid`, `unikraft_start_instances`, `unikraft_sandbox_write_file` | Write |
|
||||
| Deletion, stop, suspend, detach, unpin, and sandbox command execution, signals and stdin | `unikraft_delete_volume_by_uuid`, `unikraft_stop_instances`, `unikraft_sandbox_run_command` | Destructive |
|
||||
|
||||
Platform actions keep Unikraft's operation names (`unikraft_<operation>`).
|
||||
Sandbox plugin actions are `unikraft_sandbox_<operation>` and take an
|
||||
`instance` (name or UUID); they call
|
||||
`https://api.<metro>.unikraft.cloud/v1/instances/<instance>/plugins/sandbox`.
|
||||
Many Unikraft operations select batch targets with a JSON body, including on
|
||||
GET and DELETE; the bridge sends those bodies as published.
|
||||
`unikraft_sandbox_wait_for_command_with_timeout` returns
|
||||
`{ "status": "running", "timedOut": true }` when Unikraft reports that the
|
||||
command is still running at the timeout (HTTP 408), so an agent can wait again.
|
||||
The bridge lives in `server/src/services/connectors/unikraft/`.
|
||||
|
||||
The audit event stream (`GET /v1/audit`) is server-sent events, not a
|
||||
request/response action. It is recorded in the generated inventory and not
|
||||
advertised as a tool.
|
||||
|
||||
The specification sources and SHA-256 hashes are recorded in
|
||||
`server/src/services/connectors/unikraft/operations.generated.json`. Regenerate with:
|
||||
|
||||
```sh
|
||||
node scripts/generate-unikraft-tools.mjs
|
||||
node --test scripts/generate-unikraft-tools.test.mjs
|
||||
```
|
||||
|
||||
The generator pins a reviewed commit because the specification repository has
|
||||
no tags or releases. Updating it requires explicit regeneration and catalog
|
||||
review: saved connections quarantine new and changed actions until reviewed.
|
||||
|
||||
## Wizard path
|
||||
|
||||
The ordinary key wizard: **Access → Connect**. Setup asks only for the token.
|
||||
The default metro is an optional select under **Advanced**. Setup completes when
|
||||
`GET /v1/users/quotas` on the default metro confirms the token.
|
||||
|
||||
## Governance defaults
|
||||
|
||||
- Risk tier S4: the normal catalog includes deletion and arbitrary command
|
||||
execution inside sandboxes.
|
||||
- Every discovered action is enabled when setup finishes, under the shared
|
||||
connection access rules; operators restrict actions on Permissions.
|
||||
- New and changed actions after a catalog update are quarantined until reviewed.
|
||||
- Agents cannot export the token; generic credential export rejects Unikraft.
|
||||
- Responses and request bodies are bounded at 8 MiB. Each action has one caller
|
||||
deadline, capped at 30 seconds. Mutations are never retried automatically; a
|
||||
timed-out write can still complete at the provider.
|
||||
|
||||
## Manifest
|
||||
|
||||
Defined in `scripts/app-definition-overrides/unikraft.json` and generated into
|
||||
`packages/shared/src/app-definitions/unikraft.json`: slug `unikraft`, category
|
||||
`developer`, one method `api-token` (`rest_api`, `api_key`, `customer`), and a
|
||||
`metro` select tenant field.
|
||||
|
||||
## Validation hook
|
||||
|
||||
Database-backed tests exercise company and personal vault ownership, real
|
||||
run-scoped gateway reads, writes, deletions and sandbox commands, default and
|
||||
per-call metros, rejection of unpublished metros and path traversal, Off
|
||||
policies, revocation, stale-contract blocking, credential export denial, all
|
||||
generated argument schemas, and bounded/sanitized errors. HTTP boundary tests
|
||||
send GET and DELETE JSON bodies through the real pinned transport to a local
|
||||
server. Generator tests cover streaming endpoints, shared parameter references,
|
||||
unsigned integer formats and risk classification.
|
||||
|
||||
These fixtures do not qualify a real Unikraft account. Live provider proof is
|
||||
pending an operator connecting a token and trying an allowed read and write
|
||||
(`liveProof: not-run`).
|
||||
|
||||
## Brand
|
||||
|
||||
`ui/public/brands/apps/unikraft.svg` is Unikraft's gradient mark from the
|
||||
official documentation site, copied from
|
||||
`https://raw.githubusercontent.com/unikraft-cloud/docs/d8acfcefe8186badac84d5308d641a1c1b5b239e/public/unikraft.svg`
|
||||
with editor metadata removed. One asset serves both themes. Unikraft publishes
|
||||
no brand kit.
|
||||
|
||||
Provider research verified 2026-10-15.
|
||||
@@ -2879,6 +2879,24 @@
|
||||
"liveProof": "not-run",
|
||||
"evidenceLimit": "Public provider documentation and OAuth metadata only. Account-bound connect, discovery, read/write execution, run-scoped gateway, refresh/reconnect, removal, and secret handling remain to be qualified for this method.",
|
||||
"keyPermissions": "Create a Braintrust API key in Settings → API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
|
||||
},
|
||||
{
|
||||
"app": "unikraft",
|
||||
"method": "api-token",
|
||||
"auth": "api_key",
|
||||
"policy": "provider-key",
|
||||
"requestedScopes": [],
|
||||
"capability": "write",
|
||||
"supportedActions": "All published Unikraft Cloud platform API operations (instances, checkpoints, templates, service groups, autoscale, volumes, images, certificates, and quotas) and sandbox plugin operations (commands and files) in every published metro. The audit event stream is not exposed.",
|
||||
"evidence": [
|
||||
"https://unikraft.com/docs/sdks/overview#authentication",
|
||||
"https://unikraft.com/docs/platform/metros",
|
||||
"https://raw.githubusercontent.com/unikraft-cloud/openapi/5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b/platform.json",
|
||||
"https://raw.githubusercontent.com/unikraft-cloud/openapi/5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b/plugins/sandbox.json"
|
||||
],
|
||||
"reviewedAt": "2026-10-15",
|
||||
"liveProof": "not-run",
|
||||
"keyPermissions": "Create an API token in the Unikraft Cloud console. A token acts for its whole organization in every metro, including running sandbox commands and deleting resources. Unikraft does not offer narrower token permissions, so choose trusted agents and use action permissions to limit what they can do."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# Artifacts MVP V1 — shipping the lab's direction into the product
|
||||
|
||||
Date: 2026-10-22. Status: all four slices on `claude/artifacts-mvp-v1` (draft PR #17228); a browser pass on live data is the remaining check before the PR leaves draft.
|
||||
|
||||
## Role of this document
|
||||
|
||||
The artifact directions lab (`ui/src/artifact-directions`, merged in #17213) settled a
|
||||
presentation for artifacts on three surfaces: the Artifacts page, the right panel's
|
||||
Artifacts tab and the task thread. The lab draws it from fixtures on static mocks of the
|
||||
chrome. This plan turns that reference into product code, one surface at a time, behind an
|
||||
experimental setting, without changing the artifact data model where the lab's numbers do
|
||||
not need it.
|
||||
|
||||
The lab stays the visual reference: every measurement below is the one the lab verified
|
||||
against the PCLP-Core mocks (node 2129:133457, 2096:87691, 2096:86523, 2205:138266).
|
||||
|
||||
## Locked decisions
|
||||
|
||||
1. **Gated by `/instance/settings > Experimental > Artifacts V1`** (`enableArtifactsV1`,
|
||||
default off), the same plumbing as `enableCombinedInboxTasks`: shared type, validator,
|
||||
feature catalog, server defaults, managed-key list, UI hook, settings toggle. The old
|
||||
presentation stays the fallback until the setting is removed in a later cleanup.
|
||||
2. **Token-only.** Every colour, radius, shadow, size and font value the lab kept in its
|
||||
scoped `tokens.css` becomes an `--artifact-*` token in `ui/src/index.css`, with the light
|
||||
theme's values as overrides, so the token gates pass and the theme editor can reach them.
|
||||
Artwork geometry (sheet 180×216, glyph at (58, 58), bars on a 26px pitch, stack centres)
|
||||
lives in one constants module, not in class names.
|
||||
3. **Reuse the lab's code, not its types.** The primitives move to
|
||||
`ui/src/components/artifacts/v1/` and take `CompanyArtifact`, `IssueWorkProduct`,
|
||||
issue documents and attachments through small adapters, the way the lab's Today kit
|
||||
adapted the other direction. The lab keeps its own copies; it is a reference, not a
|
||||
dependency of product code.
|
||||
4. **No new API fields for the first three slices.** The Artifacts page tiles show title,
|
||||
identifier, count and "Updated …"; the panel and thread rows get sizes from the issue
|
||||
work-product and attachment payloads they already have. A picture's aspect ratio comes
|
||||
from the loaded image (`naturalWidth / naturalHeight`), 4:3 until it loads.
|
||||
5. **Reduced motion is respected**: the hover movement (page turn, 6px rise) applies only
|
||||
under `prefers-reduced-motion: no-preference`; colour fades stay.
|
||||
|
||||
## Slices
|
||||
|
||||
### Slice 1 — foundation (done)
|
||||
|
||||
- Experimental setting `enableArtifactsV1` end to end, with `useArtifactsV1Enabled()`.
|
||||
- `--artifact-*` tokens in `index.css` (paper, ink, faint ink, sheet and print radii, sheet
|
||||
and print shadows with light overrides, grid column minimum, frame and tile heights, tile
|
||||
hover fill and radius, row fill, hover, radius and gap, play scrim, hover easing).
|
||||
- Primitives under `ui/src/components/artifacts/v1/`: kind helpers (`treatmentOf`,
|
||||
`labelOf`, `glyphOf`, `mediaKindOf`), `ArtifactSheet` (preview and icon faces),
|
||||
`ArtifactPrint` (bordered picture, fitted to a box, play disc for video),
|
||||
`ArtifactStack` (the two backings), `ArtifactFront`, `ArtifactTile` (stack and single
|
||||
tiles with the caption and the Paper Mono pill), `ArtifactRow` (the 40px-icon row),
|
||||
date helpers, and the hover rules. Unit tests ported from the lab.
|
||||
- Nothing mounted yet. Storybook stories under `documents-artifacts/` show the primitives on
|
||||
fixtures so reviewers can see them without the flag.
|
||||
|
||||
### Slice 2 — Artifacts page (done)
|
||||
|
||||
- With the flag on, `Artifacts.tsx` renders the V1 tiles: stacks when grouped by task
|
||||
(the default), single tiles otherwise, 28px gaps, 370px cells, hover fill with the
|
||||
12px radius.
|
||||
- Filters become Tasks (default; grouped by task) · All files · Images · Videos ·
|
||||
Documents · Text · Files. The group-by menu and search stay.
|
||||
- Opened stack: the `← All stacks / ACM-3 Title` row above the single tiles.
|
||||
- Page tests updated for both flag states.
|
||||
|
||||
### Slice 3 — right panel Artifacts tab (done)
|
||||
|
||||
What the shipped tab does that the V1 rows must keep (read 2026-10-22,
|
||||
`IssuePropertiesArtifactsTab.tsx`):
|
||||
|
||||
- Three sources merged: work products (`useIssueWorkProducts`), task documents
|
||||
(`useIssueDocuments`, minus `artifact-review-*` proxies) and attachments not
|
||||
already promoted to work products (`selectArtifactAttachments`).
|
||||
- Rows are grouped by run with an agent-name header and a time; V1 flattens to
|
||||
one list, newest first (the lab's decision), and drops the headers.
|
||||
- A Markdown work product expands in place into the document review surface
|
||||
(`MarkdownWorkProductRow`, backed by the `artifact-review-<id>` document) with
|
||||
raw open and download as secondary actions; a task document expands in place
|
||||
(`DocumentRow`) or opens in its own pane tab when `onOpenDocument` is given;
|
||||
a text attachment opens in a text tab (`openTextAttachment`), other files in a
|
||||
new tab; `documentDeepLink` expands or opens the requested document; the
|
||||
upload area (`ArtifactUploadContent`) wraps the list.
|
||||
- Work products carry a status badge (`workProductStatusBadge`: in progress,
|
||||
for review, done, changes requested, failed).
|
||||
|
||||
Approach: the V1 row is the *face* of each item. Give `DocumentRow` and
|
||||
`MarkdownWorkProductRow` a `face` render prop so, with the flag on, their
|
||||
header is the V1 row (name over size · date, status badge and the open /
|
||||
download controls in the row's trailing slot) while their expansion, deep-link
|
||||
and review behaviour stay untouched; attachments and other work products render
|
||||
the V1 row directly with the same destinations the shipped cards use.
|
||||
|
||||
- `IssuePropertiesArtifactsTab` renders the V1 row list when the flag is on: newest first,
|
||||
12px apart, 40px document icon or thumbnail with a play disc, name truncating before its
|
||||
extension over "size · date" with the panel's date form (Today / Yesterday / 2D ago /
|
||||
July 23). Existing actions (open, download, review) move into the row's menu.
|
||||
|
||||
### Slice 4 — task thread (done)
|
||||
|
||||
- Work products and documents between turns render as one full-width row 12px under the
|
||||
"Worked" row (`TaskChatProtocolCard.tsx`, `ResourceCard`): a registered file is named after
|
||||
the file with the deliverable's state beside it; a document after its key; the destination
|
||||
is the card's. A message's files render as the same rows inline under the footer, 12px
|
||||
below it, 8px apart at equal widths, wrapping past three (280px minimum), in place of the
|
||||
media grid and file chips (`TaskChatBubble.tsx`): pictures and video open the task's
|
||||
gallery, text opens a text tab when the panel offers one, anything else a new tab.
|
||||
- Not carried over: the "Images · N / Files · N" labels and the four-up image grid; the
|
||||
rows name every file instead.
|
||||
|
||||
### Later
|
||||
|
||||
- Remove the flag and the old cards once the three surfaces have been in use.
|
||||
- The lab's parked surfaces (media preview overlay, panel closeup) follow the same path if
|
||||
adopted.
|
||||
|
||||
## Verification per slice
|
||||
|
||||
Done (slices 1–4): `cd ui && pnpm typecheck` (catalog check and `tsc -b`, clean apart from
|
||||
the five pre-existing `PluginOrganizationSwitcher` errors), `pnpm check:token-gates`,
|
||||
`node scripts/check-maps.mjs`, and the Vitest suites for `components/artifacts/v1`, the
|
||||
Artifacts page, the panel's Artifacts tab, the task-chat bubble and protocol card (each in
|
||||
both flag states), the hook, the experimental settings page, the shared feature catalog and
|
||||
the server instance settings. Not yet done: a browser pass with the flag on against a dev
|
||||
instance with real artifacts on all three surfaces; the artwork was measured in the lab and
|
||||
the wiring is covered by tests.
|
||||
|
||||
- `cd ui && pnpm typecheck` (catalog check and `tsc -b`), `pnpm check:token-gates`,
|
||||
`npx vitest run src/components/artifacts/v1` plus the touched pages' tests.
|
||||
- A Playwright pass on the dev instance with the flag on, measuring what the lab measured:
|
||||
58px rows, 8px/12px gaps, 370px cells, 5° turn and 6px rise, Paper Mono pills.
|
||||
- Storybook stories for the primitives, catalogued under `documents-artifacts`.
|
||||
|
||||
## Risks
|
||||
|
||||
- The Artifacts page tests and the task-chat tests assert the current markup; the flag keeps
|
||||
the default path unchanged, and the V1 path gets its own tests.
|
||||
- Pictures without known dimensions render at 4:3 until they load; the fitted print then
|
||||
resizes once. The stack frame's height is fixed, so nothing below it moves.
|
||||
- Token count grows by about thirty `--artifact-*` entries; they are namespaced and
|
||||
documented in the token block.
|
||||
@@ -92,3 +92,42 @@ The rest of the session directory, including its control-plane state, is kept.
|
||||
|
||||
Each batch that finds candidates logs them with candidate and reclaimed allocated
|
||||
bytes, cleared task sessions, and skip reasons.
|
||||
|
||||
## Git workspace restore points
|
||||
|
||||
Restore points are encrypted saves of the parts of a git worktree that its remote
|
||||
cannot reproduce: unpushed commits and uncommitted files. They let a closed issue's
|
||||
worktree be removed and restored later. The table `execution_workspace_restore_points`
|
||||
tracks each one with its storage key, hash, size and status
|
||||
(`pending`, `active`, `expiring`, `superseded`, `delete_requested`, `deleted`). The objects live in
|
||||
the instance's configured file storage (`local_disk` or `s3`).
|
||||
|
||||
An active restore point's `delete_after` is either its expiry time or empty while its
|
||||
source issue tree is open. A database trigger on `issues` keeps this current: when an
|
||||
issue's status or parent changes, or an issue is deleted, it marks every active restore
|
||||
point whose source issue is that issue, its old or new parent, or any of their ancestors
|
||||
as due now, in the same transaction. A retention worker on the shared delivery-work
|
||||
scheduler runs at startup, when issue changes settle while restore points exist, and at
|
||||
real deadlines: the earliest expiry, the end of an abandoned capture's grace period, or
|
||||
a failed deletion's retry. With nothing due, it schedules nothing, and open trees cause
|
||||
no recurring checks. At review it checks the source issue tree:
|
||||
|
||||
- Tree still open: clear `delete_after` and wait for the next issue change.
|
||||
- Tree `done` or `cancelled`: expire `PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS`
|
||||
(default `90`, minimum `1`) after the tree's latest close, immediately if that time
|
||||
has passed. Expiry first moves the row to `expiring`, runs the expiry hook, and only
|
||||
then requests deletion. An `expiring` row's object is never deleted; after a restart
|
||||
or a hook failure the hook is retried, after 15 minutes when it failed.
|
||||
- Source issue gone: delete.
|
||||
|
||||
Deleting an issue or a company marks its restore points `delete_requested` in the same
|
||||
transaction and notifies the worker, so their objects are removed promptly. A
|
||||
`pending` restore point older than one hour was abandoned mid-capture and is deleted.
|
||||
`superseded` and `delete_requested` restore points have their stored object removed,
|
||||
then become `deleted` once the storage provider confirms the object is gone. A
|
||||
storage failure is recorded in `last_error` and retried after 15 minutes. Work is
|
||||
processed in batches of 50; a full batch continues immediately.
|
||||
|
||||
Rows have no foreign keys, so they outlive a deleted workspace, issue or company long
|
||||
enough to remove the stored object. Each run that changes anything logs its counts.
|
||||
|
||||
@@ -131,6 +131,12 @@ and stale same-name contracts before refresh; new/changed tools stay quarantined
|
||||
exercise this boundary through real run-bound gateway sessions with fixture
|
||||
provider responses. They do not qualify a live Daytona account or agent model.
|
||||
|
||||
Unikraft uses the same native-catalog contract: stale same-name saved entries are
|
||||
rejected before refresh and new or changed actions stay quarantined. Its
|
||||
[database-backed fixtures](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover this through real run-bound gateway sessions with fixture provider
|
||||
responses, not a live Unikraft account.
|
||||
|
||||
## `test-action`
|
||||
|
||||
Capability ID: `app-permissions/test-action`.
|
||||
|
||||
@@ -137,6 +137,13 @@ gateway; they cannot export the key. [Database-backed fixtures](../../server/src
|
||||
cover selected vault ownership and gateway reads/writes with provider responses
|
||||
replaced by fixtures. Real provider authentication remains unqualified.
|
||||
|
||||
Unikraft native setup uses an organization API token in the selected personal or
|
||||
company vault grant. It does not borrow `unikraft login` state or `UKC_TOKEN`.
|
||||
Agents use assigned provider tools through the run-bound gateway, limited to the
|
||||
published metro hosts, and cannot export the token. [Database-backed fixtures](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover vault ownership and gateway reads, writes and sandbox commands with
|
||||
fixture provider responses. Real provider authentication remains unqualified.
|
||||
|
||||
## `handoff`
|
||||
|
||||
Capability ID: `connection-setup/handoff`.
|
||||
|
||||
@@ -85,6 +85,12 @@ remain in place. [Daytona regressions](../../server/src/__tests__/daytona-connec
|
||||
cover shared/personal catalog upgrades, failed refresh, permission preservation,
|
||||
run-bound file transfers, and revocation with fixture provider responses.
|
||||
|
||||
Unikraft actions follow the same review rules: deletion, stop/suspend/detach and
|
||||
sandbox command execution are destructive, and catalog updates quarantine new or
|
||||
changed actions. [Unikraft regressions](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover classification, Off policies, revocation and stale contracts with fixture
|
||||
provider responses.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Connected does not imply an agent has access or an action is allowed.
|
||||
|
||||
@@ -137,6 +137,13 @@ account-bound proof. Live token expiry/refresh, remote OAuth revocation, and
|
||||
native CLI automatic capability delivery remain unqualified; public metadata
|
||||
and fixtures do not establish those results.
|
||||
|
||||
Unikraft follows the same key/access wizard. Setup asks only for the API token;
|
||||
the default metro is an optional select under Advanced, and each action can name
|
||||
another published metro. [Unikraft browser setup](../../tests/e2e/unikraft-connector.spec.ts)
|
||||
checks the catalog, password field, access controls and the advanced metro. It
|
||||
does not submit a token or qualify a real provider account. See
|
||||
[Unikraft](../../doc/connections/UNIKRAFT.md) for token authority and metros.
|
||||
|
||||
### `mcp-link`
|
||||
|
||||
Automated: AppsConnect's generic MCP cases cover probe-driven auth, private or
|
||||
|
||||
@@ -28,6 +28,9 @@ Open an agent’s model/adapter configuration or the instance adapter settings p
|
||||
### `run-history`
|
||||
|
||||
Open an agent run at `/agents/:agentId/runs/:runId` from the agent or task activity.
|
||||
The agent header has one Run now action. Administrators can enable
|
||||
Capture raw provider traces under the agent configuration’s Debugging section
|
||||
for future runs. The header shows Raw tracing on while that setting is enabled.
|
||||
|
||||
For latency diagnosis with an operator OTLP endpoint, search the hashed run ID
|
||||
without restricting the root span name. Compare workspace preparation with its
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
CREATE TABLE "execution_workspace_restore_points" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"execution_workspace_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid,
|
||||
"generation" integer NOT NULL,
|
||||
"object_key" text NOT NULL,
|
||||
"sha256" text NOT NULL,
|
||||
"byte_size" bigint NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"activated_at" timestamp with time zone,
|
||||
"tree_closed_at" timestamp with time zone,
|
||||
"delete_after" timestamp with time zone,
|
||||
"review_generation" integer DEFAULT 0 NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "execution_workspace_restore_points_object_key_uniq" ON "execution_workspace_restore_points" USING btree ("object_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "execution_workspace_restore_points_live_generation_uniq" ON "execution_workspace_restore_points" USING btree ("execution_workspace_id","generation") WHERE "execution_workspace_restore_points"."status" <> 'deleted';--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspace_restore_points_status_delete_after_idx" ON "execution_workspace_restore_points" USING btree ("status","delete_after");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspace_restore_points_company_idx" ON "execution_workspace_restore_points" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspace_restore_points_source_issue_idx" ON "execution_workspace_restore_points" USING btree ("company_id","source_issue_id");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspace_restore_points_active_source_idx" ON "execution_workspace_restore_points" USING btree ("source_issue_id") WHERE "execution_workspace_restore_points"."status" = 'active';--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION paperclip_restore_point_tree_changed() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = public, pg_temp AS $$
|
||||
DECLARE starts uuid[];
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
starts := ARRAY[OLD.parent_id];
|
||||
ELSE
|
||||
starts := ARRAY[NEW.id, OLD.parent_id, NEW.parent_id];
|
||||
END IF;
|
||||
UPDATE execution_workspace_restore_points
|
||||
SET delete_after = CASE WHEN delete_after IS NULL OR delete_after > now() THEN now() ELSE delete_after END,
|
||||
review_generation = review_generation + 1,
|
||||
updated_at = now()
|
||||
WHERE status = 'active'
|
||||
AND source_issue_id IN (
|
||||
WITH RECURSIVE ancestors(id, parent_id, depth) AS (
|
||||
SELECT i.id, i.parent_id, 0 FROM issues i WHERE i.id = ANY(starts)
|
||||
UNION
|
||||
SELECT parent.id, parent.parent_id, ancestors.depth + 1 FROM issues parent
|
||||
JOIN ancestors ON parent.id = ancestors.parent_id WHERE ancestors.depth < 64
|
||||
)
|
||||
SELECT id FROM ancestors
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER paperclip_restore_point_tree_update AFTER UPDATE OF status, parent_id ON issues
|
||||
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.parent_id IS DISTINCT FROM NEW.parent_id)
|
||||
EXECUTE FUNCTION paperclip_restore_point_tree_changed();
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER paperclip_restore_point_tree_delete AFTER DELETE ON issues
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_restore_point_tree_changed();
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "dismissed_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "dismissed_by_user_id" text;
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "dismissed_by_user_id" text;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "5bc34ca2-f5d8-4952-a6c0-4afb96e70961",
|
||||
"id": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7",
|
||||
"prevId": "7caf727e-0913-40b3-b01c-842e9447666f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -28473,6 +28473,237 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.execution_workspace_restore_points": {
|
||||
"name": "execution_workspace_restore_points",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"execution_workspace_id": {
|
||||
"name": "execution_workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_issue_id": {
|
||||
"name": "source_issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"generation": {
|
||||
"name": "generation",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"activated_at": {
|
||||
"name": "activated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tree_closed_at": {
|
||||
"name": "tree_closed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"delete_after": {
|
||||
"name": "delete_after",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"review_generation": {
|
||||
"name": "review_generation",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"execution_workspace_restore_points_object_key_uniq": {
|
||||
"name": "execution_workspace_restore_points_object_key_uniq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "object_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"execution_workspace_restore_points_live_generation_uniq": {
|
||||
"name": "execution_workspace_restore_points_live_generation_uniq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "execution_workspace_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "generation",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"execution_workspace_restore_points_status_delete_after_idx": {
|
||||
"name": "execution_workspace_restore_points_status_delete_after_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "delete_after",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"execution_workspace_restore_points_company_idx": {
|
||||
"name": "execution_workspace_restore_points_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"execution_workspace_restore_points_source_issue_idx": {
|
||||
"name": "execution_workspace_restore_points_source_issue_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "source_issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"execution_workspace_restore_points_active_source_idx": {
|
||||
"name": "execution_workspace_restore_points_active_source_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "source_issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"execution_workspace_restore_points\".\"status\" = 'active'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.execution_workspace_runtime_leases": {
|
||||
"name": "execution_workspace_runtime_leases",
|
||||
"schema": "",
|
||||
@@ -38627,18 +38858,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"dismissed_at": {
|
||||
"name": "dismissed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"dismissed_by_user_id": {
|
||||
"name": "dismissed_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
||||
@@ -2595,8 +2595,15 @@
|
||||
{
|
||||
"idx": 372,
|
||||
"version": "7",
|
||||
"when": 1792679793575,
|
||||
"tag": "0372_mighty_swordsman",
|
||||
"when": 1792707356696,
|
||||
"tag": "0372_mature_the_initiative",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 373,
|
||||
"version": "7",
|
||||
"when": 1792710084082,
|
||||
"tag": "0373_wandering_mongu",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { bigint, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
export const EXECUTION_WORKSPACE_RESTORE_POINT_STATUSES = [
|
||||
"pending",
|
||||
"active",
|
||||
"expiring",
|
||||
"superseded",
|
||||
"delete_requested",
|
||||
"deleted",
|
||||
] as const;
|
||||
export type ExecutionWorkspaceRestorePointStatus = (typeof EXECUTION_WORKSPACE_RESTORE_POINT_STATUSES)[number];
|
||||
|
||||
export const executionWorkspaceRestorePoints = pgTable(
|
||||
"execution_workspace_restore_points",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull(),
|
||||
executionWorkspaceId: uuid("execution_workspace_id").notNull(),
|
||||
sourceIssueId: uuid("source_issue_id"),
|
||||
generation: integer("generation").notNull(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
|
||||
status: text("status").$type<ExecutionWorkspaceRestorePointStatus>().notNull().default("pending"),
|
||||
activatedAt: timestamp("activated_at", { withTimezone: true }),
|
||||
treeClosedAt: timestamp("tree_closed_at", { withTimezone: true }),
|
||||
deleteAfter: timestamp("delete_after", { withTimezone: true }),
|
||||
reviewGeneration: integer("review_generation").notNull().default(0),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
objectKeyUniqueIdx: uniqueIndex("execution_workspace_restore_points_object_key_uniq").on(table.objectKey),
|
||||
liveGenerationUniqueIdx: uniqueIndex("execution_workspace_restore_points_live_generation_uniq")
|
||||
.on(table.executionWorkspaceId, table.generation)
|
||||
.where(sql`${table.status} <> 'deleted'`),
|
||||
statusDeleteAfterIdx: index("execution_workspace_restore_points_status_delete_after_idx").on(table.status, table.deleteAfter),
|
||||
companyIdx: index("execution_workspace_restore_points_company_idx").on(table.companyId),
|
||||
sourceIssueIdx: index("execution_workspace_restore_points_source_issue_idx").on(table.companyId, table.sourceIssueId),
|
||||
activeSourceIdx: index("execution_workspace_restore_points_active_source_idx").on(table.sourceIssueId)
|
||||
.where(sql`${table.status} = 'active'`),
|
||||
}),
|
||||
);
|
||||
@@ -32,6 +32,11 @@ export { projectAccessMembers } from "./project_access_members.js";
|
||||
export { documentMemberships } from "./document_memberships.js";
|
||||
export { projectWorkspaces } from "./project_workspaces.js";
|
||||
export { executionWorkspaces } from "./execution_workspaces.js";
|
||||
export {
|
||||
executionWorkspaceRestorePoints,
|
||||
EXECUTION_WORKSPACE_RESTORE_POINT_STATUSES,
|
||||
type ExecutionWorkspaceRestorePointStatus,
|
||||
} from "./execution_workspace_restore_points.js";
|
||||
export { executionWorkspaceRuntimeLeases } from "./execution_workspace_runtime_leases.js";
|
||||
export { environments } from "./environments.js";
|
||||
export { environmentLeases } from "./environment_leases.js";
|
||||
|
||||
@@ -105,6 +105,7 @@ import a103 from "./app-definitions/local.json" with { type: "json" };
|
||||
import a104 from "./app-definitions/daytona.json" with { type: "json" };
|
||||
import a105 from "./app-definitions/monday.json" with { type: "json" };
|
||||
import a106 from "./app-definitions/paperclip-notebook.json" with { type: "json" };
|
||||
import a107 from "./app-definitions/speko.json" with { type: "json" };
|
||||
import a107 from "./app-definitions/unikraft.json" with { type: "json" };
|
||||
import a108 from "./app-definitions/speko.json" with { type: "json" };
|
||||
import type { AppDefinition } from "./types/app-definition.js";
|
||||
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107] as AppDefinition[];
|
||||
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107,a108] as AppDefinition[];
|
||||
@@ -886,7 +886,7 @@ describe("AppDefinition catalog", () => {
|
||||
"ticktick",
|
||||
"xero",
|
||||
]);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(85);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(86);
|
||||
const connectableSlugs = new Set(
|
||||
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
|
||||
);
|
||||
@@ -1285,6 +1285,38 @@ describe("AppDefinition catalog", () => {
|
||||
expect(method.warnings).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
it("connects Unikraft Cloud's published HTTP APIs with an organization API token", () => {
|
||||
const unikraft = APP_DEFINITIONS.find((app) => app.slug === "unikraft")!;
|
||||
expect(unikraft).toMatchObject({
|
||||
name: "Unikraft",
|
||||
categories: ["developer"],
|
||||
urlPatterns: ["https://console.unikraft.com/*", "https://*.unikraft.cloud/*"],
|
||||
docsUrl: "https://unikraft.com/docs/api/platform/v1",
|
||||
branding: { logoUrl: "/brands/apps/unikraft.svg" },
|
||||
});
|
||||
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "unikraft")).toBe(true);
|
||||
expect(CONNECTABLE_APP_DEFINITIONS.some((app) => app.slug === "unikraft")).toBe(true);
|
||||
expect(unikraft.methods.map((method) => method.key)).toEqual(["api-token"]);
|
||||
const [method] = unikraft.methods;
|
||||
expect(method).toMatchObject({
|
||||
transport: "rest_api",
|
||||
auth: "api_key",
|
||||
ownershipModes: ["customer"],
|
||||
riskTier: "S4",
|
||||
defaults: { serverUrl: "https://controlplane.unikraft.cloud/v1" },
|
||||
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
|
||||
});
|
||||
expect(method!.credentialFields).toEqual([
|
||||
expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }),
|
||||
]);
|
||||
// The key form renders credential helper text, not method warnings.
|
||||
expect(method!.credentialFields?.[0]?.helperMd).toContain("running sandbox commands and deleting resources");
|
||||
// The default metro is optional: setup asks only for the token.
|
||||
expect(method!.tenantFields).toEqual([
|
||||
expect.objectContaining({ key: "metro", type: "select", advanced: true, defaultValue: "fra" }),
|
||||
]);
|
||||
expect(method!.tenantFields?.[0]?.options?.map((option) => option.value)).toEqual(["dal", "fra", "sfo", "sin", "was"]);
|
||||
});
|
||||
it("connects Gauge's hosted server with browser sign-in or an organization API key", () => {
|
||||
const gauge = APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
|
||||
expect(gauge).toMatchObject({
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"agentmail",
|
||||
"browser-use-cloud",
|
||||
"daytona",
|
||||
"unikraft",
|
||||
"cognee",
|
||||
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
|
||||
"monday",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "unikraft",
|
||||
"name": "Unikraft",
|
||||
"description": "Manage Unikraft Cloud instances, services, volumes, images, certificates, autoscaling, and sandbox commands and files.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"urlPatterns": [
|
||||
"https://console.unikraft.com/*",
|
||||
"https://*.unikraft.cloud/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "api-token",
|
||||
"label": "Connect Unikraft",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run sandbox commands in your Unikraft Cloud organization.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://controlplane.unikraft.cloud/v1"
|
||||
},
|
||||
"guidanceMd": "Connect a Unikraft Cloud API token. All published platform API and sandbox plugin actions are available; your action policies control which ones agents can use. Each action can target any Unikraft Cloud metro, and uses the default metro when it does not name one.",
|
||||
"riskTier": "S4",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Unikraft API token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Unikraft API token",
|
||||
"secret": true,
|
||||
"helperMd": "Create an API token in the Unikraft Cloud console. A token acts for its whole organization in every metro, including running sandbox commands and deleting resources. Unikraft does not offer narrower token permissions, so choose trusted agents and use action permissions to limit what they can do."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "metro",
|
||||
"label": "Default metro",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"defaultValue": "fra",
|
||||
"helperMd": "Used to verify the token and for actions that do not name a metro.",
|
||||
"options": [
|
||||
{
|
||||
"value": "dal",
|
||||
"label": "Dallas (dal)"
|
||||
},
|
||||
{
|
||||
"value": "fra",
|
||||
"label": "Frankfurt (fra)"
|
||||
},
|
||||
{
|
||||
"value": "sfo",
|
||||
"label": "San Francisco (sfo)"
|
||||
},
|
||||
{
|
||||
"value": "sin",
|
||||
"label": "Singapore (sin)"
|
||||
},
|
||||
{
|
||||
"value": "was",
|
||||
"label": "Washington, DC (was)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"metro",
|
||||
"instance"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://console.unikraft.com",
|
||||
"docs": "https://unikraft.com/docs/sdks/overview#authentication"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands in sandboxes, read and write sandbox files, start and stop instances, and delete instances, volumes, and other resources. Select trusted agents and set action permissions before use. Your Unikraft API token stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://unikraft.com/docs/api/platform/v1",
|
||||
"branding": {
|
||||
"logoUrl": "/brands/apps/unikraft.svg"
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableArtifactsV1: {
|
||||
title: "Artifacts V1",
|
||||
description:
|
||||
"The artifact presentation from the artifact directions lab: paper stacks and tiles on the Artifacts page, compact rows in the panel's Artifacts tab, and grouped rows in the task thread.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableConferenceRoomChat: {
|
||||
title: "Conference Room Chat",
|
||||
description:
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface InstanceExperimentalSettings {
|
||||
enableAgentChat: boolean;
|
||||
/** Reorganized left nav, Inbox-as-Tasks-views, and the chat agent rail + task cards. */
|
||||
enableCombinedInboxTasks: boolean;
|
||||
/** The artifact presentation from the artifact directions lab (MVP V1) on the Artifacts page, the panel's Artifacts tab and the task thread. */
|
||||
enableArtifactsV1: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableClassicTaskInterface: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableCases: z.boolean().default(false),
|
||||
enableAgentChat: z.boolean().default(false),
|
||||
enableCombinedInboxTasks: z.boolean().default(false),
|
||||
enableArtifactsV1: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
enableClassicTaskInterface: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "unikraft",
|
||||
"name": "Unikraft",
|
||||
"description": "Manage Unikraft Cloud instances, services, volumes, images, certificates, autoscaling, and sandbox commands and files.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"urlPatterns": [
|
||||
"https://console.unikraft.com/*",
|
||||
"https://*.unikraft.cloud/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "api-token",
|
||||
"label": "Connect Unikraft",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run sandbox commands in your Unikraft Cloud organization.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://controlplane.unikraft.cloud/v1"
|
||||
},
|
||||
"guidanceMd": "Connect a Unikraft Cloud API token. All published platform API and sandbox plugin actions are available; your action policies control which ones agents can use. Each action can target any Unikraft Cloud metro, and uses the default metro when it does not name one.",
|
||||
"riskTier": "S4",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Unikraft API token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Unikraft API token",
|
||||
"secret": true
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "metro",
|
||||
"label": "Default metro",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"defaultValue": "fra",
|
||||
"helperMd": "Used to verify the token and for actions that do not name a metro.",
|
||||
"options": [
|
||||
{ "value": "dal", "label": "Dallas (dal)" },
|
||||
{ "value": "fra", "label": "Frankfurt (fra)" },
|
||||
{ "value": "sfo", "label": "San Francisco (sfo)" },
|
||||
{ "value": "sin", "label": "Singapore (sin)" },
|
||||
{ "value": "was", "label": "Washington, DC (was)" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"metro",
|
||||
"instance"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://console.unikraft.com",
|
||||
"docs": "https://unikraft.com/docs/sdks/overview#authentication"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands in sandboxes, read and write sandbox files, start and stop instances, and delete instances, volumes, and other resources. Select trusted agents and set action permissions before use. Your Unikraft API token stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://unikraft.com/docs/api/platform/v1"
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const snake = value => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a
|
||||
|
||||
/** Retain only reachable input definitions, rather than copying the whole API
|
||||
* schema into every action. Provider response schemas are not tool inputs. */
|
||||
function inputSchema(value, schemas) {
|
||||
export function inputSchema(value, schemas) {
|
||||
const definitions = {};
|
||||
function visit(node) {
|
||||
if (Array.isArray(node)) return node.map(visit);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { inputSchema } from "./generate-daytona-tools.mjs";
|
||||
|
||||
// The official specification repository has no tags or releases. Pin a
|
||||
// reviewed commit so a provider update needs explicit regeneration and review.
|
||||
const commit = "5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b";
|
||||
const sources = {
|
||||
platform: `https://raw.githubusercontent.com/unikraft-cloud/openapi/${commit}/platform.json`,
|
||||
sandbox: `https://raw.githubusercontent.com/unikraft-cloud/openapi/${commit}/plugins/sandbox.json`,
|
||||
};
|
||||
const verbs = new Set(["get", "post", "put", "patch", "delete"]);
|
||||
const snake = value => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase();
|
||||
// Requests that wait, read, or list without changing provider state.
|
||||
const readOnlyPosts = new Set(["ReadFile", "ReadRawFile", "WaitForCommand", "WaitForCommandWithTimeout"]);
|
||||
// Reversible but disruptive lifecycle changes, and code execution in a sandbox.
|
||||
const destructiveOperations = /^(Stop|Suspend|Detach|Unpin)|^(RunCommand|SignalCommand|WriteCommandStdin)$/;
|
||||
|
||||
function parameterDefinition(parameter, spec) {
|
||||
if (!parameter.$ref) return parameter;
|
||||
const prefix = "#/components/parameters/";
|
||||
if (!parameter.$ref.startsWith(prefix)) throw new Error(`Unsupported parameter reference: ${parameter.$ref}`);
|
||||
const resolved = spec.components?.parameters?.[parameter.$ref.slice(prefix.length)];
|
||||
if (!resolved) throw new Error(`Missing parameter: ${parameter.$ref}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Ajv does not define OpenAPI's unsigned integer formats. Keep their sign
|
||||
* constraint instead of an unknown format that would not validate anything.
|
||||
* Some unsigned fields document -1 as a sentinel (for example, the longest
|
||||
* drain time); keep that value valid. */
|
||||
function unsignedFormats(node) {
|
||||
if (Array.isArray(node)) return node.forEach(unsignedFormats);
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (/^uint(8|16|32|64)$/.test(node.format ?? "")) {
|
||||
delete node.format;
|
||||
node.minimum ??= /(^|\s)-1\b/.test(node.description ?? "") ? -1 : 0;
|
||||
}
|
||||
Object.values(node).forEach(unsignedFormats);
|
||||
}
|
||||
|
||||
function streamingOperation(operation) {
|
||||
return Object.values(operation.responses ?? {}).some(response => Object.hasOwn(response.content ?? {}, "text/event-stream"));
|
||||
}
|
||||
|
||||
export function generateUnikraftCatalog(specs) {
|
||||
const metros = (specs.platform?.servers ?? []).map(server => new URL(server.url).hostname.match(/^api\.([a-z0-9]+)\.unikraft\.cloud$/)?.[1])
|
||||
.filter(Boolean).sort();
|
||||
const tools = [];
|
||||
const streaming = [];
|
||||
for (const [kind, spec] of Object.entries(specs)) {
|
||||
for (const [route, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!verbs.has(method)) continue;
|
||||
if (!operation.operationId) throw new Error(`Missing operationId: ${kind} ${method} ${route}`);
|
||||
const name = kind === "platform" ? `unikraft_${snake(operation.operationId)}` : `unikraft_${kind}_${snake(operation.operationId)}`;
|
||||
// Server-sent event streams hold a connection open; they are not
|
||||
// request/response actions. Keep them in the inventory so an update
|
||||
// cannot silently lose an operation.
|
||||
if (streamingOperation(operation)) {
|
||||
streaming.push({ name, kind, path: route, method: method.toUpperCase(), transport: "server-sent-events" });
|
||||
continue;
|
||||
}
|
||||
const properties = {}, required = [], parameters = [];
|
||||
const groups = { path: { type: "object", properties: {}, required: [], additionalProperties: false },
|
||||
query: { type: "object", properties: {}, required: [], additionalProperties: false },
|
||||
headers: { type: "object", properties: {}, required: [], additionalProperties: false } };
|
||||
for (const reference of [...(item.parameters ?? []), ...(operation.parameters ?? [])]) {
|
||||
const parameter = parameterDefinition(reference, spec);
|
||||
const group = parameter.in === "header" ? "headers" : parameter.in;
|
||||
if (!groups[group]) throw new Error(`Unsupported parameter: ${name} ${parameter.in}`);
|
||||
// Authorization comes from the selected connection, never an argument.
|
||||
if (group === "headers" && /^(authorization|cookie|host)$/i.test(parameter.name)) continue;
|
||||
groups[group].properties[parameter.name] = { ...(parameter.schema ?? {}),
|
||||
...(parameter.description ? { description: parameter.description } : {}) };
|
||||
if (parameter.required) groups[group].required.push(parameter.name);
|
||||
parameters.push({ name: parameter.name, group, style: parameter.style ?? "form", explode: parameter.explode ?? true });
|
||||
}
|
||||
for (const [group, schema] of Object.entries(groups)) {
|
||||
if (!Object.keys(schema.properties).length) continue;
|
||||
properties[group] = schema;
|
||||
if (schema.required.length) required.push(group);
|
||||
}
|
||||
properties.metro = { type: "string", enum: metros, description: "Unikraft Cloud metro. Defaults to the connection's default metro." };
|
||||
if (kind === "sandbox") {
|
||||
properties.instance = { type: "string", minLength: 1, maxLength: 200, description: "Name or UUID of the instance running the sandbox plugin." };
|
||||
required.push("instance");
|
||||
}
|
||||
const content = operation.requestBody?.content ?? {};
|
||||
const bodyContentType = Object.hasOwn(content, "application/json") ? "application/json" : Object.keys(content)[0];
|
||||
if (bodyContentType && bodyContentType !== "application/json") throw new Error(`Unsupported request body: ${name} ${bodyContentType}`);
|
||||
if (bodyContentType) {
|
||||
properties.body = content[bodyContentType].schema ?? {};
|
||||
if (operation.requestBody.required) required.push("body");
|
||||
}
|
||||
const readOnly = method === "get" || readOnlyPosts.has(operation.operationId);
|
||||
const destructive = !readOnly && (method === "delete" || destructiveOperations.test(operation.operationId));
|
||||
const schema = inputSchema({ type: "object", additionalProperties: false, properties, required }, spec.components?.schemas ?? {});
|
||||
unsignedFormats(schema);
|
||||
tools.push({ name, kind, path: route, method: method.toUpperCase(), parameters,
|
||||
...(bodyContentType ? { bodyContentType } : {}),
|
||||
title: operation.summary || operation.operationId,
|
||||
description: operation.description || operation.summary || operation.operationId,
|
||||
inputSchema: schema,
|
||||
annotations: { readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: readOnly, openWorldHint: true } });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metros.length) throw new Error("The platform specification lists no Unikraft Cloud metros");
|
||||
const names = tools.map(tool => tool.name);
|
||||
if (new Set(names).size !== names.length) throw new Error("Duplicate Unikraft action name");
|
||||
return { metros, tools, streaming };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputIndex = process.argv.indexOf("--input-dir");
|
||||
const inputDir = inputIndex < 0 ? undefined : process.argv[inputIndex + 1];
|
||||
const specs = {}, provenance = {};
|
||||
for (const [kind, url] of Object.entries(sources)) {
|
||||
const raw = inputDir ? await readFile(path.join(inputDir, `unikraft-${kind}-openapi.json`))
|
||||
: Buffer.from(await (await fetch(url)).arrayBuffer());
|
||||
specs[kind] = JSON.parse(raw.toString("utf8"));
|
||||
provenance[kind] = { url, sha256: createHash("sha256").update(raw).digest("hex") };
|
||||
}
|
||||
const output = { schemaVersion: 1, sources: provenance, ...generateUnikraftCatalog(specs) };
|
||||
const target = fileURLToPath(new URL("../server/src/services/connectors/unikraft/operations.generated.json", import.meta.url));
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, JSON.stringify(output, null, 2) + "\n");
|
||||
console.log(`Generated ${output.tools.length} Unikraft actions across ${output.metros.length} metros; recorded ${output.streaming.length} streaming endpoints.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateUnikraftCatalog } from "./generate-unikraft-tools.mjs";
|
||||
|
||||
const servers = [{ url: "https://api.fra.unikraft.cloud" }, { url: "https://api.dal.unikraft.cloud" }];
|
||||
|
||||
test("accounts for every HTTP operation and server-sent event stream without dropping methods", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: {
|
||||
"/v1/instances": { get: { operationId: "GetInstances" }, post: { operationId: "CreateInstance" } },
|
||||
"/v1/audit": { get: { operationId: "SubscribeAuditEvents", responses: { default: { content: { "text/event-stream": {} } } } } },
|
||||
} } });
|
||||
assert.deepEqual(result.metros, ["dal", "fra"]);
|
||||
assert.deepEqual(result.tools.map(t => t.name), ["unikraft_get_instances", "unikraft_create_instance"]);
|
||||
assert.deepEqual(result.streaming.map(s => s.name), ["unikraft_subscribe_audit_events"]);
|
||||
assert.deepEqual(result.tools[0].inputSchema.properties.metro.enum, ["dal", "fra"]);
|
||||
});
|
||||
|
||||
test("resolves shared parameters, keeps GET bodies and replaces unsigned formats", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: { "/v1/instances/log": { get: {
|
||||
operationId: "GetInstanceLogs",
|
||||
parameters: [{ $ref: "#/components/parameters/Uuid" }, { name: "limit", in: "query", schema: { type: "integer", format: "uint32" } },
|
||||
{ name: "Authorization", in: "header", schema: { type: "string" } }],
|
||||
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Target" } } } },
|
||||
} } }, components: {
|
||||
parameters: { Uuid: { name: "uuid", in: "query", schema: { type: "array", items: { type: "string" } } } },
|
||||
schemas: { Target: { type: "object", properties: { offset: { type: "integer", format: "uint64" },
|
||||
drain_timeout_ms: { type: "integer", format: "uint64", description: "No draining if 0. Use -1 for the largest possible value." } } } },
|
||||
} } });
|
||||
const [tool] = result.tools;
|
||||
assert.deepEqual(tool.parameters.map(p => p.name), ["uuid", "limit"]);
|
||||
assert.equal(tool.inputSchema.properties.headers, undefined);
|
||||
assert.deepEqual(tool.inputSchema.properties.query.properties.limit, { type: "integer", minimum: 0 });
|
||||
assert.deepEqual(tool.inputSchema.definitions.Target.properties.offset, { type: "integer", minimum: 0 });
|
||||
// A documented -1 sentinel stays valid.
|
||||
assert.equal(tool.inputSchema.definitions.Target.properties.drain_timeout_ms.minimum, -1);
|
||||
assert.equal(tool.bodyContentType, "application/json");
|
||||
assert.ok(tool.inputSchema.required.includes("body"));
|
||||
assert.equal(tool.annotations.readOnlyHint, true);
|
||||
});
|
||||
|
||||
test("targets a sandbox instance and treats execution, disruption and deletion as destructive", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: {
|
||||
"/v1/instances/stop": { put: { operationId: "StopInstances" } },
|
||||
"/v1/instances/start": { put: { operationId: "StartInstances" } },
|
||||
"/v1/volumes/detach": { put: { operationId: "DetachVolumes" } },
|
||||
"/v1/volumes/{uuid}": { delete: { operationId: "DeleteVolumeByUUID" } },
|
||||
} }, sandbox: { paths: {
|
||||
"/commands": { post: { operationId: "RunCommand" } },
|
||||
"/fs/read": { post: { operationId: "ReadFile" } },
|
||||
"/fs/write": { post: { operationId: "WriteFile" } },
|
||||
} } });
|
||||
const risk = Object.fromEntries(result.tools.map(t => [t.name,
|
||||
t.annotations.destructiveHint ? "destructive" : t.annotations.readOnlyHint ? "read" : "write"]));
|
||||
assert.deepEqual(risk, {
|
||||
unikraft_stop_instances: "destructive", unikraft_start_instances: "write", unikraft_detach_volumes: "destructive",
|
||||
unikraft_delete_volume_by_uuid: "destructive", unikraft_sandbox_run_command: "destructive",
|
||||
unikraft_sandbox_read_file: "read", unikraft_sandbox_write_file: "write",
|
||||
});
|
||||
const sandbox = result.tools.find(t => t.name === "unikraft_sandbox_run_command");
|
||||
assert.ok(sandbox.inputSchema.required.includes("instance"));
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("reviewed connector risk dispatch", () => {
|
||||
});
|
||||
|
||||
describe("reviewed connector catalog policies", () => {
|
||||
it.each(["railway", "enterpret", "daytona", "braintrust"])("declares the initial drift policy for %s", source => {
|
||||
it.each(["railway", "enterpret", "daytona", "unikraft", "braintrust"])("declares the initial drift policy for %s", source => {
|
||||
expect(connectorToolPolicy(source).quarantineNewEntries).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../config.ts";
|
||||
|
||||
describe("git restore point retention config parsing", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("keeps restore points 90 days after their issue tree closes by default", () => {
|
||||
vi.stubEnv("PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS", undefined);
|
||||
expect(loadConfig()).toMatchObject({ gitRestorePointRetentionDays: 90 });
|
||||
});
|
||||
|
||||
it("accepts an explicit retention and never goes below one day", () => {
|
||||
vi.stubEnv("PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS", "30");
|
||||
expect(loadConfig()).toMatchObject({ gitRestorePointRetentionDays: 30 });
|
||||
vi.stubEnv("PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS", "0");
|
||||
expect(loadConfig()).toMatchObject({ gitRestorePointRetentionDays: 1 });
|
||||
});
|
||||
|
||||
it.each(["", "-1", "many"])("falls back to 90 days for %j", (value) => {
|
||||
vi.stubEnv("PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS", value);
|
||||
expect(loadConfig()).toMatchObject({ gitRestorePointRetentionDays: 90 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, createDb, executionWorkspaceRestorePoints, issues } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
GIT_RESTORE_POINT_RETRY_MS,
|
||||
gitRestorePointRetentionService,
|
||||
registerGitRestorePointWork,
|
||||
requestRestorePointDeletion,
|
||||
} from "../services/git-restore-point-ledger.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { deleteCompany } from "../services/company-deletion.ts";
|
||||
import type { StorageService } from "../storage/types.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres git restore point ledger tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describeEmbeddedPostgres("git restore point retention", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const now = new Date();
|
||||
let deleted: string[] = [];
|
||||
let failDeletes = false;
|
||||
let silentlyKeep = false;
|
||||
const storage = {
|
||||
deleteObject: async (_companyId: string, objectKey: string) => {
|
||||
if (failDeletes) throw new Error("storage unavailable");
|
||||
if (!silentlyKeep) deleted.push(objectKey);
|
||||
},
|
||||
headObject: async (_companyId: string, objectKey: string) => ({ exists: silentlyKeep && !deleted.includes(objectKey) }),
|
||||
} as unknown as StorageService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-git-restore-point-ledger-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
deleted = [];
|
||||
failDeletes = false;
|
||||
silentlyKeep = false;
|
||||
await db.delete(executionWorkspaceRestorePoints);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(input: {
|
||||
status?: "pending" | "active" | "superseded" | "delete_requested";
|
||||
issueStatus?: string;
|
||||
closedDaysAgo?: number;
|
||||
openChild?: boolean;
|
||||
createdHoursAgo?: number;
|
||||
withoutIssue?: boolean;
|
||||
deleteAfter?: Date | null;
|
||||
companyId?: string;
|
||||
} = {}) {
|
||||
const companyId = input.companyId ?? randomUUID();
|
||||
if (!input.companyId) {
|
||||
await db.insert(companies).values({ id: companyId, name: "Restore", issuePrefix: `R${companyId.slice(0, 6)}` });
|
||||
}
|
||||
const issueStatus = input.issueStatus ?? "done";
|
||||
const closedAt = new Date(now.getTime() - (input.closedDaysAgo ?? 10) * DAY_MS);
|
||||
const [issue] = await db.insert(issues).values({
|
||||
companyId,
|
||||
title: "Work",
|
||||
status: issueStatus,
|
||||
completedAt: issueStatus === "done" ? closedAt : null,
|
||||
cancelledAt: issueStatus === "cancelled" ? closedAt : null,
|
||||
}).returning();
|
||||
if (input.openChild) {
|
||||
await db.insert(issues).values({ companyId, title: "Child", status: "blocked", parentId: issue!.id });
|
||||
}
|
||||
const workspaceId = randomUUID();
|
||||
const [row] = await db.insert(executionWorkspaceRestorePoints).values({
|
||||
companyId,
|
||||
executionWorkspaceId: workspaceId,
|
||||
sourceIssueId: input.withoutIssue ? randomUUID() : issue!.id,
|
||||
generation: 1,
|
||||
objectKey: `${companyId}/git-restore-points/${workspaceId}/1/${randomUUID()}.pcrp`,
|
||||
sha256: "a".repeat(64),
|
||||
byteSize: 1024,
|
||||
status: input.status ?? "active",
|
||||
deleteAfter: input.deleteAfter ?? null,
|
||||
createdAt: new Date(now.getTime() - (input.createdHoursAgo ?? 0) * 60 * 60 * 1000),
|
||||
}).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
function service(options: { at?: Date; beforeExpire?: (row: typeof executionWorkspaceRestorePoints.$inferSelect) => Promise<void> } = {}) {
|
||||
return gitRestorePointRetentionService(db, { storage, now: () => options.at ?? now, beforeExpire: options.beforeExpire });
|
||||
}
|
||||
|
||||
async function reload(id: string) {
|
||||
const [row] = await db.select().from(executionWorkspaceRestorePoints).where(eq(executionWorkspaceRestorePoints.id, id));
|
||||
return row!;
|
||||
}
|
||||
|
||||
const due = () => new Date(now.getTime() - 1000);
|
||||
|
||||
it("waits without a deadline while its tree is open", async () => {
|
||||
const row = await seed({ openChild: true, deleteAfter: due() });
|
||||
expect(await service().tick()).toMatchObject({ scheduled: 1, deleted: 0 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "active", treeClosedAt: null, deleteAfter: null });
|
||||
expect(await service().nextDeadlineAt()).toBeNull();
|
||||
});
|
||||
|
||||
it("schedules deletion 90 days after the tree closes", async () => {
|
||||
const row = await seed({ closedDaysAgo: 10, deleteAfter: due() });
|
||||
await service().tick();
|
||||
const reloaded = await reload(row.id);
|
||||
expect(reloaded.status).toBe("active");
|
||||
expect(reloaded.deleteAfter!.getTime() - reloaded.treeClosedAt!.getTime()).toBe(90 * DAY_MS);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("expires and deletes a restore point at its deadline once the tree has been closed long enough", async () => {
|
||||
const row = await seed({ closedDaysAgo: 91, deleteAfter: new Date(now.getTime() - 1000) });
|
||||
const beforeExpire = vi.fn(async () => {});
|
||||
expect(await service({ beforeExpire }).tick()).toMatchObject({ expired: 1, deleted: 1 });
|
||||
expect(beforeExpire).toHaveBeenCalledOnce();
|
||||
expect(deleted).toEqual([row.objectKey]);
|
||||
expect(await reload(row.id)).toMatchObject({ status: "deleted" });
|
||||
});
|
||||
|
||||
it("keeps a restore point expiring, and its object, until the expiry hook succeeds", async () => {
|
||||
const row = await seed({ closedDaysAgo: 91, deleteAfter: due() });
|
||||
expect(await service({ beforeExpire: async () => { throw new Error("recipe busy"); } }).tick()).toMatchObject({ failed: 1, expired: 0, deleted: 0 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "expiring", lastError: "recipe busy" });
|
||||
expect(await service().tick()).toMatchObject({ expired: 0, deleted: 0 });
|
||||
expect(await service().nextDeadlineAt()).toBe((await reload(row.id)).updatedAt.getTime() + GIT_RESTORE_POINT_RETRY_MS);
|
||||
const beforeExpire = vi.fn(async () => {});
|
||||
const later = service({ at: new Date(now.getTime() + GIT_RESTORE_POINT_RETRY_MS + 1000), beforeExpire });
|
||||
expect(await later.tick()).toMatchObject({ expired: 1, deleted: 1 });
|
||||
expect(beforeExpire).toHaveBeenCalledOnce();
|
||||
expect(deleted).toEqual([row.objectKey]);
|
||||
});
|
||||
|
||||
it("resumes the expiry hook after a restart instead of deleting the object directly", async () => {
|
||||
const row = await seed({ closedDaysAgo: 91, status: "active" });
|
||||
await db.update(executionWorkspaceRestorePoints).set({ status: "expiring" }).where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
const beforeExpire = vi.fn(async () => {});
|
||||
expect(await service().nextDeadlineAt()).toBe(now.getTime());
|
||||
expect(await service({ beforeExpire }).tick()).toMatchObject({ expired: 1, deleted: 1 });
|
||||
expect(beforeExpire).toHaveBeenCalledOnce();
|
||||
expect(await reload(row.id)).toMatchObject({ status: "deleted" });
|
||||
});
|
||||
|
||||
it("never deletes the object of an expiring restore point whose hook has not run", async () => {
|
||||
const row = await seed({ closedDaysAgo: 91, status: "active" });
|
||||
await db.update(executionWorkspaceRestorePoints).set({ status: "expiring" }).where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
await service({ beforeExpire: async () => { throw new Error("recipe busy"); } }).tick();
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes restore points whose source issue no longer exists at review", async () => {
|
||||
const row = await seed({ withoutIssue: true, deleteAfter: due() });
|
||||
expect(await service().tick()).toMatchObject({ orphaned: 1, deleted: 1 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "deleted" });
|
||||
});
|
||||
|
||||
it("evaluates only restore points that are due, so retained ones cause no rescans", async () => {
|
||||
for (let index = 0; index < 60; index += 1) await seed({ closedDaysAgo: 10, deleteAfter: new Date(now.getTime() + DAY_MS) });
|
||||
expect(await service().tick()).toMatchObject({ scheduled: 0, expired: 0, truncated: false });
|
||||
expect(await service().nextDeadlineAt()).toBe(now.getTime() + DAY_MS);
|
||||
});
|
||||
|
||||
it("works through a backlog of due restore points in bounded batches", async () => {
|
||||
for (let index = 0; index < 60; index += 1) await seed({ closedDaysAgo: 10, deleteAfter: due() });
|
||||
expect(await service().tick()).toMatchObject({ scheduled: 50, truncated: true });
|
||||
expect(await service().tick()).toMatchObject({ scheduled: 10, truncated: false });
|
||||
expect(await service().tick()).toMatchObject({ scheduled: 0, truncated: false });
|
||||
});
|
||||
|
||||
it("cleans up abandoned pending restore points but leaves recent ones", async () => {
|
||||
const stale = await seed({ status: "pending", createdHoursAgo: 2 });
|
||||
const recent = await seed({ status: "pending", createdHoursAgo: 0 });
|
||||
await service().tick();
|
||||
expect(await reload(stale.id)).toMatchObject({ status: "deleted" });
|
||||
expect(await reload(recent.id)).toMatchObject({ status: "pending" });
|
||||
});
|
||||
|
||||
it("deletes superseded restore points", async () => {
|
||||
const row = await seed({ status: "superseded" });
|
||||
await service().tick();
|
||||
expect(await reload(row.id)).toMatchObject({ status: "deleted" });
|
||||
});
|
||||
|
||||
it("does not mark an object deleted when it is still present after deletion", async () => {
|
||||
const row = await seed({ status: "delete_requested" });
|
||||
silentlyKeep = true;
|
||||
expect(await service().tick()).toMatchObject({ failed: 1, deleted: 0 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "delete_requested", lastError: "restore_point_object_still_present" });
|
||||
});
|
||||
|
||||
it("retries a failed deletion only after its retry deadline", async () => {
|
||||
const row = await seed({ status: "delete_requested" });
|
||||
failDeletes = true;
|
||||
expect(await service().tick()).toMatchObject({ failed: 1 });
|
||||
failDeletes = false;
|
||||
expect(await service().tick()).toMatchObject({ failed: 0, deleted: 0 });
|
||||
expect(await service().nextDeadlineAt()).toBe((await reload(row.id)).updatedAt.getTime() + GIT_RESTORE_POINT_RETRY_MS);
|
||||
expect(await service({ at: new Date(now.getTime() + GIT_RESTORE_POINT_RETRY_MS + 1000) }).tick()).toMatchObject({ deleted: 1 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "deleted", lastError: null });
|
||||
});
|
||||
|
||||
it("reports the real next deadline, and none when idle", async () => {
|
||||
expect(await service().nextDeadlineAt()).toBeNull();
|
||||
await seed({ openChild: true });
|
||||
expect(await service().nextDeadlineAt()).toBeNull();
|
||||
const reviewed = await seed({ closedDaysAgo: 10, deleteAfter: due() });
|
||||
expect(await service().nextDeadlineAt()).toBe(due().getTime());
|
||||
await service().tick();
|
||||
expect(await service().nextDeadlineAt()).toBe((await reload(reviewed.id)).deleteAfter!.getTime());
|
||||
const pending = await seed({ status: "pending", createdHoursAgo: 0 });
|
||||
expect(await service().nextDeadlineAt()).toBe((await reload(pending.id)).createdAt.getTime() + 60 * 60 * 1000);
|
||||
await seed({ status: "delete_requested" });
|
||||
expect(await service().nextDeadlineAt()).toBe(now.getTime());
|
||||
});
|
||||
|
||||
it("marks only the deleted issue's restore points when an issue is removed", async () => {
|
||||
const removed = await seed({ deleteAfter: new Date(now.getTime() + 80 * DAY_MS) });
|
||||
const kept = await seed({ deleteAfter: new Date(now.getTime() + 80 * DAY_MS) });
|
||||
await issueService(db).remove(removed.sourceIssueId!);
|
||||
expect(await reload(removed.id)).toMatchObject({ status: "delete_requested" });
|
||||
expect(await reload(kept.id)).toMatchObject({ status: "active" });
|
||||
await service().tick();
|
||||
expect(deleted).toEqual([removed.objectKey]);
|
||||
});
|
||||
|
||||
it("marks every restore point of a deleted company", async () => {
|
||||
const first = await seed({ deleteAfter: new Date(now.getTime() + 80 * DAY_MS) });
|
||||
const second = await seed({ companyId: first.companyId, status: "pending" });
|
||||
const other = await seed({ deleteAfter: new Date(now.getTime() + 80 * DAY_MS) });
|
||||
await deleteCompany(db, first.companyId);
|
||||
expect(await reload(first.id)).toMatchObject({ status: "delete_requested" });
|
||||
expect(await reload(second.id)).toMatchObject({ status: "delete_requested" });
|
||||
expect(await reload(other.id)).toMatchObject({ status: "active" });
|
||||
await service().tick();
|
||||
expect(deleted.sort()).toEqual([first.objectKey, second.objectKey].sort());
|
||||
});
|
||||
|
||||
it("requests deletion by issue without touching superseded rows", async () => {
|
||||
const row = await seed({ status: "superseded" });
|
||||
await db.transaction(async (tx) => requestRestorePointDeletion(tx, { sourceIssueId: row.sourceIssueId! }));
|
||||
expect(await reload(row.id)).toMatchObject({ status: "superseded" });
|
||||
});
|
||||
|
||||
it("schedules nothing when idle, and only the next deadline when restore points exist", async () => {
|
||||
let task!: { run: (signal: AbortSignal) => Promise<unknown>; nextRunAt?: () => number | null };
|
||||
const coordinator = { register: (_queue: string, registered: typeof task) => { task = registered; return { ready: Promise.resolve(), wake: vi.fn() }; } };
|
||||
registerGitRestorePointWork(coordinator as never, service(), () => {});
|
||||
await task.run(new AbortController().signal);
|
||||
expect(task.nextRunAt?.()).toBeNull();
|
||||
const row = await seed({ closedDaysAgo: 10, deleteAfter: due() });
|
||||
await task.run(new AbortController().signal);
|
||||
expect(task.nextRunAt?.()).toBe((await reload(row.id)).deleteAfter!.getTime());
|
||||
});
|
||||
|
||||
it("wakes on issue changes only while restore points exist", async () => {
|
||||
let task!: { run: (signal: AbortSignal) => Promise<unknown>; nextRunAt?: () => number | null };
|
||||
const wake = vi.fn();
|
||||
const coordinator = { register: (_queue: string, registered: typeof task) => { task = registered; return { ready: Promise.resolve(), wake }; } };
|
||||
const worker = registerGitRestorePointWork(coordinator as never, service(), () => {});
|
||||
await task.run(new AbortController().signal);
|
||||
worker.issuesChanged();
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
await seed({ openChild: true });
|
||||
await task.run(new AbortController().signal);
|
||||
worker.issuesChanged();
|
||||
expect(wake).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("issue tree trigger", () => {
|
||||
async function child(parentId: string, companyId: string, status = "blocked") {
|
||||
const [row] = await db.insert(issues).values({ companyId, title: "Child", status, parentId }).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
it("marks a parent's restore point due when its last open child closes", async () => {
|
||||
const row = await seed();
|
||||
const open = await child(row.sourceIssueId!, row.companyId);
|
||||
await db.update(executionWorkspaceRestorePoints).set({ deleteAfter: null }).where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, open.id));
|
||||
expect((await reload(row.id)).deleteAfter!.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it("marks the former parent when a child is deleted or moved", async () => {
|
||||
const deletedFrom = await seed();
|
||||
const movedFrom = await seed();
|
||||
const deleting = await child(deletedFrom.sourceIssueId!, deletedFrom.companyId);
|
||||
const moving = await child(movedFrom.sourceIssueId!, movedFrom.companyId);
|
||||
const [elsewhere] = await db.insert(issues).values({ companyId: movedFrom.companyId, title: "Other", status: "todo" }).returning();
|
||||
await db.update(executionWorkspaceRestorePoints).set({ deleteAfter: null });
|
||||
await db.delete(issues).where(eq(issues.id, deleting.id));
|
||||
await db.update(issues).set({ parentId: elsewhere!.id }).where(eq(issues.id, moving.id));
|
||||
expect((await reload(deletedFrom.id)).deleteAfter).not.toBeNull();
|
||||
expect((await reload(movedFrom.id)).deleteAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks a restore point when its own source issue changes status", async () => {
|
||||
const row = await seed({ issueStatus: "in_progress" });
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, row.sourceIssueId!));
|
||||
expect((await reload(row.id)).deleteAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
it("leaves unrelated, inactive and already due restore points alone", async () => {
|
||||
const unrelated = await seed({ issueStatus: "in_progress" });
|
||||
const superseded = await seed({ status: "superseded", issueStatus: "in_progress" });
|
||||
const alreadyDue = await seed({ issueStatus: "in_progress", deleteAfter: new Date(now.getTime() - DAY_MS) });
|
||||
const [other] = await db.insert(issues).values({ companyId: unrelated.companyId, title: "Other", status: "todo" }).returning();
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, other!.id));
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, superseded.sourceIssueId!));
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, alreadyDue.sourceIssueId!));
|
||||
expect((await reload(unrelated.id)).deleteAfter).toBeNull();
|
||||
expect((await reload(superseded.id)).deleteAfter).toBeNull();
|
||||
expect((await reload(alreadyDue.id)).deleteAfter!.getTime()).toBe(now.getTime() - DAY_MS);
|
||||
});
|
||||
|
||||
it("keeps a restore point due when its tree closes between the worker's read and write", async () => {
|
||||
const row = await seed({ deleteAfter: due() });
|
||||
const open = await child(row.sourceIssueId!, row.companyId);
|
||||
const racing = gitRestorePointRetentionService(db, {
|
||||
storage,
|
||||
afterTreeRead: async () => {
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, open.id));
|
||||
},
|
||||
});
|
||||
expect(await racing.tick()).toMatchObject({ raced: 1, scheduled: 0 });
|
||||
const stillDue = await reload(row.id);
|
||||
expect(stillDue.deleteAfter!.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
await gitRestorePointRetentionService(db, { storage }).tick();
|
||||
const reviewed = await reload(row.id);
|
||||
expect(reviewed.deleteAfter!.getTime() - reviewed.treeClosedAt!.getTime()).toBe(90 * DAY_MS);
|
||||
});
|
||||
|
||||
it("does not expire a restore point whose tree reopens between the worker's read and write", async () => {
|
||||
const row = await seed({ closedDaysAgo: 120, deleteAfter: due() });
|
||||
const racing = gitRestorePointRetentionService(db, {
|
||||
storage,
|
||||
afterTreeRead: async () => {
|
||||
await db.update(issues).set({ status: "todo", completedAt: null }).where(eq(issues.id, row.sourceIssueId!));
|
||||
},
|
||||
});
|
||||
expect(await racing.tick()).toMatchObject({ raced: 1, expired: 0, deleted: 0 });
|
||||
expect(await reload(row.id)).toMatchObject({ status: "active" });
|
||||
await gitRestorePointRetentionService(db, { storage }).tick();
|
||||
expect(await reload(row.id)).toMatchObject({ status: "active", deleteAfter: null });
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the worker schedule expiry once an open tree closes", async () => {
|
||||
const row = await seed();
|
||||
const open = await child(row.sourceIssueId!, row.companyId);
|
||||
await db.update(executionWorkspaceRestorePoints).set({ deleteAfter: due() }).where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
const live = gitRestorePointRetentionService(db, { storage });
|
||||
await live.tick();
|
||||
expect((await reload(row.id)).deleteAfter).toBeNull();
|
||||
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, open.id));
|
||||
await live.tick();
|
||||
const reviewed = await reload(row.id);
|
||||
expect(reviewed.deleteAfter!.getTime() - reviewed.treeClosedAt!.getTime()).toBe(90 * DAY_MS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyRestorePoint,
|
||||
captureRestorePoint,
|
||||
currentRestorePointSnapshot,
|
||||
importRestorePoint,
|
||||
openRestorePoint,
|
||||
RestorePointRestoreFailure,
|
||||
RestorePointSkip,
|
||||
sealRestorePoint,
|
||||
} from "../services/git-restore-point.ts";
|
||||
|
||||
const identity = { companyId: "company-1", workspaceId: "workspace-1", restorePointId: "restore-1", generation: 1 };
|
||||
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
return execFileSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "t@example.com" },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function write(root: string, relative: string, contents: string, mode?: number) {
|
||||
await fs.mkdir(path.dirname(path.join(root, relative)), { recursive: true });
|
||||
await fs.writeFile(path.join(root, relative), contents);
|
||||
if (mode !== undefined) await fs.chmod(path.join(root, relative), mode);
|
||||
}
|
||||
|
||||
async function read(root: string, relative: string) {
|
||||
return fs.readFile(path.join(root, relative), "utf8");
|
||||
}
|
||||
|
||||
describe("git restore points", () => {
|
||||
let root: string;
|
||||
let remote: string;
|
||||
let work: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32));
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-test-"));
|
||||
remote = path.join(root, "remote.git");
|
||||
work = path.join(root, "work");
|
||||
git(root, "init", "-q", "--bare", "-b", "master", remote);
|
||||
git(root, "clone", "-q", remote, work);
|
||||
await write(work, ".gitignore", "node_modules/\n.env\n");
|
||||
await write(work, "src/app.ts", "export const value = 1;\n");
|
||||
await write(work, "src/remove-me.ts", "export const gone = true;\n");
|
||||
await write(work, "README.md", "hello\n");
|
||||
git(work, "add", "-A");
|
||||
git(work, "commit", "-q", "-m", "initial");
|
||||
git(work, "push", "-q", "origin", "HEAD:master");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const upstream = () => [git(work, "rev-parse", "origin/master")];
|
||||
|
||||
async function freshClone(name: string) {
|
||||
const target = path.join(root, name);
|
||||
git(root, "clone", "-q", remote, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
it("returns nothing when the workspace is fully reproducible from the remote", async () => {
|
||||
await write(work, "node_modules/pkg/index.js", "ignored\n");
|
||||
expect(await captureRestorePoint({ cwd: work, upstreamCommits: upstream() })).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips unpushed commits, staged and unstaged edits, untracked files, deletions and modes", async () => {
|
||||
git(work, "checkout", "-q", "-b", "feature");
|
||||
await write(work, "src/app.ts", "export const value = 2;\n");
|
||||
git(work, "commit", "-q", "-am", "unpushed one");
|
||||
await write(work, "src/feature.ts", "export const feature = true;\n");
|
||||
git(work, "add", "src/feature.ts");
|
||||
git(work, "commit", "-q", "-m", "unpushed two");
|
||||
await write(work, "src/app.ts", "export const value = 3;\n");
|
||||
git(work, "add", "src/app.ts");
|
||||
await write(work, "src/app.ts", "export const value = 4;\n");
|
||||
await fs.rm(path.join(work, "src/remove-me.ts"));
|
||||
await write(work, "notes/todo.md", "untracked work\n");
|
||||
await write(work, "scripts/run.sh", "#!/bin/sh\necho hi\n", 0o755);
|
||||
await write(work, "private/key.txt", "secret-ish\n", 0o600);
|
||||
await fs.symlink("../README.md", path.join(work, "notes/readme-link"));
|
||||
await write(work, "node_modules/pkg/index.js", "ignored\n");
|
||||
await write(work, ".env", "TOKEN=ignored\n");
|
||||
await write(work, "config/.env.local", "TOKEN=untracked-env\n");
|
||||
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
expect(captured).not.toBeNull();
|
||||
const { payload, manifest } = captured!;
|
||||
expect(manifest.head).toBe(git(work, "rev-parse", "HEAD"));
|
||||
expect(manifest.prerequisites).toEqual(upstream());
|
||||
expect(await currentRestorePointSnapshot(work)).toEqual({ head: manifest.head, index: manifest.index, worktree: manifest.worktree, modes: manifest.modes });
|
||||
|
||||
const restored = await freshClone("restored");
|
||||
expect(() => git(restored, "cat-file", "-e", `${manifest.head}^{commit}`)).toThrow();
|
||||
const imported = await importRestorePoint({ repositoryCwd: restored, payload });
|
||||
git(restored, "checkout", "-q", "-b", "feature", imported.head);
|
||||
await applyRestorePoint({ cwd: restored, manifest: imported });
|
||||
|
||||
expect(await read(restored, "src/app.ts")).toBe("export const value = 4;\n");
|
||||
expect(git(restored, "show", ":src/app.ts")).toBe("export const value = 3;");
|
||||
expect(await read(restored, "src/feature.ts")).toBe("export const feature = true;\n");
|
||||
await expect(fs.access(path.join(restored, "src/remove-me.ts"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await read(restored, "notes/todo.md")).toBe("untracked work\n");
|
||||
expect(git(restored, "status", "--porcelain", "--", "notes/todo.md")).toBe("?? notes/todo.md");
|
||||
expect((await fs.stat(path.join(restored, "scripts/run.sh"))).mode & 0o777).toBe(0o755);
|
||||
expect((await fs.stat(path.join(restored, "private/key.txt"))).mode & 0o777).toBe(0o600);
|
||||
expect(await fs.readlink(path.join(restored, "notes/readme-link"))).toBe("../README.md");
|
||||
await expect(fs.access(path.join(restored, "node_modules"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.access(path.join(restored, ".env"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.access(path.join(restored, "config/.env.local"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("captures edits to tracked environment files", async () => {
|
||||
await write(work, ".env.example", "TOKEN=\n");
|
||||
git(work, "add", ".env.example");
|
||||
git(work, "commit", "-q", "-m", "example env");
|
||||
git(work, "push", "-q", "origin", "HEAD:master");
|
||||
git(work, "fetch", "-q");
|
||||
await write(work, ".env.example", "TOKEN=\nOTHER=\n");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
expect(captured).not.toBeNull();
|
||||
const restored = await freshClone("restored");
|
||||
await importRestorePoint({ repositoryCwd: restored, payload: captured!.payload });
|
||||
await applyRestorePoint({ cwd: restored, manifest: captured!.manifest });
|
||||
expect(await read(restored, ".env.example")).toBe("TOKEN=\nOTHER=\n");
|
||||
});
|
||||
|
||||
it("applies safeguards and modes to staged files whose contents match the index", async () => {
|
||||
await write(work, "private/staged.txt", "staged secret\n", 0o600);
|
||||
git(work, "add", "private/staged.txt");
|
||||
await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream(), limits: { maxFiles: 0 } }))
|
||||
.rejects.toMatchObject({ reason: "restore_point_too_large" });
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
expect(captured!.manifest.modes).toEqual([{ path: "private/staged.txt", mode: 0o600 }]);
|
||||
const restored = await freshClone("restored");
|
||||
await importRestorePoint({ repositoryCwd: restored, payload: captured!.payload });
|
||||
await applyRestorePoint({ cwd: restored, manifest: captured!.manifest });
|
||||
expect((await fs.stat(path.join(restored, "private/staged.txt"))).mode & 0o777).toBe(0o600);
|
||||
expect(git(restored, "status", "--porcelain", "--", "private/staged.txt")).toBe("A private/staged.txt");
|
||||
});
|
||||
|
||||
it("detects permission-only changes after capture", async () => {
|
||||
await write(work, "private/key.txt", "secret\n", 0o600);
|
||||
const before = await currentRestorePointSnapshot(work);
|
||||
await fs.chmod(path.join(work, "private/key.txt"), 0o644);
|
||||
expect(await currentRestorePointSnapshot(work)).not.toEqual(before);
|
||||
});
|
||||
|
||||
it("restores exact bytes when the repository configures a diff text conversion", async () => {
|
||||
await write(work, ".gitattributes", "*.txt diff=upper\n");
|
||||
await write(work, "notes.txt", "original\n");
|
||||
git(work, "add", ".gitattributes", "notes.txt");
|
||||
git(work, "commit", "-q", "-m", "textconv");
|
||||
git(work, "push", "-q", "origin", "HEAD:master");
|
||||
git(work, "fetch", "-q");
|
||||
await write(work, "notes.txt", "changed contents\n");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
const restored = await freshClone("restored");
|
||||
git(restored, "config", "diff.upper.textconv", "tr a-z A-Z <");
|
||||
await importRestorePoint({ repositoryCwd: restored, payload: captured!.payload });
|
||||
await applyRestorePoint({ cwd: restored, manifest: captured!.manifest });
|
||||
expect(await read(restored, "notes.txt")).toBe("changed contents\n");
|
||||
});
|
||||
|
||||
it("captures a small edit despite a long ordinary history", async () => {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
await write(work, "log.txt", `${index}\n`);
|
||||
git(work, "commit", "-q", "-am", `history ${index}`, "--allow-empty");
|
||||
}
|
||||
git(work, "add", "-A");
|
||||
git(work, "commit", "-q", "-m", "tracked log", "--allow-empty");
|
||||
git(work, "push", "-q", "origin", "HEAD:master");
|
||||
git(work, "fetch", "-q");
|
||||
await write(work, "notes/todo.md", "small edit\n");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
expect(captured!.manifest.preserved).toEqual([]);
|
||||
expect(captured!.manifest.worktree).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to capture when history-only commits exceed the supported limit", async () => {
|
||||
git(work, "checkout", "-q", "-b", "feature");
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
await write(work, `scratch/${index}.txt`, `${index}\n`);
|
||||
git(work, "add", "-A");
|
||||
git(work, "commit", "-q", "-m", `scratch ${index}`);
|
||||
git(work, "reset", "-q", "--hard", "origin/master");
|
||||
}
|
||||
await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream() }))
|
||||
.rejects.toMatchObject({ reason: "restore_point_history_too_large" });
|
||||
});
|
||||
|
||||
it("keeps capture objects out of the repository", async () => {
|
||||
await write(work, "notes/todo.md", "untracked work\n");
|
||||
const before = git(work, "count-objects", "-v");
|
||||
expect(await captureRestorePoint({ cwd: work, upstreamCommits: upstream() })).not.toBeNull();
|
||||
expect(git(work, "count-objects", "-v")).toBe(before);
|
||||
expect(git(work, "fsck", "--no-reflogs", "--unreachable", "--no-progress")).toBe("");
|
||||
});
|
||||
|
||||
it("produces the same snapshot ids for unchanged contents and different ids after a change", async () => {
|
||||
await write(work, "notes/todo.md", "one\n");
|
||||
const first = await currentRestorePointSnapshot(work);
|
||||
expect(await currentRestorePointSnapshot(work)).toEqual(first);
|
||||
await write(work, "notes/todo.md", "two\n");
|
||||
expect(await currentRestorePointSnapshot(work)).not.toEqual(first);
|
||||
});
|
||||
|
||||
it("bundles commits only reachable from history that is not on any branch", async () => {
|
||||
git(work, "checkout", "-q", "-b", "feature");
|
||||
await write(work, "src/lost.ts", "export const lost = true;\n");
|
||||
git(work, "add", "src/lost.ts");
|
||||
git(work, "commit", "-q", "-m", "rewritten away");
|
||||
const lost = git(work, "rev-parse", "HEAD");
|
||||
git(work, "reset", "-q", "--hard", "origin/master");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
expect(captured!.manifest.preserved).toEqual([lost]);
|
||||
const restored = await freshClone("restored");
|
||||
await importRestorePoint({ repositoryCwd: restored, payload: captured!.payload });
|
||||
expect(git(restored, "cat-file", "-t", lost)).toBe("commit");
|
||||
expect(git(restored, "for-each-ref", "refs/paperclip-restore-import/")).toBe("");
|
||||
});
|
||||
|
||||
it("skips a nested untracked repository", async () => {
|
||||
git(root, "init", "-q", path.join(work, "vendor/other"));
|
||||
await write(work, "vendor/other/file.txt", "x\n");
|
||||
await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream() }))
|
||||
.rejects.toMatchObject({ reason: "untracked_nested_repository" });
|
||||
});
|
||||
|
||||
it("ignores special files, which git never tracks", async () => {
|
||||
execFileSync("mkfifo", [path.join(work, "pipe")]);
|
||||
expect(await captureRestorePoint({ cwd: work, upstreamCommits: upstream() })).toBeNull();
|
||||
});
|
||||
|
||||
it("skips workspaces over the size limits", async () => {
|
||||
await write(work, "big.bin", "x".repeat(2048));
|
||||
await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream(), limits: { maxFileBytes: 1024 } }))
|
||||
.rejects.toBeInstanceOf(RestorePointSkip);
|
||||
await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream(), limits: { maxFiles: 0 } }))
|
||||
.rejects.toMatchObject({ reason: "restore_point_too_large" });
|
||||
});
|
||||
|
||||
it("refuses to import a bundle whose prerequisites are missing", async () => {
|
||||
git(work, "checkout", "-q", "-b", "feature");
|
||||
await write(work, "src/app.ts", "export const value = 2;\n");
|
||||
git(work, "commit", "-q", "-am", "unpushed");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
const empty = path.join(root, "empty");
|
||||
git(root, "init", "-q", empty);
|
||||
await expect(importRestorePoint({ repositoryCwd: empty, payload: captured!.payload }))
|
||||
.rejects.toMatchObject({ reason: "bundle_prerequisites_missing" });
|
||||
});
|
||||
|
||||
it("refuses to apply onto a different commit", async () => {
|
||||
await write(work, "notes/todo.md", "work\n");
|
||||
const captured = await captureRestorePoint({ cwd: work, upstreamCommits: upstream() });
|
||||
const restored = await freshClone("restored");
|
||||
await write(restored, "other.txt", "x\n");
|
||||
git(restored, "add", "other.txt");
|
||||
git(restored, "commit", "-q", "-m", "diverged");
|
||||
await expect(applyRestorePoint({ cwd: restored, manifest: captured!.manifest }))
|
||||
.rejects.toMatchObject({ reason: "head_mismatch" });
|
||||
});
|
||||
|
||||
describe("encryption", () => {
|
||||
const payload = Buffer.from("restore point payload");
|
||||
|
||||
it("round-trips and binds to the restore point identity", async () => {
|
||||
const sealed = await sealRestorePoint(payload, identity);
|
||||
expect(sealed.ciphertext.includes(payload)).toBe(false);
|
||||
expect(await openRestorePoint({ ...sealed, identity })).toEqual(payload);
|
||||
await expect(openRestorePoint({ ...sealed, identity: { ...identity, workspaceId: "workspace-2" } }))
|
||||
.rejects.toMatchObject({ reason: "key_identity_mismatch" });
|
||||
});
|
||||
|
||||
it("rejects tampered, truncated or rehashed ciphertext", async () => {
|
||||
const sealed = await sealRestorePoint(payload, identity);
|
||||
const flipped = Buffer.from(sealed.ciphertext);
|
||||
flipped[flipped.length - 20] ^= 1;
|
||||
await expect(openRestorePoint({ ...sealed, ciphertext: flipped, identity }))
|
||||
.rejects.toMatchObject({ reason: "ciphertext_hash_mismatch" });
|
||||
const { createHash } = await import("node:crypto");
|
||||
await expect(openRestorePoint({ ...sealed, ciphertext: flipped, sha256: createHash("sha256").update(flipped).digest("hex"), identity }))
|
||||
.rejects.toMatchObject({ reason: "ciphertext_authentication_failed" });
|
||||
const truncated = sealed.ciphertext.subarray(0, 10);
|
||||
await expect(openRestorePoint({ ...sealed, ciphertext: truncated, sha256: createHash("sha256").update(truncated).digest("hex"), identity }))
|
||||
.rejects.toBeInstanceOf(RestorePointRestoreFailure);
|
||||
});
|
||||
|
||||
it("cannot be opened with a different master key", async () => {
|
||||
const sealed = await sealRestorePoint(payload, identity);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "42".repeat(32));
|
||||
await expect(openRestorePoint({ ...sealed, identity })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describe("instance settings service", () => {
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb,
|
||||
ensurePostgresDatabase, applyPendingMigrations, heartbeatRuns, issues, toolCatalogEntries, toolConnections } from "@paperclipai/db";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { createToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { UNIKRAFT_TOOLS, executeUnikraftTool, unikraftClient } from "../services/connectors/unikraft/client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const actor = { actorType: "user" as const, actorId: "unikraft-reviewer" };
|
||||
const credential = "unikraft-fixture-token";
|
||||
(support.supported ? describe : describe.skip)("Unikraft governed provider actions", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
beforeAll(async () => {
|
||||
if (external) {
|
||||
const url = new URL(external); url.pathname = "/unikraft_" + randomUUID().replaceAll("-", "");
|
||||
await ensurePostgresDatabase(external, url.pathname.slice(1));
|
||||
await applyPendingMigrations(url.toString()); db = createDb(url.toString());
|
||||
} else { temp = await startEmbeddedPostgresTestDatabase("unikraft-connector-"); db = createDb(temp.connectionString); }
|
||||
}, 30000);
|
||||
afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); });
|
||||
|
||||
async function fixture(options: { personal?: boolean; metro?: string } = {}) {
|
||||
const [company] = await db.insert(companies).values({ name: "Unikraft fixture", issuePrefix: `U${randomUUID().slice(0, 6)}` }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" });
|
||||
const instanceId = randomUUID();
|
||||
const request = vi.fn(async (url: string, init: RequestInit) => {
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${credential}`);
|
||||
expect(["error", "manual"]).toContain(init.redirect);
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.hostname).toMatch(/^api\.(dal|fra|sfo|sin|was)\.unikraft\.cloud$/);
|
||||
const metro = parsed.hostname.split(".")[1];
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||||
if (parsed.pathname === "/v1/users/quotas") return Response.json({ status: "success", data: { quotas: [{ uuid: randomUUID() }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === "/v1/instances" && init.method === "GET") {
|
||||
return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, name: "web", metro, selector: body ?? null }] }, op_time_us: 1 });
|
||||
}
|
||||
if (parsed.pathname === "/v1/instances" && init.method === "POST") return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, ...body }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === `/v1/instances/${instanceId}` && init.method === "DELETE") return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, state: "deleted" }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === "/v1/instances/web/plugins/sandbox/commands" && init.method === "POST") {
|
||||
return Response.json({ uuid: randomUUID(), cmd: body.cmd, stdout: `ran with ${credential}` });
|
||||
}
|
||||
throw new Error(`Unexpected provider request ${init.method} ${url}`);
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "unikraft", methodKey: "api-token",
|
||||
...(options.personal ? { grantKind: "user" as const } : {}), ...(options.metro ? { configValues: { metro: options.metro } } : {}),
|
||||
credentialValues: { "credentials.authorization": credential } }, actor);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Operator", role: "engineer", adapterType: "process" }).returning();
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(c => c.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] } }, actor);
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Provider work", assigneeAgentId: agent.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id,
|
||||
scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning();
|
||||
const gateway = createToolGatewayService(db, { remoteHttpRequest: request });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id });
|
||||
const tools = await gateway.listToolsForSession(session.token);
|
||||
const call = (name: string, parameters: unknown) => {
|
||||
const tool = tools.find(t => t.upstreamToolName === name);
|
||||
expect(tool, name).toBeTruthy();
|
||||
return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters, idempotencyKey: randomUUID() });
|
||||
};
|
||||
return { company, service, connected, agent, request, call, gateway, session, instanceId };
|
||||
}
|
||||
|
||||
it.each([false, true])("uses the selected vault identity for real gateway reads, writes and sandbox commands (personal=%s)", async personal => {
|
||||
const f = await fixture({ personal });
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connected.connectionId));
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
|
||||
expect(grant.kind).toBe(personal ? "user" : "organization");
|
||||
expect(secret.scope).toBe(personal ? "user" : "company");
|
||||
expect(secret.ownerUserId).toBe(personal ? actor.actorId : null);
|
||||
expect(f.connected.catalog).toHaveLength(UNIKRAFT_TOOLS.length);
|
||||
expect(f.connected.catalog.find(c => c.toolName === "unikraft_get_instances")).toMatchObject({ isReadOnly: true });
|
||||
expect(f.connected.catalog.find(c => c.toolName === "unikraft_create_instance")).toMatchObject({ isReadOnly: false, isDestructive: false });
|
||||
for (const name of ["unikraft_delete_instance_by_uuid", "unikraft_stop_instances", "unikraft_sandbox_run_command"]) {
|
||||
expect(f.connected.catalog.find(c => c.toolName === name), name).toMatchObject({ isDestructive: true, isReadOnly: false });
|
||||
}
|
||||
expect(JSON.stringify(await f.service.getConnection(f.connected.connectionId))).not.toContain(credential);
|
||||
// The token is verified against the default metro.
|
||||
expect(f.request.mock.calls.some(([url]) => url === "https://api.fra.unikraft.cloud/v1/users/quotas")).toBe(true);
|
||||
// Batch selectors travel as a JSON body even on GET.
|
||||
const listed = JSON.stringify(await f.call("unikraft_get_instances", { body: [{ name: "web" }] }));
|
||||
expect(listed).toContain(f.instanceId); expect(listed).toContain('"metro":"fra"'); expect(listed).toContain('"name":"web"');
|
||||
expect(JSON.stringify(await f.call("unikraft_get_instances", { metro: "sin" }))).toContain('"metro":"sin"');
|
||||
expect(JSON.stringify(await f.call("unikraft_create_instance", { body: { image: "nginx:latest" } }))).toContain("nginx:latest");
|
||||
expect(JSON.stringify(await f.call("unikraft_delete_instance_by_uuid", { path: { uuid: f.instanceId } }))).toContain("deleted");
|
||||
const ran = JSON.stringify(await f.call("unikraft_sandbox_run_command", { instance: "web", body: { cmd: "echo done" } }));
|
||||
expect(ran).toContain("echo done"); expect(ran).not.toContain(credential);
|
||||
}, 30000);
|
||||
|
||||
it("uses the connection's default metro for verification and actions that do not name one", async () => {
|
||||
const f = await fixture({ metro: "was" });
|
||||
expect(f.request.mock.calls.some(([url]) => url === "https://api.was.unikraft.cloud/v1/users/quotas")).toBe(true);
|
||||
expect(JSON.stringify(await f.call("unikraft_get_instances", {}))).toContain('"metro":"was"');
|
||||
});
|
||||
|
||||
it("rejects unpublished metros, unknown arguments, path traversal and foreign bindings before forwarding the token", async () => {
|
||||
const f = await fixture(); f.request.mockClear();
|
||||
await expect(f.call("unikraft_get_instances", { metro: "attacker.example" })).rejects.toThrow();
|
||||
await expect(f.call("unikraft_get_instances", { command: "printenv" })).rejects.toThrow();
|
||||
await expect(f.call("unikraft_sandbox_run_command", { instance: "../../users/quotas", body: { cmd: "true" } })).rejects.toThrow("Invalid Unikraft path");
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await expect(executeUnikraftTool({ binding: { companyId: randomUUID(), agentId: f.agent.id },
|
||||
connection: { companyId: f.company.id, config: {} }, headers: { Authorization: `Bearer ${credential}` }, request: f.request,
|
||||
toolName: "unikraft_get_instances", parameters: {} })).rejects.toThrow("Connection not found");
|
||||
await expect(executeUnikraftTool({ binding: { companyId: f.company.id, agentId: f.agent.id },
|
||||
connection: { companyId: f.company.id, config: { methodConfig: { metro: "evil" } } }, headers: { Authorization: `Bearer ${credential}` },
|
||||
request: f.request, toolName: "unikraft_get_instances", parameters: {} })).rejects.toThrow("Choose a Unikraft Cloud metro");
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks revocation and Off policies for a previously created gateway session", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
enabledCatalogEntryIds: f.connected.catalog.filter(c => c.toolName !== "unikraft_sandbox_run_command").map(c => c.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
||||
}, actor);
|
||||
f.request.mockClear();
|
||||
await expect(f.call("unikraft_sandbox_run_command", { instance: "web", body: { cmd: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await db.update(toolConnections).set({ enabled: false }).where(and(eq(toolConnections.id, f.connected.connectionId), eq(toolConnections.companyId, f.company.id)));
|
||||
await expect(f.call("unikraft_get_instances", {})).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a stale same-name contract and quarantines it on refresh", async () => {
|
||||
const f = await fixture();
|
||||
const changed = f.connected.catalog.find(entry => entry.toolName === "unikraft_sandbox_run_command")!;
|
||||
await db.update(toolCatalogEntries).set({ versionHash: "earlier-provider-contract", schemaHash: "earlier-schema", riskLevel: "read" })
|
||||
.where(eq(toolCatalogEntries.id, changed.id));
|
||||
f.request.mockClear();
|
||||
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === changed.toolName)).toBe(false);
|
||||
await expect(f.call(changed.toolName, { instance: "web", body: { cmd: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
const refreshed = await f.service.refreshCatalog(f.connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(refreshed.catalog.find(entry => entry.toolName === changed.toolName)?.status).toBe("quarantined");
|
||||
});
|
||||
|
||||
it("does not export the token to agents", async () => {
|
||||
const f = await fixture();
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agent.id));
|
||||
await expect(f.service.mintConnectionTokenForAgent({ connectionId: f.connected.connectionId, companyId: f.company.id,
|
||||
agentId: f.agent.id, runId: run.id, body: {} as never })).rejects.toThrow("Unikraft credentials stay in the governed tool gateway");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unikraft provider client", () => {
|
||||
it("validates every published action schema before provider dispatch", async () => {
|
||||
const request = vi.fn(async () => { throw new Error("Unexpected provider request"); });
|
||||
expect(UNIKRAFT_TOOLS).toHaveLength(105);
|
||||
for (const tool of UNIKRAFT_TOOLS) {
|
||||
await expect(executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" },
|
||||
connection: { companyId: "company", config: {} }, headers: { Authorization: `Bearer ${credential}` },
|
||||
request, toolName: tool.name, parameters: null }), tool.name).rejects.toThrow("Invalid Unikraft action arguments");
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("sanitizes provider denials and rejects an unconfirmed token", async () => {
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => new Response(credential, { status: 401 })).probe())
|
||||
.rejects.toThrow("Unikraft denied access");
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => Response.json({ status: "error" })).probe())
|
||||
.rejects.toThrow("did not confirm");
|
||||
});
|
||||
it("accepts the documented -1 drain timeout for the longest drain", async () => {
|
||||
const request = vi.fn(async (_url: string, init: RequestInit) => Response.json({ status: "success", echo: JSON.parse(init.body as string) }));
|
||||
for (const [toolName, parameters] of [
|
||||
["unikraft_stop_instances", { body: [{ name: "web", drain_timeout_ms: -1 }] }],
|
||||
["unikraft_suspend_instance_by_uuid", { path: { uuid: randomUUID() }, body: { drain_timeout_ms: -1 } }],
|
||||
] as const) {
|
||||
await executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName, parameters });
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
await expect(executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName: "unikraft_stop_instances",
|
||||
parameters: { body: [{ name: "web", drain_timeout_ms: -2 }] } })).rejects.toThrow("Invalid Unikraft action arguments");
|
||||
});
|
||||
it("reports a timed-out sandbox wait as a still-running command, not a provider failure", async () => {
|
||||
const commandId = randomUUID();
|
||||
const request = vi.fn(async () => new Response(null, { status: 408 }));
|
||||
const call = (toolName: string) => executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" },
|
||||
connection: { companyId: "company", config: {} }, headers: { Authorization: `Bearer ${credential}` }, request, toolName,
|
||||
parameters: { instance: "web", path: { uuid: commandId }, ...(toolName.endsWith("with_timeout") ? { body: { timeout_s: 0.01 } } : {}) } });
|
||||
await expect(call("unikraft_sandbox_wait_for_command_with_timeout")).resolves.toMatchObject({ status: "running", timedOut: true });
|
||||
expect(request.mock.calls[0]![0]).toBe(`https://api.fra.unikraft.cloud/v1/instances/web/plugins/sandbox/commands/${commandId}/wait_timeout`);
|
||||
// Other operations keep treating 408 as a failure.
|
||||
await expect(call("unikraft_sandbox_get_command_by_uuid")).rejects.toThrow("Unikraft could not complete this operation");
|
||||
});
|
||||
it("bounds response bytes", async () => {
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => new Response("x".repeat(8 * 1024 * 1024 + 1))).probe())
|
||||
.rejects.toThrow("exceeded the limit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { connect, type AddressInfo, type Socket } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { executeUnikraftTool } from "../services/connectors/unikraft/client.js";
|
||||
import { guardedRemoteHttpFetch } from "../services/remote-http-fetch.js";
|
||||
|
||||
const credential = "unikraft-http-fixture-token";
|
||||
const servers: Server[] = [], sockets: Socket[] = [];
|
||||
afterEach(async () => {
|
||||
sockets.splice(0).forEach(socket => socket.destroy());
|
||||
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
async function provider() {
|
||||
const received: { method: string; url: string; host: string; contentType?: string; body: string }[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", chunk => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({ method: req.method!, url: req.url!, host: req.headers.host!, contentType: req.headers["content-type"],
|
||||
body: Buffer.concat(chunks).toString("utf8") });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "success", data: { instances: [] }, op_time_us: 1 }));
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.on("connection", socket => sockets.push(socket));
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
// Preserve the real pinned node:http path, including its body serializer.
|
||||
// Only the network/TLS leg is a controlled local stand-in.
|
||||
const request = (url: string, init: RequestInit) => {
|
||||
const endpoint = new URL(url);
|
||||
endpoint.protocol = "http:"; endpoint.port = String(port);
|
||||
return guardedRemoteHttpFetch(endpoint, init, {
|
||||
allowPrivateNetwork: false, lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||||
socketFactory: target => {
|
||||
const socket = connect({ host: "127.0.0.1", port });
|
||||
sockets.push(socket);
|
||||
Object.defineProperty(socket, "remoteAddress", { get: () => target.address, configurable: true });
|
||||
return socket;
|
||||
},
|
||||
responseTimeoutMs: 5000, error: (message, code) => Object.assign(new Error(message), { code }),
|
||||
});
|
||||
};
|
||||
const call = (toolName: string, parameters: unknown) => executeUnikraftTool({
|
||||
binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName, parameters,
|
||||
signal: AbortSignal.timeout(3000), deadlineAt: Date.now() + 3000,
|
||||
});
|
||||
return { call, received };
|
||||
}
|
||||
|
||||
describe("Unikraft production HTTP boundary", () => {
|
||||
it.each([
|
||||
["unikraft_get_instance_logs", "GET", { metro: "dal", query: { name: ["web"] }, body: [{ name: "web" }] }],
|
||||
["unikraft_delete_instances", "DELETE", { body: [{ name: "web" }] }],
|
||||
] as const)("sends %s JSON selectors as a %s request body through the pinned transport", async (toolName, method, parameters) => {
|
||||
const p = await provider();
|
||||
await p.call(toolName, parameters);
|
||||
expect(p.received).toHaveLength(1);
|
||||
const [received] = p.received;
|
||||
expect(received.method).toBe(method);
|
||||
expect(received.host).toMatch(new RegExp(`^api\\.${parameters.metro ?? "fra"}\\.unikraft\\.cloud:`));
|
||||
expect(received.contentType).toBe("application/json");
|
||||
expect(JSON.parse(received.body)).toEqual([{ name: "web" }]);
|
||||
if (method === "GET") expect(received.url).toBe("/v1/instances/log?name=web");
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ export interface Config {
|
||||
nativeHarnessBackupExpirationEnabled: boolean;
|
||||
nativeHarnessBackupExpirationDryRun: boolean;
|
||||
nativeHarnessBackupExpirationDays: number;
|
||||
gitRestorePointRetentionDays: number;
|
||||
serveUi: boolean;
|
||||
uiDevMiddleware: boolean;
|
||||
secretsProvider: SecretProvider;
|
||||
@@ -337,6 +338,7 @@ export function loadConfig(): Config {
|
||||
const skillVersionRetentionKeepNewest = nonNegativeIntegerEnv("PAPERCLIP_SKILL_VERSION_RETENTION_KEEP", 0);
|
||||
const skillVersionRetentionGraceMinutes = nonNegativeIntegerEnv("PAPERCLIP_SKILL_VERSION_RETENTION_GRACE_MINUTES", 60);
|
||||
const nativeHarnessBackupExpirationDays = Math.max(1, nonNegativeIntegerEnv("PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DAYS", 7));
|
||||
const gitRestorePointRetentionDays = Math.max(1, nonNegativeIntegerEnv("PAPERCLIP_GIT_RESTORE_POINT_RETENTION_DAYS", 90));
|
||||
const bindValidationErrors = validateConfiguredBindMode({
|
||||
deploymentMode,
|
||||
deploymentExposure,
|
||||
@@ -394,6 +396,7 @@ export function loadConfig(): Config {
|
||||
nativeHarnessBackupExpirationEnabled: process.env.PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED !== "false",
|
||||
nativeHarnessBackupExpirationDryRun: process.env.PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DRY_RUN === "true",
|
||||
nativeHarnessBackupExpirationDays,
|
||||
gitRestorePointRetentionDays,
|
||||
serveUi:
|
||||
process.env.SERVE_UI !== undefined
|
||||
? process.env.SERVE_UI === "true"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NATIVE_HARNESS_BACKUP_EXPIRATION_STARTUP_DELAY_MS,
|
||||
nativeHarnessBackupExpirationService,
|
||||
} from "./services/native-runtime/native-harness-backup-expiration.js";
|
||||
import { gitRestorePointRetentionService, registerGitRestorePointWork } from "./services/git-restore-point-ledger.js";
|
||||
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
|
||||
import { DELIVERY_QUEUES } from "./services/delivery-work-notifications.js";
|
||||
import { registerBudgetEnforcementWork, registerCostAccountingWork } from "./services/budget-enforcement-worker.js";
|
||||
@@ -1497,9 +1498,21 @@ async function startServerWithDatabaseTeardown(
|
||||
logger.error({ err }, "merged pull-request confirmation sweep failed");
|
||||
}));
|
||||
};
|
||||
const gitRestorePointWorker = registerGitRestorePointWork(deliveryWork, gitRestorePointRetentionService(db as any, {
|
||||
storage: storageService,
|
||||
retentionDays: config.gitRestorePointRetentionDays,
|
||||
}), (result) => {
|
||||
if (result.abandoned > 0 || result.expired > 0 || result.orphaned > 0 || result.deleted > 0 || result.failed > 0) {
|
||||
logger.info(result, "git restore point retention");
|
||||
}
|
||||
});
|
||||
let lastTerminalWorkspaceSkipLogAt = 0;
|
||||
const terminalWorkspaceWorker = registerTerminalWorkspaceWork(deliveryWork, {
|
||||
...terminalWorkspaces,
|
||||
terminalWorkspaceWorkSettled: (hints) => {
|
||||
terminalWorkspaces.terminalWorkspaceWorkSettled?.(hints);
|
||||
gitRestorePointWorker.issuesChanged();
|
||||
},
|
||||
sweepTerminalWorkspaces: async (limit, signal, options) => {
|
||||
const result = await terminalWorkspaces.sweepTerminalWorkspaces(limit, signal, options);
|
||||
if (result.archived > 0 || result.cleanupFailed > 0 || result.compacted > 0 || result.compactionFailed > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const CLOUD_STAGING_EXPERIMENTAL_KEYS: ReadonlySet<string> = new Set([
|
||||
"enableBetaSkills",
|
||||
"enableBuiltInAgents",
|
||||
"enableCases",
|
||||
"enableArtifactsV1",
|
||||
"enableChatConnectors",
|
||||
"enableClassicTaskInterface",
|
||||
"enableCombinedInboxTasks",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-lo
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { createAgentLifecycle, processAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { requestRestorePointDeletion } from "./git-restore-point-ledger.js";
|
||||
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
|
||||
import {
|
||||
companies,
|
||||
@@ -109,6 +110,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await requestRestorePointDeletion(tx, { companyId: id });
|
||||
await tx.delete(executionWorkspaces).where(eq(executionWorkspaces.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { daytonaRisk, isDaytonaConnection } from "../daytona.js";
|
||||
import { NATIVE_PROVIDER_CATALOGS } from "../native-provider-catalogs.js";
|
||||
import { classifyRailwayToolRisk, isRailwayEndpoint } from "../railway.js";
|
||||
import { braintrustRisk } from "./braintrust/risk.js";
|
||||
import { enterpretRisk } from "./enterpret/risk.js";
|
||||
@@ -34,12 +34,13 @@ const reviewedPolicies: ReviewedToolPolicy[] = [
|
||||
// review and drift handling, without enabling the native API bridge.
|
||||
matchesEndpoint: connection => isRailwayEndpoint(connection.config.url),
|
||||
},
|
||||
{
|
||||
sourceTemplateKey: "daytona",
|
||||
// Native REST providers (Daytona, Unikraft) classify from their generated contract.
|
||||
...NATIVE_PROVIDER_CATALOGS.map((provider): ReviewedToolPolicy => ({
|
||||
sourceTemplateKey: provider.slug,
|
||||
quarantineNewEntries: true,
|
||||
classifyRisk: tool => daytonaRisk(tool.name),
|
||||
matchesConnection: isDaytonaConnection,
|
||||
},
|
||||
classifyRisk: tool => provider.risk(tool.name),
|
||||
matchesConnection: provider.isConnection,
|
||||
})),
|
||||
{ sourceTemplateKey: "enterpret", quarantineNewEntries: true, classifyRisk: enterpretRisk },
|
||||
{ sourceTemplateKey: "braintrust", quarantineNewEntries: true, classifyRisk: braintrustRisk },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NATIVE_REST_MAX_BYTES, nativeCatalog, nativeRestFailure as failure, nativeRestPathValue, nativeRestRecord as record,
|
||||
nativeRestSender, type NativeCatalogEntry, type NativeRestRequest } from "../../native-rest-client.js";
|
||||
import catalog from "./operations.generated.json" with { type: "json" };
|
||||
|
||||
export const UNIKRAFT_METHOD = "api-token";
|
||||
// Unikraft Cloud has no global platform endpoint; each metro serves its own API.
|
||||
// The global control-plane URL identifies the connection; actions use the
|
||||
// reviewed metro hosts below.
|
||||
export const UNIKRAFT_CONNECTION_URL = "https://controlplane.unikraft.cloud/v1";
|
||||
export const UNIKRAFT_METROS: readonly string[] = catalog.metros;
|
||||
export const UNIKRAFT_DEFAULT_METRO = "fra";
|
||||
type Operation = typeof catalog.tools[number];
|
||||
const contract = nativeCatalog(catalog.tools);
|
||||
|
||||
export const UNIKRAFT_TOOLS = contract.tools;
|
||||
export const isUnikraftToolName = contract.isToolName;
|
||||
export const unikraftRisk = contract.risk;
|
||||
export const isCurrentUnikraftCatalogEntry = (entry: NativeCatalogEntry) => contract.isCurrentEntry(entry);
|
||||
|
||||
export function isUnikraftConnection(connection: { transport: string; config?: Record<string, unknown> }) {
|
||||
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "unikraft"
|
||||
&& connection.config.connectionMethodKey === UNIKRAFT_METHOD && connection.config.url === UNIKRAFT_CONNECTION_URL;
|
||||
}
|
||||
|
||||
/** Only metros published in the reviewed specification receive the token. */
|
||||
export function unikraftApiUrl(metro: unknown) {
|
||||
if (typeof metro !== "string" || !UNIKRAFT_METROS.includes(metro)) {
|
||||
throw failure(422, "unikraft_metro_invalid", `Choose a Unikraft Cloud metro: ${UNIKRAFT_METROS.join(", ")}.`);
|
||||
}
|
||||
return `https://api.${metro}.unikraft.cloud`;
|
||||
}
|
||||
|
||||
function pathValue(value: unknown) {
|
||||
return nativeRestPathValue(value, () => failure(422, "unikraft_arguments_invalid", "Invalid Unikraft path argument."));
|
||||
}
|
||||
|
||||
/** A provider-focused REST bridge. It never reads host files, calls host shell
|
||||
* commands, borrows CLI login state, or looks up Paperclip tasks. */
|
||||
export function unikraftClient(headers: Record<string, string>, request: NativeRestRequest, defaultMetro: unknown = UNIKRAFT_DEFAULT_METRO,
|
||||
signal: AbortSignal = AbortSignal.timeout(30_000), deadlineAt: number = Date.now() + 30_000) {
|
||||
const authorization = headers.Authorization;
|
||||
if (!authorization || !/^Bearer [^\r\n]+$/.test(authorization)) {
|
||||
throw failure(422, "unikraft_credential_missing", "Reconnect Unikraft to restore its API token.");
|
||||
}
|
||||
const { send } = nativeRestSender({ provider: "Unikraft", code: "unikraft", authorization, request, signal, deadlineAt,
|
||||
deniedMessage: "Unikraft denied access. Check that this token belongs to the intended organization and has not been revoked." });
|
||||
|
||||
async function probe() {
|
||||
const value = record(await send(`${unikraftApiUrl(defaultMetro)}/v1/users/quotas`, { method: "GET" }));
|
||||
if (value.status !== "success") throw failure(502, "unikraft_invalid_response", "Unikraft did not confirm this API token.");
|
||||
return { metro: defaultMetro as string };
|
||||
}
|
||||
|
||||
async function execute(operation: Operation, parameters: Record<string, unknown>) {
|
||||
let base = unikraftApiUrl(parameters.metro ?? defaultMetro);
|
||||
if (operation.kind === "sandbox") base += `/v1/instances/${pathValue(parameters.instance)}/plugins/sandbox`;
|
||||
const route = operation.path.replace(/\{([^}]+)\}/g, (_match, key: string) => pathValue(record(parameters.path)[key]));
|
||||
const url = new URL(base + route);
|
||||
const projectedHeaders: Record<string, string> = {};
|
||||
for (const parameter of operation.parameters) {
|
||||
if (parameter.group === "path") continue;
|
||||
const value = record(parameters[parameter.group])[parameter.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (parameter.group === "headers") {
|
||||
const text = String(value);
|
||||
if (/[\r\n]/.test(text)) throw failure(422, "unikraft_arguments_invalid", "Invalid Unikraft header argument.");
|
||||
projectedHeaders[parameter.name] = text;
|
||||
} else if (Array.isArray(value)) {
|
||||
if (parameter.explode) value.forEach(item => url.searchParams.append(parameter.name, String(item)));
|
||||
else url.searchParams.append(parameter.name, value.join(","));
|
||||
} else url.searchParams.append(parameter.name, String(value));
|
||||
}
|
||||
let body: string | undefined;
|
||||
// Unikraft selects batch targets with JSON bodies, including on GET and
|
||||
// DELETE. The pinned transport sends them as ordinary request bodies.
|
||||
if (parameters.body !== undefined) {
|
||||
body = JSON.stringify(parameters.body);
|
||||
if (Buffer.byteLength(body) > NATIVE_REST_MAX_BYTES) throw failure(422, "unikraft_input_limit", "The request body exceeds the limit.");
|
||||
projectedHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
// The sandbox plugin documents 408 as "the command is still running when
|
||||
// the wait timed out", an ordinary outcome rather than a failure.
|
||||
const expected = operation.kind === "sandbox" && operation.path === "/commands/{uuid}/wait_timeout"
|
||||
? { 408: { status: "running", timedOut: true, message: "The command is still running. Wait again or check its logs." } } : {};
|
||||
// No automatic retry. A transport failure cannot prove a write did not run.
|
||||
return send(url.toString(), { method: operation.method, headers: projectedHeaders, body }, {}, expected);
|
||||
}
|
||||
return { probe, execute };
|
||||
}
|
||||
|
||||
type Binding = { companyId: string; agentId: string | null };
|
||||
type Connection = { companyId: string; config: Record<string, unknown> };
|
||||
export async function executeUnikraftTool(input: {
|
||||
binding: Binding; connection: Connection; headers: Record<string, string>;
|
||||
request: NativeRestRequest; toolName: string; parameters: unknown;
|
||||
signal?: AbortSignal;
|
||||
deadlineAt?: number;
|
||||
}) {
|
||||
if (!input.binding.agentId || input.connection.companyId !== input.binding.companyId) {
|
||||
throw failure(404, "unikraft_connection_not_found", "Connection not found.");
|
||||
}
|
||||
const operation = contract.operation(input.toolName);
|
||||
if (!operation) throw failure(404, "unikraft_tool_not_found", "Unikraft action not found.");
|
||||
if (!contract.validate(operation.name, input.parameters)) throw failure(422, "unikraft_arguments_invalid", "Invalid Unikraft action arguments.");
|
||||
const config = record(input.connection.config.methodConfig);
|
||||
return unikraftClient(input.headers, input.request, config.metro ?? UNIKRAFT_DEFAULT_METRO, input.signal, input.deadlineAt)
|
||||
.execute(operation, record(input.parameters));
|
||||
}
|
||||
@@ -1,62 +1,27 @@
|
||||
import Ajv, { type ValidateFunction } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import { z } from "zod";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import { descriptorHash, stableHash } from "./tool-catalog-hash.js";
|
||||
import { NATIVE_REST_MAX_BYTES as MAX_BYTES, nativeCatalog, nativeRestFailure as failure, nativeRestPathValue, nativeRestRecord as record,
|
||||
nativeRestSender, type NativeCatalogEntry, type NativeRestRequest } from "./native-rest-client.js";
|
||||
import catalog from "./daytona/operations.generated.json" with { type: "json" };
|
||||
|
||||
export const DAYTONA_API_URL = "https://app.daytona.io/api";
|
||||
// Preserve the saved method identity when upgrading the earlier small catalog.
|
||||
export const DAYTONA_METHOD = "workspace-diagnostics";
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const failure = (status: number, code: string, message: string) => new HttpError(status, message, { code });
|
||||
export type DaytonaRequest = (url: string, init: RequestInit) => Promise<Response>;
|
||||
export type DaytonaRequest = NativeRestRequest;
|
||||
type Operation = typeof catalog.tools[number];
|
||||
const operations = new Map(catalog.tools.map(operation => [operation.name, operation]));
|
||||
const validators = new Map<string, ValidateFunction>();
|
||||
// Match the interop used by the existing plugin configuration validator.
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ strict: false, allErrors: true, validateFormats: true });
|
||||
((addFormats as any).default ?? addFormats)(ajv);
|
||||
const contract = nativeCatalog(catalog.tools);
|
||||
|
||||
export const DAYTONA_TOOLS = catalog.tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
||||
name, title, description, inputSchema: inputSchema as Record<string, unknown>, annotations,
|
||||
}));
|
||||
|
||||
export function isDaytonaToolName(name: string): boolean {
|
||||
return operations.has(name);
|
||||
}
|
||||
|
||||
const currentContracts = new Map(DAYTONA_TOOLS.map(tool => [tool.name, {
|
||||
versionHash: descriptorHash(tool, daytonaRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: daytonaRisk(tool.name),
|
||||
}]));
|
||||
|
||||
export function isCurrentDaytonaCatalogEntry(entry: { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }): boolean {
|
||||
const current = currentContracts.get(entry.toolName);
|
||||
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
|
||||
}
|
||||
export const DAYTONA_TOOLS = contract.tools;
|
||||
export const isDaytonaToolName = contract.isToolName;
|
||||
export const daytonaRisk = contract.risk;
|
||||
export const isCurrentDaytonaCatalogEntry = (entry: NativeCatalogEntry) => contract.isCurrentEntry(entry);
|
||||
|
||||
export function isDaytonaConnection(connection: { transport: string; config?: Record<string, unknown> }) {
|
||||
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "daytona"
|
||||
&& connection.config.connectionMethodKey === DAYTONA_METHOD && connection.config.url === DAYTONA_API_URL;
|
||||
}
|
||||
|
||||
export function daytonaRisk(name: string): "read" | "write" | "destructive" {
|
||||
const operation = operations.get(name);
|
||||
if (!operation || operation.annotations.destructiveHint) return "destructive";
|
||||
return operation.annotations.readOnlyHint ? "read" : "write";
|
||||
}
|
||||
|
||||
function pathValue(value: unknown) {
|
||||
const text = String(value);
|
||||
if (!text || text.length > 8192 || /[\u0000-\u001f\\]/.test(text)
|
||||
|| text.split("/").some(segment => segment === "." || segment === "..")) {
|
||||
throw failure(422, "daytona_arguments_invalid", "Invalid Daytona path argument.");
|
||||
}
|
||||
return encodeURIComponent(text);
|
||||
return nativeRestPathValue(value, () => failure(422, "daytona_arguments_invalid", "Invalid Daytona path argument."));
|
||||
}
|
||||
|
||||
function decodeFile(value: unknown) {
|
||||
@@ -77,78 +42,11 @@ export function daytonaClient(headers: Record<string, string>, request: DaytonaR
|
||||
if (!authorization || !/^Bearer [^\r\n]+$/.test(authorization)) {
|
||||
throw failure(422, "daytona_credential_missing", "Reconnect Daytona to restore its API key.");
|
||||
}
|
||||
const secret = authorization.slice(7);
|
||||
const expired = () => failure(504, "daytona_deadline_exceeded", "The Daytona action deadline expired. An operation already sent may still be running; inspect it before retrying.");
|
||||
// Bound the caller even if a transport or body reader is slow to acknowledge
|
||||
// cancellation. A late prerequisite result never advances to the next call.
|
||||
async function withinDeadline<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
let abort!: () => void;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(expired());
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally { signal.removeEventListener("abort", abort); }
|
||||
}
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactSensitiveText(value.split(secret).join("[REDACTED]"));
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
async function send(url: string, init: RequestInit, organizationId?: string): Promise<unknown> {
|
||||
const projected = new Headers(init.headers);
|
||||
projected.set("Authorization", authorization!);
|
||||
projected.set("X-Daytona-Source", "paperclip-connector");
|
||||
if (organizationId) projected.set("X-Daytona-Organization-ID", organizationId);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await withinDeadline(() => request(url, { ...init, redirect: "error", headers: Object.fromEntries(projected), signal }));
|
||||
} catch {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
throw failure(502, "daytona_outcome_unknown", init.method === "GET"
|
||||
? "Daytona could not complete the request."
|
||||
: "Daytona did not return a result. The operation may have started; inspect its state before retrying.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
const status = [400, 401, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
|
||||
throw failure(status, "daytona_request_failed", status === 401 || status === 403
|
||||
? "Daytona denied access. Check this key's organization and permissions. Some account operations require provider user authentication."
|
||||
: status === 404 ? "Daytona could not find this resource." : "Daytona could not complete this operation.");
|
||||
}
|
||||
if (response.status === 204 || !response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await withinDeadline(() => reader.read());
|
||||
if (part.done) break;
|
||||
length += part.value.byteLength;
|
||||
if (length > MAX_BYTES) throw failure(502, "daytona_output_limit", "Daytona's response exceeded the limit. Request a smaller page or file range.");
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw failure(502, "daytona_outcome_unknown", "Daytona's response was interrupted. Inspect the operation before retrying.");
|
||||
} finally { void reader.cancel().catch(() => {}); }
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!bytes.length) return null;
|
||||
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
if (contentType.includes("json")) {
|
||||
try { return sanitize(JSON.parse(bytes.toString("utf8"))); }
|
||||
catch { throw failure(502, "daytona_invalid_response", "Daytona returned an invalid response."); }
|
||||
}
|
||||
if (contentType.startsWith("text/")) return sanitize(bytes.toString("utf8"));
|
||||
if (bytes.includes(Buffer.from(secret))) throw failure(403, "daytona_credential_export_denied", "The response contains connection credential material.");
|
||||
return { contentType, contentBase64: bytes.toString("base64"), byteLength: bytes.length };
|
||||
}
|
||||
const { send: sendRequest, withinDeadline } = nativeRestSender({ provider: "Daytona", code: "daytona", authorization, request, signal, deadlineAt,
|
||||
deniedMessage: "Daytona denied access. Check this key's organization and permissions. Some account operations require provider user authentication.",
|
||||
fixedHeaders: { "X-Daytona-Source": "paperclip-connector" } });
|
||||
const send = (url: string, init: RequestInit, organizationId?: string) =>
|
||||
sendRequest(url, init, organizationId ? { "X-Daytona-Organization-ID": organizationId } : {});
|
||||
|
||||
async function identity() {
|
||||
const value = record(await send(`${DAYTONA_API_URL}/api-keys/current`, { method: "GET" }));
|
||||
@@ -248,11 +146,9 @@ export async function executeDaytonaTool(input: {
|
||||
if (!input.binding.agentId || input.connection.companyId !== input.binding.companyId) {
|
||||
throw failure(404, "daytona_connection_not_found", "Connection not found.");
|
||||
}
|
||||
const operation = operations.get(input.toolName);
|
||||
const operation = contract.operation(input.toolName);
|
||||
if (!operation) throw failure(404, "daytona_tool_not_found", "Daytona action not found.");
|
||||
let validate = validators.get(operation.name);
|
||||
if (!validate) { validate = ajv.compile(operation.inputSchema); validators.set(operation.name, validate!); }
|
||||
if (!validate!(input.parameters)) throw failure(422, "daytona_arguments_invalid", "Invalid Daytona action arguments.");
|
||||
if (!contract.validate(operation.name, input.parameters)) throw failure(422, "daytona_arguments_invalid", "Invalid Daytona action arguments.");
|
||||
const config = record(input.connection.config.methodConfig);
|
||||
return daytonaClient(input.headers, input.request, config.organizationId, input.signal, input.deadlineAt).execute(operation, record(input.parameters));
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export const DELIVERY_QUEUES = {
|
||||
feedback: "feedback-exports",
|
||||
chatCompletion: "chat-completions",
|
||||
connection: "connection-continuations",
|
||||
gitRestorePoints: "git-restore-points",
|
||||
question: "question-responses",
|
||||
pluginLifecycle: "plugin-resource-lifecycle",
|
||||
toolAction: "tool-action-receipts",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
|
||||
import { executionWorkspaceRestorePoints, type Db } from "@paperclipai/db";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export const GIT_RESTORE_POINT_RETRY_MS = 15 * 60 * 1000;
|
||||
export const GIT_RESTORE_POINT_DEFAULT_RETENTION_DAYS = 90;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_PENDING_GRACE_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_BATCH = 50;
|
||||
|
||||
type RestorePoint = typeof executionWorkspaceRestorePoints.$inferSelect;
|
||||
|
||||
export type GitRestorePointRetentionResult = {
|
||||
abandoned: number;
|
||||
scheduled: number;
|
||||
expired: number;
|
||||
orphaned: number;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
raced: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type GitRestorePointRetentionOptions = {
|
||||
storage: StorageService;
|
||||
retentionDays?: number;
|
||||
pendingGraceMs?: number;
|
||||
batchSize?: number;
|
||||
now?: () => Date;
|
||||
beforeExpire?: (restorePoint: RestorePoint) => Promise<void>;
|
||||
afterTreeRead?: (restorePoint: RestorePoint) => Promise<void>;
|
||||
};
|
||||
|
||||
type TreeState = { exists: boolean; open: boolean; closedAt: Date | null };
|
||||
|
||||
export function gitRestorePointRetentionService(db: Db, options: GitRestorePointRetentionOptions) {
|
||||
const retentionMs = Math.max(1, options.retentionDays ?? GIT_RESTORE_POINT_DEFAULT_RETENTION_DAYS) * DAY_MS;
|
||||
const pendingGraceMs = options.pendingGraceMs ?? DEFAULT_PENDING_GRACE_MS;
|
||||
const batchSize = options.batchSize ?? DEFAULT_BATCH;
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
async function issueTreeState(row: RestorePoint): Promise<TreeState> {
|
||||
if (!row.sourceIssueId) return { exists: false, open: false, closedAt: null };
|
||||
const result = await db.execute<{ total: number; open: number; closed_at: string | null }>(sql`
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, status, completed_at, cancelled_at, updated_at FROM issues
|
||||
WHERE company_id = ${row.companyId} AND id = ${row.sourceIssueId}
|
||||
UNION
|
||||
SELECT c.id, c.status, c.completed_at, c.cancelled_at, c.updated_at FROM issues c
|
||||
JOIN tree p ON c.parent_id = p.id WHERE c.company_id = ${row.companyId}
|
||||
)
|
||||
SELECT count(*)::int AS total,
|
||||
count(*) FILTER (WHERE status NOT IN ('done', 'cancelled'))::int AS open,
|
||||
max(CASE WHEN status = 'done' THEN coalesce(completed_at, updated_at)
|
||||
WHEN status = 'cancelled' THEN coalesce(cancelled_at, updated_at) END) AS closed_at
|
||||
FROM tree`);
|
||||
const state = result[0];
|
||||
if (!state || state.total === 0) return { exists: false, open: false, closedAt: null };
|
||||
return { exists: true, open: state.open > 0, closedAt: state.closed_at ? new Date(state.closed_at) : null };
|
||||
}
|
||||
|
||||
async function requestDeletion(ids: string[], at: Date) {
|
||||
if (ids.length === 0) return;
|
||||
await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ status: "delete_requested", updatedAt: at })
|
||||
.where(and(inArray(executionWorkspaceRestorePoints.id, ids),
|
||||
inArray(executionWorkspaceRestorePoints.status, ["pending", "active"])));
|
||||
}
|
||||
|
||||
async function evaluateActive(row: RestorePoint, at: Date, result: GitRestorePointRetentionResult) {
|
||||
const tree = await issueTreeState(row);
|
||||
await options.afterTreeRead?.(row);
|
||||
const unchanged = and(
|
||||
eq(executionWorkspaceRestorePoints.id, row.id),
|
||||
eq(executionWorkspaceRestorePoints.status, "active"),
|
||||
eq(executionWorkspaceRestorePoints.reviewGeneration, row.reviewGeneration),
|
||||
);
|
||||
const deleteAfter = !tree.exists || tree.open || !tree.closedAt ? null : new Date(tree.closedAt.getTime() + retentionMs);
|
||||
if (!tree.exists || (deleteAfter && deleteAfter.getTime() <= at.getTime())) {
|
||||
const claimed = await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ status: tree.exists ? "expiring" : "delete_requested", lastError: null, updatedAt: at })
|
||||
.where(unchanged)
|
||||
.returning();
|
||||
if (claimed.length === 0) {
|
||||
result.raced += 1;
|
||||
return;
|
||||
}
|
||||
if (!tree.exists) {
|
||||
result.orphaned += 1;
|
||||
return;
|
||||
}
|
||||
await finishExpiry(claimed[0]!, at, result);
|
||||
return;
|
||||
}
|
||||
const updated = await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ treeClosedAt: tree.open ? null : tree.closedAt, deleteAfter, updatedAt: at })
|
||||
.where(unchanged)
|
||||
.returning({ id: executionWorkspaceRestorePoints.id });
|
||||
if (updated.length === 0) result.raced += 1;
|
||||
else result.scheduled += 1;
|
||||
}
|
||||
|
||||
async function finishExpiry(row: RestorePoint, at: Date, result: GitRestorePointRetentionResult) {
|
||||
try {
|
||||
await options.beforeExpire?.(row);
|
||||
} catch (error) {
|
||||
await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ lastError: error instanceof Error ? error.message.slice(0, 500) : "expire_failed", updatedAt: at })
|
||||
.where(and(eq(executionWorkspaceRestorePoints.id, row.id), eq(executionWorkspaceRestorePoints.status, "expiring")));
|
||||
result.failed += 1;
|
||||
return;
|
||||
}
|
||||
await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ status: "delete_requested", lastError: null, updatedAt: at })
|
||||
.where(and(eq(executionWorkspaceRestorePoints.id, row.id), eq(executionWorkspaceRestorePoints.status, "expiring")));
|
||||
result.expired += 1;
|
||||
}
|
||||
|
||||
function retryable(status: "expiring" | "superseded" | "delete_requested" | Array<"superseded" | "delete_requested">, at: Date) {
|
||||
return and(
|
||||
Array.isArray(status) ? inArray(executionWorkspaceRestorePoints.status, status) : eq(executionWorkspaceRestorePoints.status, status),
|
||||
or(isNull(executionWorkspaceRestorePoints.lastError),
|
||||
lte(executionWorkspaceRestorePoints.updatedAt, new Date(at.getTime() - GIT_RESTORE_POINT_RETRY_MS))),
|
||||
);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const at = now();
|
||||
const result: GitRestorePointRetentionResult = { abandoned: 0, scheduled: 0, expired: 0, orphaned: 0, deleted: 0, failed: 0, raced: 0, truncated: false };
|
||||
|
||||
const abandoned = await db.select({ id: executionWorkspaceRestorePoints.id }).from(executionWorkspaceRestorePoints)
|
||||
.where(and(eq(executionWorkspaceRestorePoints.status, "pending"),
|
||||
lt(executionWorkspaceRestorePoints.createdAt, new Date(at.getTime() - pendingGraceMs))))
|
||||
.orderBy(asc(executionWorkspaceRestorePoints.createdAt)).limit(batchSize);
|
||||
await requestDeletion(abandoned.map((row) => row.id), at);
|
||||
result.abandoned = abandoned.length;
|
||||
|
||||
const due = await db.select().from(executionWorkspaceRestorePoints)
|
||||
.where(and(eq(executionWorkspaceRestorePoints.status, "active"),
|
||||
sql`${executionWorkspaceRestorePoints.deleteAfter} <= greatest(${at.toISOString()}::timestamptz, clock_timestamp())`))
|
||||
.orderBy(asc(executionWorkspaceRestorePoints.deleteAfter), asc(executionWorkspaceRestorePoints.id)).limit(batchSize);
|
||||
for (const row of due) await evaluateActive(row, at, result);
|
||||
|
||||
const expiring = await db.select().from(executionWorkspaceRestorePoints)
|
||||
.where(retryable("expiring", at))
|
||||
.orderBy(asc(executionWorkspaceRestorePoints.updatedAt), asc(executionWorkspaceRestorePoints.id)).limit(batchSize);
|
||||
for (const row of expiring) await finishExpiry(row, at, result);
|
||||
|
||||
const doomed = await db.select().from(executionWorkspaceRestorePoints)
|
||||
.where(retryable(["superseded", "delete_requested"], at))
|
||||
.orderBy(asc(executionWorkspaceRestorePoints.updatedAt), asc(executionWorkspaceRestorePoints.id)).limit(batchSize);
|
||||
for (const row of doomed) {
|
||||
try {
|
||||
await options.storage.deleteObject(row.companyId, row.objectKey);
|
||||
if ((await options.storage.headObject(row.companyId, row.objectKey)).exists) throw new Error("restore_point_object_still_present");
|
||||
await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ status: "deleted", deletedAt: at, lastError: null, updatedAt: at })
|
||||
.where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
result.deleted += 1;
|
||||
} catch (error) {
|
||||
await db.update(executionWorkspaceRestorePoints)
|
||||
.set({ lastError: error instanceof Error ? error.message.slice(0, 500) : "delete_failed", updatedAt: at })
|
||||
.where(eq(executionWorkspaceRestorePoints.id, row.id));
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
result.truncated = abandoned.length === batchSize || due.length === batchSize
|
||||
|| expiring.length === batchSize || doomed.length === batchSize;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function nextDeadlineAt(): Promise<number | null> {
|
||||
const [row] = await db.select({
|
||||
deleteNow: sql<number>`count(*) FILTER (WHERE ${executionWorkspaceRestorePoints.status} IN ('expiring', 'superseded', 'delete_requested') AND ${executionWorkspaceRestorePoints.lastError} IS NULL)::int`,
|
||||
retryFrom: sql<string | null>`min(${executionWorkspaceRestorePoints.updatedAt}) FILTER (WHERE ${executionWorkspaceRestorePoints.status} IN ('expiring', 'superseded', 'delete_requested') AND ${executionWorkspaceRestorePoints.lastError} IS NOT NULL)`,
|
||||
pendingFrom: sql<string | null>`min(${executionWorkspaceRestorePoints.createdAt}) FILTER (WHERE ${executionWorkspaceRestorePoints.status} = 'pending')`,
|
||||
expiresAt: sql<string | null>`min(${executionWorkspaceRestorePoints.deleteAfter}) FILTER (WHERE ${executionWorkspaceRestorePoints.status} = 'active')`,
|
||||
}).from(executionWorkspaceRestorePoints);
|
||||
const at = now().getTime();
|
||||
const deadlines: number[] = [];
|
||||
if (row && row.deleteNow > 0) deadlines.push(at);
|
||||
if (row?.retryFrom) deadlines.push(new Date(row.retryFrom).getTime() + GIT_RESTORE_POINT_RETRY_MS);
|
||||
if (row?.pendingFrom) deadlines.push(new Date(row.pendingFrom).getTime() + pendingGraceMs);
|
||||
if (row?.expiresAt) deadlines.push(new Date(row.expiresAt).getTime());
|
||||
return deadlines.length > 0 ? Math.min(...deadlines) : null;
|
||||
}
|
||||
|
||||
async function hasActive(): Promise<boolean> {
|
||||
const [row] = await db.select({ id: executionWorkspaceRestorePoints.id }).from(executionWorkspaceRestorePoints)
|
||||
.where(eq(executionWorkspaceRestorePoints.status, "active")).limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
return { tick, nextDeadlineAt, hasActive };
|
||||
}
|
||||
|
||||
export function notifyGitRestorePointWork(transaction: object, hint?: string) {
|
||||
return notifyDeliveryWork(transaction, DELIVERY_QUEUES.gitRestorePoints, hint);
|
||||
}
|
||||
|
||||
export async function requestRestorePointDeletion(
|
||||
tx: Pick<Db, "update">,
|
||||
scope: { sourceIssueId: string } | { companyId: string },
|
||||
) {
|
||||
await notifyGitRestorePointWork(tx, "sourceIssueId" in scope ? `issue:${scope.sourceIssueId}` : `company:${scope.companyId}`);
|
||||
await tx.update(executionWorkspaceRestorePoints)
|
||||
.set({ status: "delete_requested", updatedAt: new Date() })
|
||||
.where(and(
|
||||
"sourceIssueId" in scope
|
||||
? eq(executionWorkspaceRestorePoints.sourceIssueId, scope.sourceIssueId)
|
||||
: eq(executionWorkspaceRestorePoints.companyId, scope.companyId),
|
||||
inArray(executionWorkspaceRestorePoints.status, ["pending", "active", "expiring"]),
|
||||
));
|
||||
}
|
||||
|
||||
export function registerGitRestorePointWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
service: ReturnType<typeof gitRestorePointRetentionService>,
|
||||
onResult: (result: GitRestorePointRetentionResult) => void,
|
||||
) {
|
||||
let next: number | null = null;
|
||||
let tracksActive = true;
|
||||
const worker = coordinator.register(DELIVERY_QUEUES.gitRestorePoints, {
|
||||
retryMs: GIT_RESTORE_POINT_RETRY_MS,
|
||||
run: async () => {
|
||||
next = null;
|
||||
const result = await service.tick();
|
||||
onResult(result);
|
||||
next = result.truncated ? Date.now() + 1000 : await service.nextDeadlineAt();
|
||||
tracksActive = await service.hasActive();
|
||||
},
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => next,
|
||||
});
|
||||
return { ...worker, issuesChanged: () => { if (tracksActive) worker.wake(); } };
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export const RESTORE_POINT_PAYLOAD_SCHEMA = "paperclip.git-restore-point-payload.v1";
|
||||
const PAYLOAD_MAGIC = Buffer.from("PCRP-PLAIN1\n");
|
||||
const SEALED_MAGIC = Buffer.from("PCRP1");
|
||||
const KEY_SCHEMA = "paperclip.git-restore-point-key.v1";
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const SNAPSHOT_IDENTITY = {
|
||||
GIT_AUTHOR_NAME: "Paperclip",
|
||||
GIT_AUTHOR_EMAIL: "restore-point@paperclip.invalid",
|
||||
GIT_AUTHOR_DATE: "@0 +0000",
|
||||
GIT_COMMITTER_NAME: "Paperclip",
|
||||
GIT_COMMITTER_EMAIL: "restore-point@paperclip.invalid",
|
||||
GIT_COMMITTER_DATE: "@0 +0000",
|
||||
};
|
||||
const EXCLUDED_UNTRACKED = [":(exclude,glob)**/.env", ":(exclude,glob)**/.env.*"];
|
||||
const MAX_PRESERVED_COMMITS = 50;
|
||||
|
||||
export type RestorePointLimits = {
|
||||
maxFiles: number;
|
||||
maxFileBytes: number;
|
||||
maxTotalBytes: number;
|
||||
maxCommits: number;
|
||||
maxBundleBytes: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_RESTORE_POINT_LIMITS: RestorePointLimits = {
|
||||
maxFiles: 20_000,
|
||||
maxFileBytes: 64 * 1024 * 1024,
|
||||
maxTotalBytes: 256 * 1024 * 1024,
|
||||
maxCommits: 1_000,
|
||||
maxBundleBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export type RestorePointManifest = {
|
||||
schema: typeof RESTORE_POINT_PAYLOAD_SCHEMA;
|
||||
head: string;
|
||||
index: string | null;
|
||||
worktree: string | null;
|
||||
preserved: string[];
|
||||
prerequisites: string[];
|
||||
modes: Array<{ path: string; mode: number }>;
|
||||
};
|
||||
|
||||
export type RestorePointSnapshot = { head: string; index: string | null; worktree: string | null; modes: Array<{ path: string; mode: number }> };
|
||||
|
||||
export type RestorePointIdentity = { companyId: string; workspaceId: string; restorePointId: string; generation: number };
|
||||
|
||||
export type SealedRestorePoint = {
|
||||
ciphertext: Buffer;
|
||||
sha256: string;
|
||||
key: StoredSecretVersionMaterial;
|
||||
};
|
||||
|
||||
export class RestorePointSkip extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(reason);
|
||||
this.name = "RestorePointSkip";
|
||||
}
|
||||
}
|
||||
|
||||
export class RestorePointRestoreFailure extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(`The workspace restore point could not be applied: ${reason}`);
|
||||
this.name = "RestorePointRestoreFailure";
|
||||
}
|
||||
}
|
||||
|
||||
type GitOptions = { env?: Record<string, string>; input?: Buffer | string; signal?: AbortSignal; maxBuffer?: number };
|
||||
|
||||
function isolatedEnv(extra: Record<string, string> = {}) {
|
||||
return {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[], options: GitOptions = {}): Promise<Buffer> {
|
||||
const child = execFile("git", [
|
||||
"-c", "core.fsmonitor=false", "-c", "core.autocrlf=false", "-c", "core.safecrlf=false",
|
||||
"-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args,
|
||||
], {
|
||||
env: isolatedEnv(options.env),
|
||||
encoding: "buffer",
|
||||
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
|
||||
timeout: 120_000,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (options.input !== undefined) child.child.stdin?.end(options.input);
|
||||
const { stdout } = await child;
|
||||
return stdout as Buffer;
|
||||
}
|
||||
|
||||
async function gitText(cwd: string, args: string[], options: GitOptions = {}) {
|
||||
return (await git(cwd, args, options)).toString("utf8").trim();
|
||||
}
|
||||
|
||||
function nulList(output: Buffer) {
|
||||
return output.toString("utf8").split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
async function objectsDirectory(cwd: string) {
|
||||
const common = await gitText(cwd, ["rev-parse", "--git-common-dir"]);
|
||||
return path.resolve(cwd, common, "objects");
|
||||
}
|
||||
|
||||
async function snapshotStore(cwd: string, temporary: string) {
|
||||
const store = path.join(temporary, "store.git");
|
||||
await execFile("git", ["init", "-q", "--bare", store], { env: isolatedEnv() });
|
||||
const repositoryObjects = await objectsDirectory(cwd);
|
||||
await fs.writeFile(path.join(store, "objects", "info", "alternates"), `${repositoryObjects}\n`);
|
||||
return {
|
||||
store,
|
||||
env: {
|
||||
GIT_OBJECT_DIRECTORY: path.join(store, "objects"),
|
||||
GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects,
|
||||
...SNAPSHOT_IDENTITY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function copyIndex(cwd: string, target: string) {
|
||||
const indexPath = path.resolve(cwd, await gitText(cwd, ["rev-parse", "--git-path", "index"]));
|
||||
await fs.copyFile(indexPath, target).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function scanChangedPaths(cwd: string, limits: RestorePointLimits, signal?: AbortSignal) {
|
||||
const changed = new Set([
|
||||
...nulList(await git(cwd, ["ls-files", "-z", "--modified"], { signal })),
|
||||
...nulList(await git(cwd, ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"], { signal })),
|
||||
...nulList(await git(cwd, ["ls-files", "-z", "--others", "--exclude-standard", "--", ".", ...EXCLUDED_UNTRACKED], { signal })),
|
||||
]);
|
||||
if (changed.size > limits.maxFiles) throw new RestorePointSkip("restore_point_too_large");
|
||||
let totalBytes = 0;
|
||||
const modes: Array<{ path: string; mode: number }> = [];
|
||||
for (const relative of changed) {
|
||||
if (relative.endsWith("/")) throw new RestorePointSkip("untracked_nested_repository");
|
||||
const stat = await fs.lstat(path.join(cwd, relative)).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat) continue;
|
||||
if (stat.isDirectory()) throw new RestorePointSkip("untracked_nested_repository");
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) throw new RestorePointSkip("unsupported_special_file");
|
||||
if (stat.isFile()) {
|
||||
if (stat.size > limits.maxFileBytes) throw new RestorePointSkip("restore_point_too_large");
|
||||
totalBytes += stat.size;
|
||||
const mode = stat.mode & 0o777;
|
||||
if (mode !== 0o644 && mode !== 0o755) modes.push({ path: relative, mode });
|
||||
}
|
||||
if (totalBytes > limits.maxTotalBytes) throw new RestorePointSkip("restore_point_too_large");
|
||||
}
|
||||
if (changed.size > 0) {
|
||||
const attributes = nulList(await git(cwd, ["check-attr", "-z", "--stdin", "filter"], { input: [...changed].join("\0"), signal }));
|
||||
for (let index = 2; index < attributes.length; index += 3) {
|
||||
if (attributes[index] !== "unspecified" && attributes[index] !== "unset") throw new RestorePointSkip("filtered_paths");
|
||||
}
|
||||
}
|
||||
return { modes: modes.sort((a, b) => a.path.localeCompare(b.path)) };
|
||||
}
|
||||
|
||||
async function snapshotTrees(cwd: string, env: Record<string, string>, temporary: string, signal?: AbortSignal) {
|
||||
const indexCopy = path.join(temporary, "index");
|
||||
await copyIndex(cwd, indexCopy);
|
||||
const indexTree = await gitText(cwd, ["write-tree"], { env: { ...env, GIT_INDEX_FILE: indexCopy }, signal })
|
||||
.catch(() => { throw new RestorePointSkip("unmerged_index"); });
|
||||
const worktreeIndex = path.join(temporary, "worktree-index");
|
||||
const worktreeEnv = { ...env, GIT_INDEX_FILE: worktreeIndex };
|
||||
await git(cwd, ["read-tree", "HEAD"], { env: worktreeEnv, signal });
|
||||
await git(cwd, ["add", "-u", "--", "."], { env: worktreeEnv, signal });
|
||||
await git(cwd, ["add", "-A", "--", ".", ...EXCLUDED_UNTRACKED], { env: worktreeEnv, signal });
|
||||
const worktreeTree = await gitText(cwd, ["write-tree"], { env: worktreeEnv, signal });
|
||||
const gitlinks = await gitText(cwd, ["ls-tree", "-r", worktreeTree], { env, signal });
|
||||
if (/^160000 /m.test(gitlinks)) throw new RestorePointSkip("submodules_unsupported");
|
||||
return { indexTree, worktreeTree };
|
||||
}
|
||||
|
||||
async function snapshotCommits(cwd: string, env: Record<string, string>, temporary: string, modes: RestorePointSnapshot["modes"], signal?: AbortSignal): Promise<RestorePointSnapshot> {
|
||||
const head = await gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"], { signal });
|
||||
const headTree = await gitText(cwd, ["rev-parse", "HEAD^{tree}"], { signal });
|
||||
const { indexTree, worktreeTree } = await snapshotTrees(cwd, env, temporary, signal);
|
||||
if (indexTree === headTree && worktreeTree === headTree) return { head, index: null, worktree: null, modes: [] };
|
||||
const index = await gitText(cwd, ["commit-tree", indexTree, "-p", head, "-m", "paperclip restore point index"], { env, signal });
|
||||
const worktree = await gitText(cwd, ["commit-tree", worktreeTree, "-p", head, "-p", index, "-m", "paperclip restore point worktree"], { env, signal });
|
||||
return { head, index, worktree, modes };
|
||||
}
|
||||
|
||||
const UNBOUNDED_LIMITS: RestorePointLimits = {
|
||||
maxFiles: Number.POSITIVE_INFINITY,
|
||||
maxFileBytes: Number.POSITIVE_INFINITY,
|
||||
maxTotalBytes: Number.POSITIVE_INFINITY,
|
||||
maxCommits: Number.POSITIVE_INFINITY,
|
||||
maxBundleBytes: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
export async function currentRestorePointSnapshot(input: string, signal?: AbortSignal): Promise<RestorePointSnapshot> {
|
||||
const cwd = await fs.realpath(input);
|
||||
const { modes } = await scanChangedPaths(cwd, UNBOUNDED_LIMITS, signal);
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
||||
try {
|
||||
const { env } = await snapshotStore(cwd, temporary);
|
||||
return await snapshotCommits(cwd, env, temporary, modes, signal);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function existingCommits(cwd: string, commits: readonly string[], signal?: AbortSignal) {
|
||||
const present: string[] = [];
|
||||
for (const commit of commits) {
|
||||
if (!SHA.test(commit)) continue;
|
||||
if (await git(cwd, ["cat-file", "-e", `${commit}^{commit}`], { signal }).then(() => true, () => false)) present.push(commit);
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
async function unpreservedHistory(cwd: string, head: string, boundary: readonly string[], signal?: AbortSignal) {
|
||||
const candidates = new Set<string>();
|
||||
const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => "");
|
||||
for (const line of reflog.split("\n")) if (SHA.test(line)) candidates.add(line);
|
||||
const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => "");
|
||||
if (SHA.test(original)) candidates.add(original);
|
||||
candidates.delete(head);
|
||||
if (candidates.size === 0) return [];
|
||||
const unreachable = new Set((await gitText(cwd, ["rev-list", "--stdin"], {
|
||||
signal, input: [...candidates, `^${head}`, ...boundary.map((commit) => `^${commit}`)].join("\n") + "\n",
|
||||
})).split("\n").filter((line) => SHA.test(line)));
|
||||
const preserved = [...candidates].filter((commit) => unreachable.has(commit));
|
||||
if (preserved.length > MAX_PRESERVED_COMMITS) throw new RestorePointSkip("restore_point_history_too_large");
|
||||
return preserved.sort();
|
||||
}
|
||||
|
||||
function bundlePrerequisites(bundle: Buffer) {
|
||||
const headerEnd = bundle.indexOf("\n\n");
|
||||
if (headerEnd < 0) throw new RestorePointSkip("invalid_bundle");
|
||||
return bundle.subarray(0, headerEnd).toString("utf8").split("\n").slice(1)
|
||||
.filter((line) => line.startsWith("-")).map((line) => line.slice(1, 41)).filter((sha) => SHA.test(sha)).sort();
|
||||
}
|
||||
|
||||
function encodePayload(manifest: RestorePointManifest, bundle: Buffer) {
|
||||
const manifestBytes = Buffer.from(JSON.stringify(manifest));
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(manifestBytes.length);
|
||||
return Buffer.concat([PAYLOAD_MAGIC, length, manifestBytes, bundle]);
|
||||
}
|
||||
|
||||
export function decodeRestorePointPayload(payload: Buffer): { manifest: RestorePointManifest; bundle: Buffer } {
|
||||
if (payload.length < PAYLOAD_MAGIC.length + 4 || !payload.subarray(0, PAYLOAD_MAGIC.length).equals(PAYLOAD_MAGIC)) {
|
||||
throw new RestorePointRestoreFailure("invalid_payload");
|
||||
}
|
||||
const length = payload.readUInt32BE(PAYLOAD_MAGIC.length);
|
||||
const start = PAYLOAD_MAGIC.length + 4;
|
||||
if (start + length > payload.length) throw new RestorePointRestoreFailure("invalid_payload");
|
||||
const manifest = JSON.parse(payload.subarray(start, start + length).toString("utf8")) as RestorePointManifest;
|
||||
const shas = [manifest.head, ...(manifest.index ? [manifest.index] : []), ...(manifest.worktree ? [manifest.worktree] : []),
|
||||
...manifest.preserved, ...manifest.prerequisites];
|
||||
if (manifest.schema !== RESTORE_POINT_PAYLOAD_SCHEMA || shas.some((sha) => !SHA.test(sha))
|
||||
|| (manifest.index === null) !== (manifest.worktree === null)
|
||||
|| manifest.modes.some((entry) => typeof entry.path !== "string" || path.isAbsolute(entry.path)
|
||||
|| entry.path.split("/").includes("..") || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) {
|
||||
throw new RestorePointRestoreFailure("invalid_payload");
|
||||
}
|
||||
return { manifest, bundle: payload.subarray(start + length) };
|
||||
}
|
||||
|
||||
export async function captureRestorePoint(input: {
|
||||
cwd: string;
|
||||
upstreamCommits: readonly string[];
|
||||
limits?: Partial<RestorePointLimits>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ payload: Buffer; manifest: RestorePointManifest } | null> {
|
||||
const limits = { ...DEFAULT_RESTORE_POINT_LIMITS, ...input.limits };
|
||||
const cwd = await fs.realpath(input.cwd);
|
||||
const { modes } = await scanChangedPaths(cwd, limits, input.signal);
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
||||
try {
|
||||
const { store, env } = await snapshotStore(cwd, temporary);
|
||||
const snapshot = await snapshotCommits(cwd, env, temporary, modes, input.signal);
|
||||
const boundary = await existingCommits(cwd, input.upstreamCommits, input.signal);
|
||||
const preserved = await unpreservedHistory(cwd, snapshot.head, boundary, input.signal);
|
||||
const headOnUpstream = await (async () => {
|
||||
for (const tip of boundary) {
|
||||
if (await git(cwd, ["merge-base", "--is-ancestor", snapshot.head, tip], { signal: input.signal }).then(() => true, () => false)) return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
if (headOnUpstream && snapshot.worktree === null && preserved.length === 0) return null;
|
||||
const refs: Array<[string, string]> = [["refs/restore/head", snapshot.head]];
|
||||
if (snapshot.index && snapshot.worktree) refs.push(["refs/restore/index", snapshot.index], ["refs/restore/worktree", snapshot.worktree]);
|
||||
preserved.forEach((commit, index) => refs.push([`refs/restore/preserved/${index}`, commit]));
|
||||
for (const [ref, commit] of refs) await git(store, ["update-ref", ref, commit], { env, signal: input.signal });
|
||||
const exclusions = boundary.map((commit) => `^${commit}`);
|
||||
const commitCount = Number(await gitText(store, ["rev-list", "--count", ...refs.map(([ref]) => ref), ...exclusions], { env, signal: input.signal }));
|
||||
if (commitCount > limits.maxCommits) throw new RestorePointSkip("restore_point_too_large");
|
||||
if (commitCount === 0) return null;
|
||||
const bundlePath = path.join(temporary, "restore.bundle");
|
||||
await git(store, ["bundle", "create", "-q", bundlePath, "--stdin"], {
|
||||
env, signal: input.signal, input: [...refs.map(([ref]) => ref), ...exclusions].join("\n") + "\n",
|
||||
});
|
||||
const bundle = await fs.readFile(bundlePath);
|
||||
if (bundle.length > limits.maxBundleBytes) throw new RestorePointSkip("restore_point_too_large");
|
||||
const prerequisites = bundlePrerequisites(bundle);
|
||||
const manifest: RestorePointManifest = {
|
||||
schema: RESTORE_POINT_PAYLOAD_SCHEMA,
|
||||
head: snapshot.head,
|
||||
index: snapshot.index,
|
||||
worktree: snapshot.worktree,
|
||||
preserved,
|
||||
prerequisites,
|
||||
modes: snapshot.modes,
|
||||
};
|
||||
return { payload: encodePayload(manifest, bundle), manifest };
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function importRestorePoint(input: { repositoryCwd: string; payload: Buffer; signal?: AbortSignal }) {
|
||||
const { manifest, bundle } = decodeRestorePointPayload(input.payload);
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restore-point-"));
|
||||
try {
|
||||
const bundlePath = path.join(temporary, "restore.bundle");
|
||||
await fs.writeFile(bundlePath, bundle, { mode: 0o600 });
|
||||
await git(input.repositoryCwd, ["bundle", "verify", "-q", bundlePath], { signal: input.signal })
|
||||
.catch(() => { throw new RestorePointRestoreFailure("bundle_prerequisites_missing"); });
|
||||
const heads = (await gitText(input.repositoryCwd, ["bundle", "list-heads", bundlePath], { signal: input.signal }))
|
||||
.split("\n").filter(Boolean).map((line) => line.split(" "));
|
||||
const expected = new Map<string, string>([["refs/restore/head", manifest.head]]);
|
||||
if (manifest.index && manifest.worktree) {
|
||||
expected.set("refs/restore/index", manifest.index);
|
||||
expected.set("refs/restore/worktree", manifest.worktree);
|
||||
}
|
||||
manifest.preserved.forEach((commit, index) => expected.set(`refs/restore/preserved/${index}`, commit));
|
||||
if (heads.some(([sha, ref]) => expected.get(ref ?? "") !== sha)) throw new RestorePointRestoreFailure("bundle_manifest_mismatch");
|
||||
const listed = new Set(heads.map(([, ref]) => ref));
|
||||
for (const [ref, commit] of expected) {
|
||||
if (listed.has(ref)) continue;
|
||||
const present = await git(input.repositoryCwd, ["cat-file", "-e", `${commit}^{commit}`], { signal: input.signal }).then(() => true, () => false);
|
||||
if (!present) throw new RestorePointRestoreFailure("bundle_manifest_mismatch");
|
||||
}
|
||||
if (listed.size > 0) {
|
||||
await git(input.repositoryCwd, ["fetch", "-q", "--no-tags", "--no-write-fetch-head", bundlePath,
|
||||
...[...listed].map((ref) => `${ref}:refs/paperclip-restore-import/${ref!.slice("refs/restore/".length)}`)], { signal: input.signal });
|
||||
}
|
||||
await git(input.repositoryCwd, ["for-each-ref", "--format=delete %(refname)", "refs/paperclip-restore-import/"], { signal: input.signal })
|
||||
.then((output) => git(input.repositoryCwd, ["update-ref", "--stdin"], { input: output, signal: input.signal }));
|
||||
return manifest;
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyRestorePoint(input: { cwd: string; manifest: RestorePointManifest; signal?: AbortSignal }) {
|
||||
const { manifest } = input;
|
||||
const cwd = await fs.realpath(input.cwd);
|
||||
const head = await gitText(cwd, ["rev-parse", "--verify", "HEAD^{commit}"], { signal: input.signal });
|
||||
if (head !== manifest.head) throw new RestorePointRestoreFailure("head_mismatch");
|
||||
if (manifest.index && manifest.worktree) {
|
||||
const diff = await git(cwd, ["diff", "--binary", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "HEAD", manifest.worktree], { signal: input.signal });
|
||||
if (diff.length > 0) await git(cwd, ["apply", "--binary", "--whitespace=nowarn", "-"], { input: diff, signal: input.signal });
|
||||
await git(cwd, ["read-tree", `${manifest.index}^{tree}`], { signal: input.signal });
|
||||
}
|
||||
for (const entry of manifest.modes) {
|
||||
const target = path.join(cwd, entry.path);
|
||||
if (path.relative(cwd, target).startsWith("..")) throw new RestorePointRestoreFailure("invalid_payload");
|
||||
const stat = await fs.lstat(target).catch(() => null);
|
||||
if (stat?.isFile()) await fs.chmod(target, entry.mode);
|
||||
}
|
||||
const restored = await currentRestorePointSnapshot(cwd, input.signal);
|
||||
if (restored.head !== manifest.head || restored.index !== manifest.index || restored.worktree !== manifest.worktree
|
||||
|| JSON.stringify(restored.modes) !== JSON.stringify(manifest.modes)) {
|
||||
throw new RestorePointRestoreFailure("restored_contents_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function additionalData(identity: RestorePointIdentity) {
|
||||
return Buffer.from(JSON.stringify([identity.companyId, identity.workspaceId, identity.restorePointId, identity.generation]));
|
||||
}
|
||||
|
||||
export async function sealRestorePoint(payload: Buffer, identity: RestorePointIdentity): Promise<SealedRestorePoint> {
|
||||
const dataKey = randomBytes(32);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", dataKey, iv);
|
||||
cipher.setAAD(additionalData(identity));
|
||||
const ciphertext = Buffer.concat([SEALED_MAGIC, iv, cipher.update(payload), cipher.final(), cipher.getAuthTag()]);
|
||||
const { material } = await localEncryptedProvider.createVersion({
|
||||
value: JSON.stringify({
|
||||
schema: KEY_SCHEMA,
|
||||
...identity,
|
||||
dataKey: dataKey.toString("base64"),
|
||||
plaintextSha256: createHash("sha256").update(payload).digest("hex"),
|
||||
plaintextBytes: payload.length,
|
||||
}),
|
||||
} as Parameters<typeof localEncryptedProvider.createVersion>[0]);
|
||||
return { ciphertext, sha256: createHash("sha256").update(ciphertext).digest("hex"), key: material };
|
||||
}
|
||||
|
||||
export async function openRestorePoint(input: {
|
||||
ciphertext: Buffer;
|
||||
sha256: string;
|
||||
key: StoredSecretVersionMaterial;
|
||||
identity: RestorePointIdentity;
|
||||
}): Promise<Buffer> {
|
||||
if (createHash("sha256").update(input.ciphertext).digest("hex") !== input.sha256) {
|
||||
throw new RestorePointRestoreFailure("ciphertext_hash_mismatch");
|
||||
}
|
||||
const minimum = SEALED_MAGIC.length + 12 + 16;
|
||||
if (input.ciphertext.length < minimum || !input.ciphertext.subarray(0, SEALED_MAGIC.length).equals(SEALED_MAGIC)) {
|
||||
throw new RestorePointRestoreFailure("invalid_ciphertext");
|
||||
}
|
||||
const unwrapped = JSON.parse(await localEncryptedProvider.resolveVersion({ material: input.key, externalRef: null } as Parameters<typeof localEncryptedProvider.resolveVersion>[0]));
|
||||
const { identity } = input;
|
||||
if (unwrapped.schema !== KEY_SCHEMA || unwrapped.companyId !== identity.companyId || unwrapped.workspaceId !== identity.workspaceId
|
||||
|| unwrapped.restorePointId !== identity.restorePointId || unwrapped.generation !== identity.generation) {
|
||||
throw new RestorePointRestoreFailure("key_identity_mismatch");
|
||||
}
|
||||
const iv = input.ciphertext.subarray(SEALED_MAGIC.length, SEALED_MAGIC.length + 12);
|
||||
const tag = input.ciphertext.subarray(input.ciphertext.length - 16);
|
||||
const body = input.ciphertext.subarray(SEALED_MAGIC.length + 12, input.ciphertext.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(unwrapped.dataKey, "base64"), iv);
|
||||
decipher.setAAD(additionalData(identity));
|
||||
decipher.setAuthTag(tag);
|
||||
let payload: Buffer;
|
||||
try {
|
||||
payload = Buffer.concat([decipher.update(body), decipher.final()]);
|
||||
} catch {
|
||||
throw new RestorePointRestoreFailure("ciphertext_authentication_failed");
|
||||
}
|
||||
if (payload.length !== unwrapped.plaintextBytes || createHash("sha256").update(payload).digest("hex") !== unwrapped.plaintextSha256) {
|
||||
throw new RestorePointRestoreFailure("plaintext_hash_mismatch");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -247,6 +247,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableCases: parsed.data.enableCases ?? false,
|
||||
enableAgentChat: parsed.data.enableAgentChat ?? false,
|
||||
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
|
||||
enableArtifactsV1: parsed.data.enableArtifactsV1 ?? false,
|
||||
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
||||
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
@@ -298,6 +299,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@papercli
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
|
||||
import { notifyTerminalWorkspaceWork } from "./terminal-workspace-work.js";
|
||||
import { requestRestorePointDeletion } from "./git-restore-point-ledger.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
|
||||
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
|
||||
@@ -11696,6 +11697,7 @@ export function issueService(db: Db) {
|
||||
let removedIssue;
|
||||
try {
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
|
||||
await requestRestorePointDeletion(tx, { sourceIssueId: id });
|
||||
removedIssue = await tx
|
||||
.delete(issues)
|
||||
.where(eq(issues.id, id))
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, executeDaytonaTool, isCurrentDaytonaCatalogEntry, isDaytonaConnection, isDaytonaToolName } from "./daytona.js";
|
||||
import type { NativeCatalogEntry, NativeRestRequest } from "./native-rest-client.js";
|
||||
import { UNIKRAFT_DEFAULT_METRO, UNIKRAFT_TOOLS, executeUnikraftTool, isCurrentUnikraftCatalogEntry, isUnikraftConnection,
|
||||
isUnikraftToolName, unikraftClient, unikraftRisk } from "./connectors/unikraft/client.js";
|
||||
|
||||
type ConnectionShape = { transport: string; config?: Record<string, unknown> };
|
||||
const methodConfig = (connection: ConnectionShape) => {
|
||||
const value = connection.config?.methodConfig;
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
};
|
||||
|
||||
/** A provider whose action catalog is generated from its published HTTP API
|
||||
* and executed by a Paperclip bridge rather than a remote MCP server. Shared
|
||||
* connection code consumes this contract instead of naming providers. */
|
||||
export type NativeProviderCatalog = {
|
||||
slug: string;
|
||||
name: string;
|
||||
tools: typeof DAYTONA_TOOLS;
|
||||
isConnection(connection: ConnectionShape): boolean;
|
||||
isToolName(name: string): boolean;
|
||||
risk(name: string): "read" | "write" | "destructive";
|
||||
isCurrentEntry(entry: NativeCatalogEntry): boolean;
|
||||
probe(connection: ConnectionShape, headers: Record<string, string>, request: NativeRestRequest): Promise<unknown>;
|
||||
execute(input: Parameters<typeof executeDaytonaTool>[0]): Promise<unknown>;
|
||||
};
|
||||
|
||||
export const NATIVE_PROVIDER_CATALOGS: readonly NativeProviderCatalog[] = [
|
||||
{
|
||||
slug: "daytona", name: "Daytona", tools: DAYTONA_TOOLS,
|
||||
isConnection: isDaytonaConnection, isToolName: isDaytonaToolName, risk: daytonaRisk, isCurrentEntry: isCurrentDaytonaCatalogEntry,
|
||||
probe: (connection, headers, request) => daytonaClient(headers, request, methodConfig(connection).organizationId).probe(),
|
||||
execute: executeDaytonaTool,
|
||||
},
|
||||
{
|
||||
slug: "unikraft", name: "Unikraft", tools: UNIKRAFT_TOOLS,
|
||||
isConnection: isUnikraftConnection, isToolName: isUnikraftToolName, risk: unikraftRisk, isCurrentEntry: isCurrentUnikraftCatalogEntry,
|
||||
probe: (connection, headers, request) => unikraftClient(headers, request, methodConfig(connection).metro ?? UNIKRAFT_DEFAULT_METRO).probe(),
|
||||
execute: executeUnikraftTool,
|
||||
},
|
||||
];
|
||||
|
||||
export const NATIVE_PROVIDER_SLUGS: readonly string[] = NATIVE_PROVIDER_CATALOGS.map(provider => provider.slug);
|
||||
|
||||
export function nativeProviderCatalogFor(connection: ConnectionShape): NativeProviderCatalog | null {
|
||||
return NATIVE_PROVIDER_CATALOGS.find(provider => provider.isConnection(connection)) ?? null;
|
||||
}
|
||||
|
||||
export function nativeProviderCatalogForSlug(slug: unknown): NativeProviderCatalog | null {
|
||||
return NATIVE_PROVIDER_CATALOGS.find(provider => provider.slug === slug) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Ajv, { type ValidateFunction } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import { descriptorHash, stableHash } from "./tool-catalog-hash.js";
|
||||
|
||||
/** Shared transport for native provider catalogs (Daytona, Unikraft). Each
|
||||
* provider owns its endpoints and target checks; this owns the bounded,
|
||||
* credential-redacting request/response handling they must all apply. */
|
||||
export const NATIVE_REST_MAX_BYTES = 8 * 1024 * 1024;
|
||||
export type NativeRestRequest = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export const nativeRestFailure = (status: number, code: string, message: string) => new HttpError(status, message, { code });
|
||||
|
||||
export function nativeRestRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function nativeRestPathValue(value: unknown, invalid: () => Error) {
|
||||
const text = String(value);
|
||||
if (!text || text.length > 8192 || /[\u0000-\u001f\\]/.test(text)
|
||||
|| text.split("/").some(segment => segment === "." || segment === "..")) {
|
||||
throw invalid();
|
||||
}
|
||||
return encodeURIComponent(text);
|
||||
}
|
||||
|
||||
export function nativeRestSender(options: {
|
||||
/** Provider display name used in caller-facing messages. */
|
||||
provider: string;
|
||||
/** Error code prefix, such as `daytona`. */
|
||||
code: string;
|
||||
authorization: string;
|
||||
request: NativeRestRequest;
|
||||
signal: AbortSignal;
|
||||
deadlineAt: number;
|
||||
deniedMessage: string;
|
||||
/** Provider-owned headers set after caller headers so they cannot be replaced. */
|
||||
fixedHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const { provider, code, authorization, request, signal, deadlineAt } = options;
|
||||
const secret = authorization.replace(/^Bearer /, "");
|
||||
const expired = () => nativeRestFailure(504, `${code}_deadline_exceeded`, `The ${provider} action deadline expired. An operation already sent may still be running; inspect it before retrying.`);
|
||||
// Bound the caller even if a transport or body reader is slow to acknowledge
|
||||
// cancellation. A late prerequisite result never advances to the next call.
|
||||
async function withinDeadline<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
let abort!: () => void;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(expired());
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally { signal.removeEventListener("abort", abort); }
|
||||
}
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactSensitiveText(value.split(secret).join("[REDACTED]"));
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `expectedStatuses` maps a provider-documented non-2xx outcome (for
|
||||
* example, a wait that timed out) to the result returned in its place. */
|
||||
async function send(url: string, init: RequestInit, extraHeaders: Record<string, string> = {},
|
||||
expectedStatuses: Record<number, unknown> = {}): Promise<unknown> {
|
||||
const projected = new Headers(init.headers);
|
||||
projected.set("Authorization", authorization);
|
||||
for (const [name, value] of Object.entries({ ...options.fixedHeaders, ...extraHeaders })) projected.set(name, value);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await withinDeadline(() => request(url, { ...init, redirect: "error", headers: Object.fromEntries(projected), signal }));
|
||||
} catch {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
throw nativeRestFailure(502, `${code}_outcome_unknown`, init.method === "GET"
|
||||
? `${provider} could not complete the request.`
|
||||
: `${provider} did not return a result. The operation may have started; inspect its state before retrying.`);
|
||||
}
|
||||
if (Object.hasOwn(expectedStatuses, response.status)) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
return expectedStatuses[response.status];
|
||||
}
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
const status = [400, 401, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
|
||||
throw nativeRestFailure(status, `${code}_request_failed`, status === 401 || status === 403
|
||||
? options.deniedMessage
|
||||
: status === 404 ? `${provider} could not find this resource.` : `${provider} could not complete this operation.`);
|
||||
}
|
||||
if (response.status === 204 || !response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await withinDeadline(() => reader.read());
|
||||
if (part.done) break;
|
||||
length += part.value.byteLength;
|
||||
if (length > NATIVE_REST_MAX_BYTES) throw nativeRestFailure(502, `${code}_output_limit`, `${provider}'s response exceeded the limit. Request a smaller page or file range.`);
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw nativeRestFailure(502, `${code}_outcome_unknown`, `${provider}'s response was interrupted. Inspect the operation before retrying.`);
|
||||
} finally { void reader.cancel().catch(() => {}); }
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!bytes.length) return null;
|
||||
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
if (contentType.includes("json")) {
|
||||
try { return sanitize(JSON.parse(bytes.toString("utf8"))); }
|
||||
catch { throw nativeRestFailure(502, `${code}_invalid_response`, `${provider} returned an invalid response.`); }
|
||||
}
|
||||
if (contentType.startsWith("text/")) return sanitize(bytes.toString("utf8"));
|
||||
if (bytes.includes(Buffer.from(secret))) throw nativeRestFailure(403, `${code}_credential_export_denied`, "The response contains connection credential material.");
|
||||
return { contentType, contentBase64: bytes.toString("base64"), byteLength: bytes.length };
|
||||
}
|
||||
|
||||
return { send, withinDeadline };
|
||||
}
|
||||
|
||||
type NativeOperation = {
|
||||
name: string; title: string; description: string; inputSchema: unknown;
|
||||
annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
||||
};
|
||||
export type NativeCatalogEntry = { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string };
|
||||
|
||||
// Match the interop used by the existing plugin configuration validator.
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ strict: false, allErrors: true, validateFormats: true });
|
||||
((addFormats as any).default ?? addFormats)(ajv);
|
||||
|
||||
/** The deployed contract of a generated provider catalog: agent-visible tools,
|
||||
* reviewed risk, saved-entry staleness and argument validation. */
|
||||
export function nativeCatalog<T extends NativeOperation>(operationsList: T[]) {
|
||||
const operations = new Map(operationsList.map(operation => [operation.name, operation]));
|
||||
const validators = new Map<string, ValidateFunction>();
|
||||
const risk = (name: string): "read" | "write" | "destructive" => {
|
||||
const operation = operations.get(name);
|
||||
if (!operation || operation.annotations.destructiveHint) return "destructive";
|
||||
return operation.annotations.readOnlyHint ? "read" : "write";
|
||||
};
|
||||
const tools = operationsList.map(({ name, title, description, inputSchema, annotations }) => ({
|
||||
name, title, description, inputSchema: inputSchema as Record<string, unknown>, annotations,
|
||||
}));
|
||||
const contracts = new Map(tools.map(tool => [tool.name, {
|
||||
versionHash: descriptorHash(tool, risk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: risk(tool.name),
|
||||
}]));
|
||||
return {
|
||||
tools,
|
||||
operation: (name: string) => operations.get(name),
|
||||
isToolName: (name: string) => operations.has(name),
|
||||
risk,
|
||||
isCurrentEntry(entry: NativeCatalogEntry): boolean {
|
||||
const current = contracts.get(entry.toolName);
|
||||
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
|
||||
},
|
||||
validate(name: string, parameters: unknown): boolean {
|
||||
const operation = operations.get(name);
|
||||
if (!operation) return false;
|
||||
let validate = validators.get(name);
|
||||
if (!validate) { validate = ajv.compile(operation.inputSchema as object) as ValidateFunction; validators.set(name, validate); }
|
||||
return validate(parameters) as boolean;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-acc
|
||||
|
||||
import { stableHash } from "./tool-catalog-hash.js";
|
||||
|
||||
import { isDaytonaConnection } from "./daytona.js";
|
||||
import { NATIVE_PROVIDER_SLUGS, nativeProviderCatalogFor } from "./native-provider-catalogs.js";
|
||||
import { connectorToolPolicy } from "./connectors/tool-policy.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
||||
@@ -2412,7 +2412,7 @@ export function toolAccessService(
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name: applicationName,
|
||||
description: safeApplicationDescription,
|
||||
type: transport === "rest_api" && ["browser-use-cloud", "daytona"].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: transport === "rest_api" && ["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry
|
||||
? {
|
||||
@@ -4488,7 +4488,7 @@ export function toolAccessService(
|
||||
companyId,
|
||||
applicationKey: normalizeKey(input.applicationName ?? input.name),
|
||||
name: input.applicationName ?? input.name,
|
||||
type: isBrowserUseConnection({ transport, config }) || isDaytonaConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "active",
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { googleWorkspaceToolTarget } from "@paperclipai/shared";
|
||||
import { BROWSER_USE_TOOLS } from "@paperclipai/shared";
|
||||
import { isMemoryConnectorId } from "@paperclipai/shared";
|
||||
@@ -291,5 +291,5 @@ export function isGoogleWorkspaceToolAllowed(
|
||||
export function shouldQuarantineNewEntries(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): boolean {
|
||||
return isDaytonaConnection(connection) || asRecord(connection.config).quarantineNewEntries === true;
|
||||
return Boolean(nativeProviderCatalogFor(connection)) || asRecord(connection.config).quarantineNewEntries === true;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { ActorInfo } from "../types.js";
|
||||
import { createOAuthDiscovery } from "../oauth/discovery.js";
|
||||
import { oauthConfig } from "../oauth/metadata.js";
|
||||
import { projectedConnectionHeaders } from "../connection-methods.js";
|
||||
import { DAYTONA_TOOLS, daytonaClient, isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, type GoogleWorkspaceService } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "../../connection-permission-errors.js";
|
||||
import { mcpDiscoveryHttpFailure, withMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
@@ -426,10 +426,11 @@ export function createToolCatalogDiscovery(db: Db, { now, audit, secrets, creden
|
||||
assertSupportedConnection(connection);
|
||||
await assertConnectorConnectionSetupEnabled(connection);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
||||
if (isDaytonaConnection(connection)) {
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) {
|
||||
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
|
||||
await daytonaClient(headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init), asRecord(connection.config.methodConfig).organizationId).probe();
|
||||
return DAYTONA_TOOLS;
|
||||
await nativeProvider.probe(connection, headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init));
|
||||
return nativeProvider.tools;
|
||||
}
|
||||
if (isBrowserUseConnection(connection)) {
|
||||
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connectionSetupUrl, connectionReconnectUrl } from "../identifiers.js";
|
||||
import { createGitHubGrantContinuity } from "../github-grant-continuity.js";
|
||||
import { createToolRuntimeSlots } from "../runtime/slots.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
import { isBrowserUseConnection } from "../../browser-use-client.js";
|
||||
import { and, asc, eq, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
@@ -121,7 +121,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
});
|
||||
for (const grant of grantsToCheck)
|
||||
await refreshManagedGitHubGrantAccess(connection, grant, actor);
|
||||
} else if (isBrowserUseConnection(connection) || isDaytonaConnection(connection)) {
|
||||
} else if (isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) {
|
||||
await discoverTools(connection, undefined, actor);
|
||||
} else if (isAgentMailConnection(connection)) {
|
||||
await validateAgentMailConnection(connection);
|
||||
@@ -157,8 +157,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
: config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isDaytonaConnection(connection)
|
||||
? "Daytona API access is connected."
|
||||
: nativeProviderCatalogFor(connection)
|
||||
? `${nativeProviderCatalogFor(connection)!.name} API access is connected.`
|
||||
: isBrowserUseConnection(connection)
|
||||
? "Browser Use API key is connected."
|
||||
: isAgentMailConnection(connection)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createToolRuntimeSlots } from "../runtime/slots.js";
|
||||
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
|
||||
import { connectionMethodForConnection } from "../connection-methods.js";
|
||||
import { stableHash, descriptorHash } from "../../tool-catalog-hash.js";
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
|
||||
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
||||
@@ -115,9 +115,9 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
const existingByName = new Map(
|
||||
existingRows.map((entry) => [entry.toolName, entry]),
|
||||
);
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
const nativeProvider = Boolean(nativeProviderCatalogFor(connection));
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
|| nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
|| nativeProvider || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
||||
// The catalog is shared, but discovery uses only the caller's grant. A
|
||||
// narrower personal consent must not remove actions another active grant
|
||||
@@ -135,13 +135,13 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
}
|
||||
// Preserve only consent-filtered actions, not failed-service catalogs or
|
||||
// tools a successful, sufficiently authorized discovery actually removed.
|
||||
const removedStatus = nativeDaytona ? "removed" : "disabled";
|
||||
const removedStatus = nativeProvider ? "removed" : "disabled";
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus
|
||||
&& !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes))
|
||||
&& !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes)
|
||||
&& !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id);
|
||||
if (removedIds.length) await publish(tx => tx.update(toolCatalogEntries)
|
||||
.set({ status: removedStatus, quarantineReason: nativeDaytona ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.set({ status: removedStatus, quarantineReason: nativeProvider ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds))));
|
||||
}
|
||||
// Retired native actions are absent from discovery, but old catalog rows
|
||||
@@ -200,7 +200,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
quarantineOnRefresh &&
|
||||
(!existing || changed) &&
|
||||
existing?.status !== "disabled" &&
|
||||
(!safeDefault || riskLevel !== "read" || nativeDaytona);
|
||||
(!safeDefault || riskLevel !== "read" || nativeProvider);
|
||||
const providerPermanentlyBlocked = Boolean(
|
||||
googleProfile &&
|
||||
!isGoogleWorkspaceToolAllowed(googleProfile, descriptor),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { singleFlight } from "../single-flight.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { descriptorHash } from "../../tool-catalog-hash.js";
|
||||
import { DAYTONA_TOOLS, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { toolCatalogEntries, toolConnections } from "@paperclipai/db";
|
||||
@@ -27,18 +27,18 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
.where(eq(toolCatalogEntries.connectionId, connection.id))
|
||||
.orderBy(desc(toolCatalogEntries.updatedAt));
|
||||
// Native provider catalogs change with the deployed code. Upgrade saved
|
||||
// Daytona connections too; their first small catalog must not survive
|
||||
// merely because REST discovery has no remote MCP cache timer.
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
// connections too; an earlier catalog must not survive merely because
|
||||
// REST discovery has no remote MCP cache timer.
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
const rowsByName = new Map(rows.map((row) => [row.toolName, row]));
|
||||
const daytonaCatalogChanged = nativeDaytona && connection.status === "active" && (
|
||||
rows.some((row) => row.status !== "removed" && !isDaytonaToolName(row.toolName))
|
||||
|| DAYTONA_TOOLS.some((tool) => {
|
||||
const nativeCatalogChanged = Boolean(nativeProvider) && connection.status === "active" && (
|
||||
rows.some((row) => row.status !== "removed" && !nativeProvider!.isToolName(row.toolName))
|
||||
|| nativeProvider!.tools.some((tool) => {
|
||||
const existing = rowsByName.get(tool.name);
|
||||
return !existing || existing.versionHash !== descriptorHash(tool, daytonaRisk(tool.name));
|
||||
return !existing || existing.versionHash !== descriptorHash(tool, nativeProvider!.risk(tool.name));
|
||||
})
|
||||
);
|
||||
const cacheExpired = daytonaCatalogChanged || (
|
||||
const cacheExpired = nativeCatalogChanged || (
|
||||
connection.transport === "mcp_remote" &&
|
||||
connection.status !== "archived" &&
|
||||
(rows.length === 0 ||
|
||||
@@ -48,7 +48,7 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
if (cacheExpired) {
|
||||
try {
|
||||
await singleFlight(catalogRefreshFlights, connection.id, () =>
|
||||
refreshCatalog(connection.id, nativeDaytona && actor ? actor : {
|
||||
refreshCatalog(connection.id, nativeProvider && actor ? actor : {
|
||||
actorType: "system",
|
||||
actorId: "tool_catalog_cache",
|
||||
}),
|
||||
@@ -68,7 +68,7 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
// Even failed discovery must not keep retired native tools visible.
|
||||
// Their database rows remain available to historical invocation records.
|
||||
return rows
|
||||
.filter((row) => !nativeDaytona || (row.status !== "removed" && isDaytonaToolName(row.toolName)))
|
||||
.filter((row) => !nativeProvider || (row.status !== "removed" && nativeProvider.isToolName(row.toolName)))
|
||||
.map((row) => toCatalogEntryForConnection(row, connection));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { isBrowserUseConnection } from "../../browser-use-client.js";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -58,7 +58,8 @@ export function createConnectionTokenBroker(db: Db, { now, secrets, policySvc, a
|
||||
input.connectionId,
|
||||
input.companyId,
|
||||
);
|
||||
if (isDaytonaConnection(connection)) throw forbidden("Daytona credentials stay in the governed tool gateway and cannot be exported.");
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) throw forbidden(`${nativeProvider.name} credentials stay in the governed tool gateway and cannot be exported.`);
|
||||
if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported.");
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
|
||||
const application = await getConnectionApplication(connection);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BudgetServiceHooks } from "./budgets.js";
|
||||
import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared";
|
||||
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { executeDaytonaTool, isDaytonaConnection, isCurrentDaytonaCatalogEntry } from "./daytona.js";
|
||||
import { nativeProviderCatalogFor } from "./native-provider-catalogs.js";
|
||||
import { composeConnectionInstructions } from "./connection-instructions.js";
|
||||
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
@@ -1445,9 +1445,9 @@ export function createToolGatewayService(
|
||||
({ catalogEntry, connection, application }) =>
|
||||
(!featureSettings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(application.applicationKey), featureSettings)) &&
|
||||
!isRetiredComposioConnection(connection) &&
|
||||
!(isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(catalogEntry)) &&
|
||||
!(nativeProviderCatalogFor(connection) && !nativeProviderCatalogFor(connection)!.isCurrentEntry(catalogEntry)) &&
|
||||
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
|
||||
(((isBrowserUseConnection(connection) || isDaytonaConnection(connection)) && application.type === "rest_api") ||
|
||||
(((isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) && application.type === "rest_api") ||
|
||||
(connection.transport === "mcp_remote" &&
|
||||
application.type === "mcp_http") ||
|
||||
(connection.transport === "local_stdio" &&
|
||||
@@ -1473,7 +1473,7 @@ export function createToolGatewayService(
|
||||
({ catalogEntry, connection, application }, index) => {
|
||||
if (
|
||||
connection.transport !== "mcp_remote" &&
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection)
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection)
|
||||
) {
|
||||
throw new Error(
|
||||
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
|
||||
@@ -4903,7 +4903,7 @@ export function createToolGatewayService(
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection))) {
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection))) {
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
`Tool "${tool.name}" not found`,
|
||||
@@ -4926,8 +4926,9 @@ export function createToolGatewayService(
|
||||
if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) {
|
||||
throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked");
|
||||
}
|
||||
if (isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(entry)) {
|
||||
throw new ToolGatewayHttpError(409, "Daytona's action catalog changed. Review the updated actions in Permissions.", "daytona_catalog_changed");
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider && !nativeProvider.isCurrentEntry(entry)) {
|
||||
throw new ToolGatewayHttpError(409, `${nativeProvider.name}'s action catalog changed. Review the updated actions in Permissions.`, `${nativeProvider.slug}_catalog_changed`);
|
||||
}
|
||||
return { entry, connection };
|
||||
}
|
||||
@@ -6069,11 +6070,12 @@ export function createToolGatewayService(
|
||||
ms = railwayCommandBudgetMs(parameters);
|
||||
}
|
||||
const grant = await resolveConnectionGrant(session, connection);
|
||||
if (isDaytonaConnection(connection)) {
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) {
|
||||
const budgetMs = Math.min(ms, 30_000);
|
||||
const deadlineAt = Date.now() + budgetMs;
|
||||
const signal = AbortSignal.timeout(budgetMs);
|
||||
const data = await executeDaytonaTool({ binding: session, connection,
|
||||
const data = await nativeProvider.execute({ binding: session, connection,
|
||||
headers: await resolveCredentialHeaders(session, connection, grant),
|
||||
request: (url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
|
||||
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Unikraft uses the ordinary access and credential wizard", async ({ page, request }) => {
|
||||
const response = await request.post("/api/companies", { data: { name: `Unikraft setup ${Date.now()}` } });
|
||||
expect(response.ok()).toBe(true);
|
||||
const company = await response.json();
|
||||
await page.goto(`/${company.issuePrefix}/apps`);
|
||||
await expect(page.getByRole("heading", { name: "Connectors", exact: true })).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: "Search connectors" }).fill("Unikraft");
|
||||
const card = page.getByRole("list", { name: "Connector list" }).getByRole("listitem").filter({ hasText: "Unikraft" });
|
||||
await expect(card).toHaveCount(1);
|
||||
// App marks are decorative (empty alt), so they have no accessible img role.
|
||||
await expect(card.locator('img[src="/brands/apps/unikraft.svg"]')).toBeVisible();
|
||||
await card.getByRole("button", { name: "Add key Unikraft", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connect Unikraft", exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Unikraft key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Unikraft key", { exact: true })).toHaveAttribute("type", "password");
|
||||
await expect(page.getByText("Default metro", { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText("Connects for everyone in your organization, available to all agents.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||||
await expect(page.getByText("Which humans can use this credential?", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Default metro", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("select").filter({ has: page.locator('option[value="fra"]') })).toHaveValue("fra");
|
||||
// No token is submitted and no real provider connection is created. Gateway
|
||||
// lifecycle and provider reads are exercised in the database-backed suite.
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<title>Artifact directions — lab</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<style>
|
||||
/*
|
||||
Unlayered, for the same reason `wizard-preview.html` carries the same
|
||||
rule: index.css pins `html, body` to viewport height with
|
||||
`overflow: hidden` inside @layer base, which is right for the app shell
|
||||
and would stop this page growing to the height of its frames — a
|
||||
full-page capture would then stop at the viewport.
|
||||
|
||||
Only <html> scrolls. A body that is itself a scroll container (overflow
|
||||
auto) is the nearest scrollport of everything inside it, so the sticky
|
||||
lab toolbar would stick to the body's own box — as tall as the page —
|
||||
and never stay in view.
|
||||
*/
|
||||
html {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/artifact-directions/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -702,6 +702,12 @@
|
||||
"catalogVisible": true,
|
||||
"localAsset": "/brands/apps/paperclip-notebook.svg",
|
||||
"darkAsset": "/brands/apps/paperclip-notebook-dark.svg"
|
||||
},
|
||||
{
|
||||
"slug": "unikraft",
|
||||
"provider": "Unikraft",
|
||||
"catalogVisible": true,
|
||||
"localAsset": "/brands/apps/unikraft.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 50 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M49.466,39.225l-13.671,0c-0.193,0 -0.338,0.097 -0.435,0.29c-0.579,1.111 -1.787,1.884 -3.139,1.884l-14.396,-0c-1.98,-0 -3.575,-1.594 -3.575,-3.575l0,-19.95c0,-1.981 -1.594,-3.575 -3.574,-3.575l-7.101,-0c-1.981,-0 -3.575,1.594 -3.575,3.575l-0,19.95c-0,9.855 7.971,17.825 17.825,17.825l14.396,0c9.178,0 16.714,-6.956 17.728,-15.844c0,-0.29 -0.193,-0.58 -0.483,-0.58Z" style="fill:url(#_Radial1);fill-rule:nonzero;"></path><path d="M48.79,2.125c0.435,0 0.628,-0.483 0.386,-0.772c-0.676,-0.822 -1.642,-1.353 -2.802,-1.353l-7.149,-0c-1.111,-0 -2.125,0.531 -2.802,1.353c-0.241,0.338 0,0.772 0.387,0.772l11.98,0Zm0.145,33.574l-12.27,-0c-0.58,-0 -1.015,0.434 -1.015,1.014l0,0.097c0,0.579 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.435 1.014,-1.014l0,-0.097c0,-0.58 -0.435,-1.014 -1.014,-1.014Zm-0.145,-24.975c0.435,0 0.628,0.483 0.386,0.773c-0.676,0.821 -1.642,1.353 -2.802,1.353l-11.641,-0c-0.29,-0 -0.484,-0.194 -0.484,-0.483c0,-0.145 0,-0.29 0.194,-0.339c0.338,-0.289 0.628,-0.628 0.821,-1.062c0.096,-0.145 0.241,-0.29 0.435,-0.29l13.091,-0l-0,0.048Zm-11.835,4.396c-0.338,0.29 -0.628,0.628 -0.822,1.063c-0.096,0.145 -0.241,0.29 -0.434,0.29l-13.14,-0c-0.435,-0 -0.628,-0.483 -0.386,-0.773c0.676,-0.822 1.642,-1.353 2.802,-1.353l11.642,0c0.289,0 0.483,0.193 0.483,0.483c-0,0.097 -0,0.242 -0.145,0.338l-0,-0.048Zm0.193,11.497c-0,0.29 -0.193,0.483 -0.483,0.483l-11.642,0c-1.111,0 -2.126,-0.531 -2.802,-1.352c-0.242,-0.339 -0,-0.773 0.387,-0.773l13.139,-0c0.193,-0 0.338,0.096 0.435,0.289c0.193,0.387 0.483,0.725 0.821,1.063c0.097,0 0.145,0.193 0.145,0.338l-0,-0.048Zm11.642,4.058l-13.14,-0c-0.193,-0 -0.338,-0.097 -0.434,-0.29c-0.194,-0.386 -0.483,-0.725 -0.822,-1.063c-0.096,-0.096 -0.193,-0.241 -0.193,-0.338c0,-0.29 0.193,-0.483 0.483,-0.483l11.642,-0c1.111,-0 2.126,0.531 2.802,1.353c0.29,0.338 -0,0.821 -0.338,0.821Zm0.145,1.449l-12.27,-0c-0.58,-0 -1.015,0.435 -1.015,1.014l0,0.097c0,0.58 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.434 1.014,-1.014l0,-0.097c0,-0.579 -0.435,-1.014 -1.014,-1.014Zm-14.251,-10.724l-12.27,-0c-0.579,-0 -1.014,0.435 -1.014,1.014l-0,0.097c-0,0.58 0.435,1.014 1.014,1.014l12.27,0c0.58,0 1.015,-0.434 1.015,-1.014l-0,-0.097c-0,-0.579 -0.483,-1.014 -1.015,-1.014Zm0,-3.575l-12.27,0c-0.579,0 -1.014,0.435 -1.014,1.015l-0,0.096c-0,0.58 0.435,1.015 1.014,1.015l12.27,-0c0.58,-0 1.015,-0.435 1.015,-1.015l-0,-0.096c-0,-0.532 -0.483,-1.015 -1.015,-1.015Zm14.251,-10.676l-12.27,0c-0.58,0 -1.015,0.435 -1.015,1.015l0,0.096c0,0.58 0.435,1.015 1.015,1.015l12.27,-0c0.579,-0 1.014,-0.435 1.014,-1.015l0,-0.096c0,-0.58 -0.435,-1.015 -1.014,-1.015Zm-0,-3.574l-12.27,-0c-0.58,-0 -1.015,0.434 -1.015,1.014l0,0.097c0,0.579 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.435 1.014,-1.014l0,-0.097c0,-0.58 -0.435,-1.014 -1.014,-1.014Z" style="fill:url(#_Linear2);fill-rule:nonzero;"></path><defs><radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.0718,20.5709,-35.9993,28.1257,14.8241,23.4977)"><stop offset="0" style="stop-color:#0059e9;stop-opacity:1"></stop><stop offset="1" style="stop-color:#00a2f6;stop-opacity:1"></stop></radialGradient><linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.676277,-37.9208,37.9208,-0.676277,36.1334,38.0657)"><stop offset="0" style="stop-color:#0094f1;stop-opacity:1"></stop><stop offset="0.2" style="stop-color:#0094f1;stop-opacity:1"></stop><stop offset="0.5" style="stop-color:#0094ff;stop-opacity:1"></stop><stop offset="0.7" style="stop-color:#00d9fd;stop-opacity:1"></stop><stop offset="1" style="stop-color:#00e8ce;stop-opacity:1"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { installApiStub, registerApiRoute } from "./api-stub";
|
||||
import { COMPANY } from "./fixtures";
|
||||
|
||||
describe("installApiStub", () => {
|
||||
const original = window.fetch;
|
||||
const restores: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (restores.length) restores.pop()?.();
|
||||
window.fetch = original;
|
||||
});
|
||||
|
||||
it("answers /api/ from the fixtures and puts fetch back when restored", async () => {
|
||||
restores.push(installApiStub());
|
||||
expect(window.fetch).not.toBe(original);
|
||||
const companies = await (await window.fetch("/api/companies")).json();
|
||||
expect(companies).toEqual([COMPANY]);
|
||||
restores.pop()?.();
|
||||
expect(window.fetch).toBe(original);
|
||||
});
|
||||
|
||||
it("serves a registered route and answers anything else under /api/ with an empty object", async () => {
|
||||
registerApiRoute((pathname) => (pathname === "/api/lab/ping" ? { pong: true } : undefined));
|
||||
restores.push(installApiStub());
|
||||
expect(await (await window.fetch("/api/lab/ping")).json()).toEqual({ pong: true });
|
||||
expect(await (await window.fetch("/api/lab/nothing", { method: "POST" })).json()).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps one stub across nested installs and restores on the last release", () => {
|
||||
const first = installApiStub();
|
||||
const stub = window.fetch;
|
||||
const second = installApiStub();
|
||||
restores.push(first, second);
|
||||
expect(window.fetch).toBe(stub);
|
||||
first();
|
||||
expect(window.fetch).toBe(stub);
|
||||
first(); // a second call of the same restore is a no-op
|
||||
expect(window.fetch).toBe(stub);
|
||||
second();
|
||||
expect(window.fetch).toBe(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { COMPANY } from "./fixtures";
|
||||
|
||||
/**
|
||||
* The lab's only "server". Every API call in the app goes through `fetch`, so
|
||||
* standing in for `/api/` here is enough to render real data-fetching
|
||||
* components with no backend and no module mocks. Nothing under `/api/` ever
|
||||
* reaches the network; everything else (fonts, assets) passes through.
|
||||
*
|
||||
* A direction that renders a real component registers the routes it needs.
|
||||
* `installApiStub` returns a restore, so a story can scope the stub to itself.
|
||||
*/
|
||||
|
||||
/** Returns the JSON body for a path it owns, or `undefined` to pass. */
|
||||
export type ApiRoute = (pathname: string, method: string) => unknown | undefined;
|
||||
|
||||
const routes: ApiRoute[] = [];
|
||||
|
||||
export function registerApiRoute(route: ApiRoute): void {
|
||||
routes.push(route);
|
||||
}
|
||||
|
||||
const json = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
||||
|
||||
function respond(pathname: string, method: string): Response {
|
||||
for (const route of routes) {
|
||||
const body = route(pathname, method);
|
||||
if (body !== undefined) return json(body);
|
||||
}
|
||||
if (pathname.endsWith("/auth/get-session")) {
|
||||
return json({ session: { id: "lab-session", userId: "lab-user" }, user: { id: "lab-user", name: "Lab", email: "lab@acmehats.example" } });
|
||||
}
|
||||
if (pathname.endsWith("/companies")) return json([COMPANY]);
|
||||
// An unhandled call answers empty rather than failing, so it is never the
|
||||
// reason a frame looks broken; the debug line says what was asked for.
|
||||
console.debug("[artifact-directions] unhandled", method, pathname);
|
||||
return json({});
|
||||
}
|
||||
|
||||
let holders = 0;
|
||||
let realFetch: typeof window.fetch | null = null;
|
||||
let stubFetch: typeof window.fetch | null = null;
|
||||
|
||||
/**
|
||||
* Stands in for `/api/` until the returned function is called. The lab page
|
||||
* installs once for its lifetime; a Storybook story installs on mount and
|
||||
* restores on unmount. Nested installs share one stub, and the last restore
|
||||
* puts the real `fetch` back — unless something else replaced it meanwhile.
|
||||
*/
|
||||
export function installApiStub(): () => void {
|
||||
if (holders === 0) {
|
||||
const original = window.fetch;
|
||||
const real = original.bind(window);
|
||||
realFetch = original;
|
||||
stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/")) return real(input as RequestInfo, init);
|
||||
const { pathname } = new URL(url, window.location.origin);
|
||||
return respond(pathname, (init?.method ?? "GET").toUpperCase());
|
||||
}) as typeof window.fetch;
|
||||
window.fetch = stubFetch;
|
||||
}
|
||||
holders += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
holders -= 1;
|
||||
if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 800" width="1600" height="800"><rect width="1600" height="800" fill="#1f4d3a"/><rect x="40" y="40" width="1520" height="720" fill="none" stroke="#f3ecd9" stroke-opacity="0.25" stroke-width="2"/>
|
||||
<g transform="translate(300 250) scale(2.1)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#e4dbc4"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f3ecd9"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(31,77,58,0.45)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(31,77,58,0.45)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f3ecd9" stroke="rgba(31,77,58,0.45)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#c8553d" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="740" y="440" font-family="Georgia, 'Times New Roman', serif" font-size="150" fill="#f3ecd9">acmehats</text>
|
||||
<text x="746" y="520" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" letter-spacing="10" fill="#f3ecd9" opacity="0.7">CUSTOM CAPS · EST. 2026</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7eb6e3"/><stop offset="1" stop-color="#ee79a1"/></linearGradient></defs>
|
||||
<path d="M14 88 V50 C14 26 29 10 48 10 C67 10 82 26 82 50 V88 C82 90 80 91 78 90 L70 84 L62 90 C61 91 59 91 58 90 L48 84 L38 90 C37 91 35 91 34 90 L26 84 L18 90 C16 91 14 90 14 88 Z" fill="url(#g)"/>
|
||||
<circle cx="38" cy="46" r="4.5" fill="#1b1b1b"/><circle cx="58" cy="46" r="4.5" fill="#1b1b1b"/><path d="M40 58 Q48 65 56 58" fill="none" stroke="#1b1b1b" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3e6c4"/><stop offset="1" stop-color="#e3a21a"/></linearGradient></defs>
|
||||
<path d="M14 88 V50 C14 26 29 10 48 10 C67 10 82 26 82 50 V88 C82 90 80 91 78 90 L70 84 L62 90 C61 91 59 91 58 90 L48 84 L38 90 C37 91 35 91 34 90 L26 84 L18 90 C16 91 14 90 14 88 Z" fill="url(#g)"/>
|
||||
<circle cx="38" cy="46" r="4.5" fill="#1b1b1b"/><circle cx="58" cy="46" r="4.5" fill="#1b1b1b"/><path d="M40 58 Q48 65 56 58" fill="none" stroke="#1b1b1b" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900"><defs><radialGradient id="sp" cx="0.5" cy="0.38" r="0.6"><stop offset="0" stop-color="#5a3a22"/><stop offset="0.55" stop-color="#22160f"/><stop offset="1" stop-color="#0b0807"/></radialGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="18"/></filter></defs>
|
||||
<rect width="1600" height="900" fill="url(#sp)"/>
|
||||
<ellipse cx="800" cy="676" rx="470" ry="62" fill="#2b1c12"/><ellipse cx="800" cy="664" rx="470" ry="62" fill="#3c2819"/><ellipse cx="800" cy="664" rx="400" ry="50" fill="none" stroke="#6b4a2f" stroke-width="2" opacity="0.6"/>
|
||||
<ellipse cx="810" cy="662" rx="330" ry="26" fill="#000" opacity="0.55" filter="url(#sh)"/>
|
||||
<g transform="translate(420 250) scale(3.9)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#c44f1d"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#e2632d"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#e2632d" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6ead8" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="70" y="820" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="34" font-weight="700" fill="#f6ead8" opacity="0.85" letter-spacing="3">ACMEHATS — SIGNAL ORANGE, 360°</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1250" width="1000" height="1250"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d98f76"/><stop offset="1" stop-color="#c97458"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="20"/></filter></defs>
|
||||
<rect width="1000" height="1250" fill="url(#bg)"/><rect x="0" y="880" width="1000" height="370" fill="#b9634a"/>
|
||||
<rect x="150" y="820" width="700" height="90" rx="6" fill="#efe3d3"/><rect x="150" y="900" width="700" height="14" fill="#d5c4ae"/>
|
||||
<ellipse cx="510" cy="826" rx="330" ry="24" fill="#3a1a10" opacity="0.45" filter="url(#sh)"/>
|
||||
<g transform="translate(180 420) scale(3.5)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#0c0c0c"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#151515"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#151515" stroke="rgba(255,255,255,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#efe3d3" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<g transform="translate(801 190) scale(-2.9 2.9)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#e2d5c2"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f1e7d8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f1e7d8" stroke="rgba(0,0,0,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#151515" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="76" y="1150" font-family="Georgia, 'Times New Roman', serif" font-size="64" fill="#fbeee2">Your place. Your cap.</text></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" width="1200" height="1200"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f7cf2f"/><stop offset="1" stop-color="#f0b91a"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="26"/></filter></defs>
|
||||
<rect width="1200" height="1200" fill="url(#bg)"/><circle cx="600" cy="560" r="400" fill="#f9dc5a" opacity="0.65"/>
|
||||
<ellipse cx="610" cy="818" rx="400" ry="34" fill="#7a5200" opacity="0.4" filter="url(#sh)"/>
|
||||
<g transform="translate(1007 330) scale(-4.3 4.3)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1836a3"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f44c8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f44c8" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f7cf2f" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="80" y="1110" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="46" font-weight="800" fill="#1836a3" letter-spacing="2">THE SHARED MARK</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1000" width="1500" height="1000"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#efe5d2"/><stop offset="1" stop-color="#e2d3b8"/></linearGradient><linearGradient id="fl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d9c8a8"/><stop offset="1" stop-color="#cdb992"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="22"/></filter></defs>
|
||||
<rect width="1500" height="1000" fill="url(#bg)"/><path d="M0 640 C400 600 1100 600 1500 640 L1500 1000 L0 1000 Z" fill="url(#fl)"/>
|
||||
<ellipse cx="760" cy="742" rx="430" ry="38" fill="#3b2f1c" opacity="0.38" filter="url(#sh)"/>
|
||||
<g transform="translate(330 250) scale(4.4)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1d4333"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#24523f"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.16)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.16)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#24523f" stroke="rgba(255,255,255,0.16)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f1e7d2" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="96" y="96" width="150" height="150" rx="10" fill="#f8f1e2" opacity="0.6"/><text x="126" y="200" font-family="Georgia, 'Times New Roman', serif" font-size="96" fill="#24523f">a</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d9d2c0"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#1f4d3a"/><text x="26" y="62" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your place.</text><text x="26" y="95.6" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your people.</text><text x="26" y="129.2" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/>
|
||||
<rect x="330" y="46" width="96" height="96" fill="#f6f0e1"/><text x="360" y="112" font-family="Georgia, 'Times New Roman', serif" font-size="56" font-weight="400" fill="#1f4d3a">a</text>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#f3ecd9" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#f3ecd9" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#f3ecd9" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#1f3a2e" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#1f4d3a"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#1f4d3a"/><text x="36" y="98" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Independent cafés</text><text x="36" y="114.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">For independent places,</text><text x="22" y="101.68" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#1f4d3a"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">A cap can become a small expression</text><text x="22" y="113.12" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#1f4d3a"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">acmehats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#1f3a2e">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#1f3a2e">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#c8553d"/><rect x="108" y="192" width="80" height="6" fill="#1f4d3a"/><rect x="194" y="192" width="68" height="6" fill="#1f3a2e" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f4d3a"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f4d3a"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f4d3a" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6f0e1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#1f4d3a"/><text x="300" y="100" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your place.</text><text x="300" y="116.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your people.</text><text x="300" y="133.6" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#1f44c8"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#f4c81f"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#1a2f8f">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#1a2f8f">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#ffffff" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#1f44c8"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#f4c81f"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#f6f0e1"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f44c8"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f44c8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f44c8" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f4c81f" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#f4c81f"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#1f4d3a"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#f3ecd9">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#1f3a2e" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,65 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d4d4d4"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#0c0c0c"/><text x="26" y="62" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your place.</text><text x="26" y="95.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your people.</text><text x="26" y="129.2" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#ffffff" opacity="0.5"/>
|
||||
<g transform="translate(290 60) scale(0.82)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#f4f4f4"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f4f4f4"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f4f4f4" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#0c0c0c" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#ffffff" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#ffffff" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#ffffff" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#111111" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#0c0c0c"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#ffffff" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#0c0c0c"/><text x="36" y="98" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Independent cafés</text><text x="36" y="114.8" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#ffffff" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">For independent places,</text><text x="22" y="101.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#0c0c0c"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">A cap can become a small expression</text><text x="22" y="113.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#0c0c0c"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#ffffff">acme hats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#ffffff" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#111111">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#111111">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#111111"/><rect x="108" y="192" width="80" height="6" fill="#0c0c0c"/><rect x="194" y="192" width="68" height="6" fill="#111111" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#111111"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#111111"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#111111" stroke="rgba(255,255,255,0.28)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#ffffff" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#0c0c0c"/><text x="300" y="100" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your place.</text><text x="300" y="116.8" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your people.</text><text x="300" y="133.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#0c0c0c"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#ffffff">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#ffffff">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#111111" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#0c0c0c"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#ffffff" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#ececec"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#111111"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#111111"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#111111" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#0c0c0c" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#0c0c0c"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#0c0c0c"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#ffffff">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#ffffff" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#111111" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,65 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d8d3c7"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#23372e"/><text x="26" y="62" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your place.</text><text x="26" y="95.6" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your people.</text><text x="26" y="129.2" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#f5efe2" opacity="0.5"/>
|
||||
<g transform="translate(290 60) scale(0.82)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#d9622b"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#d9622b"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#d9622b" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f5efe2" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#f5efe2" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#f5efe2" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#f5efe2" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#22302a" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#23372e"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#23372e"/><text x="36" y="98" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Independent cafés</text><text x="36" y="114.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#f5efe2" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">For independent places,</text><text x="22" y="101.68" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#23372e"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">A cap can become a small expression</text><text x="22" y="113.12" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#23372e"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">acmehats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#f5efe2" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#22302a">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#22302a">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#d9622b"/><rect x="108" y="192" width="80" height="6" fill="#23372e"/><rect x="194" y="192" width="68" height="6" fill="#22302a" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#23372e"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#23372e"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#23372e" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#fbf8f1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#23372e"/><text x="300" y="100" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your place.</text><text x="300" y="116.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your people.</text><text x="300" y="133.6" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#d9622b"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#fbf8f1"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#23372e">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#23372e">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#fff7ee" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#d9622b"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#fbf8f1"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#23372e" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#23372e"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#d9622b"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#d9622b"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#d9622b" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#fbf8f1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#fbf8f1"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#23372e"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#f5efe2">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#f5efe2" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#22302a" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900"><rect width="1600" height="900" fill="#f6f0e1"/><rect width="1600" height="84" fill="#1f4d3a"/><text x="60" y="56" font-family="Georgia, 'Times New Roman', serif" font-size="38" fill="#f3ecd9">acmehats</text>
|
||||
<rect x="1040" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1170" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1300" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1430" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/>
|
||||
<text x="80" y="250" font-family="Georgia, 'Times New Roman', serif" font-size="84" font-weight="400" fill="#1f3a2e">Caps that carry</text><text x="80" y="344.08000000000004" font-family="Georgia, 'Times New Roman', serif" font-size="84" font-weight="400" fill="#1f3a2e">your place.</text><rect x="84" y="400" width="520" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/><rect x="84" y="422" width="478.40000000000003" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/><rect x="84" y="444" width="322.4" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/>
|
||||
<rect x="84" y="500" width="260" height="70" rx="35" fill="#1f4d3a"/><text x="130" y="546" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="28" font-weight="700" fill="#f3ecd9">Design yours</text>
|
||||
<rect x="860" y="160" width="660" height="560" rx="24" fill="#e7dcc4"/><g transform="translate(930 250) scale(2.9)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f4d3a"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f4d3a"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f4d3a" stroke="rgba(255,255,255,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6f0e1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="84" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="130" cy="805" r="22" fill="#1f4d3a"/><rect x="166" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/><rect x="334" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="380" cy="805" r="22" fill="#1f44c8"/><rect x="416" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/><rect x="584" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="630" cy="805" r="22" fill="#c8553d"/><rect x="666" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,65 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { MetaDot } from "./MetaLine";
|
||||
import { StateDot } from "./StateMarker";
|
||||
|
||||
/**
|
||||
* The mono count label, one grammar on every surface: a total in foreground,
|
||||
* then any breakdown, then what is waiting — each waiting count behind its
|
||||
* state dot, which is its separator. The caller sets the mono recipe
|
||||
* (META_TEXT) and the muted colour for the fill it sits on.
|
||||
*
|
||||
* thread 5 attachments · 1 image · 4 files ● 5 to review
|
||||
* panel 21 artifacts ● 9 to review
|
||||
* gallery 6 stacks · 43 artifacts ● 12 to review ● 1 change requested
|
||||
*
|
||||
* Every item is unbreakable and the label wraps between items, so in a narrow
|
||||
* tile or pane it takes a second line instead of running out of its slab.
|
||||
*/
|
||||
|
||||
const reviewCount = (items: LabArtifact[]) => items.filter((a) => a.review === "needs_review").length;
|
||||
const changesCount = (items: LabArtifact[]) => items.filter((a) => a.review === "changes_requested").length;
|
||||
|
||||
export function Counts({
|
||||
lead,
|
||||
parts = [],
|
||||
of,
|
||||
leadClassName = "text-foreground",
|
||||
}: {
|
||||
/** "21 artifacts" */
|
||||
lead: string;
|
||||
/** Plain breakdown after the lead: "1 image", "4 files". */
|
||||
parts?: string[];
|
||||
/** The artifacts counted: what waits among them is appended behind its state dot. */
|
||||
of?: LabArtifact[];
|
||||
leadClassName?: string;
|
||||
}) {
|
||||
const review = of ? reviewCount(of) : 0;
|
||||
const changes = of ? changesCount(of) : 0;
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-2.5">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={leadClassName}>{lead}</span>
|
||||
{parts.map((part) => (
|
||||
<Fragment key={part}>
|
||||
<MetaDot />
|
||||
{part}
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
{review > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<StateDot state="needs_review" />
|
||||
{review} to review
|
||||
</span>
|
||||
) : null}
|
||||
{changes > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<StateDot state="changes_requested" />
|
||||
{changes} {changes === 1 ? "change" : "changes"} requested
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { KindTag } from "./KindBlock";
|
||||
|
||||
/**
|
||||
* The preview for kinds whose preview is TEXT — task documents, Markdown, code
|
||||
* and sheets — at panel and gallery scale: the artifact's own words set as
|
||||
* real text on a SHEET, the lightest fill short of ink in both themes (paper
|
||||
* on a mat), so content is the filled thing and never a recess.
|
||||
*
|
||||
* prose sans; the opening line and every heading in foreground medium,
|
||||
* body in the sheet's text tone
|
||||
* code the mono token, whitespace kept, a long line ends in an ellipsis
|
||||
* sheet a table in the mono token, header row in foreground; whole columns
|
||||
* only, as many as the sheet is wide enough for, the rest counted
|
||||
* ("+2 cols")
|
||||
*
|
||||
* Three shapes:
|
||||
* plate (default) a gallery preview: the kind tab in the bottom-left
|
||||
* corner, prose at 13px, as many WHOLE lines as the plate's height
|
||||
* has room for above the tab (--solid-sheet-fit) — at any column
|
||||
* width, never a fixed count.
|
||||
* cell a small cell of a stack mosaic: the same at 11px, 8px inset.
|
||||
* drawer a panel card's preview: no tab (the card's kind block is right
|
||||
* above it), height set by `lines`, 12px inset like the slab.
|
||||
*
|
||||
* The thread never uses this: at 72×44 a text kind is an ink plate.
|
||||
* No fade at the bottom edge (no gradients in this direction).
|
||||
*/
|
||||
|
||||
const CLAMP = { 3: "line-clamp-3", 6: "line-clamp-6" } as const;
|
||||
|
||||
/** Whether an artifact's preview is its text (a picture always wins). */
|
||||
export const hasExcerpt = (a: LabArtifact) => Boolean(a.excerpt) && !a.thumbnailUrl;
|
||||
|
||||
const words = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const paragraphs = (a: LabArtifact) => (a.body ?? a.excerpt ?? "").trim().split(/\n{2,}/);
|
||||
const HEADING = /^#{1,6}\s+/;
|
||||
|
||||
/** Markdown to the text a sheet sets: bullets kept as bullets, link and emphasis syntax dropped. */
|
||||
const inline = (markdown: string) =>
|
||||
markdown
|
||||
.replace(/^\s*[-*]\s+/gm, "• ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_`]/g, "");
|
||||
|
||||
/** Whether the text opens with the artifact's own title, which the surface already prints beside the sheet. */
|
||||
const opensWithTitle = (a: LabArtifact) => words(paragraphs(a)[0].replace(HEADING, "")) === words(a.title);
|
||||
|
||||
interface Run {
|
||||
text: string;
|
||||
strong: boolean;
|
||||
/** What separates it from the run before: a new line, or a space within the same paragraph. */
|
||||
lead: "\n" | " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* The prose as runs. The repeated title is left out; headings are strong; and
|
||||
* the sheet always opens on a strong line — a short opening paragraph whole,
|
||||
* a long one by its first sentence.
|
||||
*/
|
||||
function runs(a: LabArtifact): Run[] {
|
||||
const all = paragraphs(a);
|
||||
const blocks = (opensWithTitle(a) && all.length > 1 ? all.slice(1) : all).slice(0, 10);
|
||||
const out: Run[] = blocks.map((block) => ({ text: inline(block.replace(HEADING, "")), strong: HEADING.test(block), lead: "\n" }));
|
||||
const first = out[0];
|
||||
if (!first || first.strong) return out;
|
||||
const sentence = first.text.length > 90 ? /^(.+?[.!?])\s+([\s\S]+)$/.exec(first.text) : null;
|
||||
if (!sentence) return [{ ...first, strong: true }, ...out.slice(1)];
|
||||
return [{ text: sentence[1], strong: true, lead: "\n" }, { text: sentence[2], strong: false, lead: " " }, ...out.slice(1)];
|
||||
}
|
||||
|
||||
const SHEET_TEXT = "text-(--solid-sheet-text)";
|
||||
/**
|
||||
* Code and tables: 11px on 16px in a drawer or a mosaic cell; a size up on a
|
||||
* tile's plate, which is read from further away (as prose is) — from 16rem of
|
||||
* sheet, so the large cell of a mosaic keeps the smaller size and its columns.
|
||||
*/
|
||||
const MONO = "font-mono text-(length:--text-micro) leading-4";
|
||||
const MONO_PLATE = "font-mono text-(length:--text-micro) leading-4 @3xs:text-xs @3xs:leading-5";
|
||||
|
||||
/**
|
||||
* Columns are shown whole or not at all. How many is decided by the width of
|
||||
* the sheet itself (its own container, inside its inset), so a table never
|
||||
* runs out of its sheet: two, a third from 18rem, a fourth from 24rem, a fifth
|
||||
* from 32rem. A mosaic cell shows the first column only. What is left out is
|
||||
* counted in the header row.
|
||||
*/
|
||||
const COLUMN = ["", "", "hidden @2xs:table-cell", "hidden @sm:table-cell", "hidden @lg:table-cell"];
|
||||
const SHOWN = [
|
||||
{ columns: 2, when: "@2xs:hidden" },
|
||||
{ columns: 3, when: "hidden @2xs:table-cell @sm:hidden" },
|
||||
{ columns: 4, when: "hidden @sm:table-cell @lg:hidden" },
|
||||
{ columns: 5, when: "hidden @lg:table-cell" },
|
||||
];
|
||||
|
||||
function Table({ csv, rows, single, mono, className }: { csv: string; rows: number; single: boolean; mono: string; className?: string }) {
|
||||
const [head, ...body] = csv
|
||||
.trim()
|
||||
.split("\n")
|
||||
.slice(0, rows)
|
||||
.map((line) => line.split(",").slice(0, single ? 1 : COLUMN.length));
|
||||
const total = csv.trim().split("\n")[0].split(",").length;
|
||||
const notes = single ? [{ columns: 1, when: "" }] : SHOWN;
|
||||
return (
|
||||
<div className={cn(mono, SHEET_TEXT, "overflow-hidden", className)}>
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
{head.map((cell, index) => (
|
||||
<th key={index} scope="col" className={cn("truncate pr-2 text-left font-medium text-foreground", COLUMN[index])}>
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
{notes.map((note) =>
|
||||
total > note.columns ? (
|
||||
<th key={note.columns} className={cn("w-7 text-right font-normal whitespace-nowrap @2xs:w-14", note.when)}>
|
||||
+{total - note.columns}
|
||||
<span className="hidden @2xs:inline"> {total - note.columns === 1 ? "col" : "cols"}</span>
|
||||
</th>
|
||||
) : null,
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{row.map((cell, column) => (
|
||||
<td key={column} className={cn("truncate pr-2", COLUMN[column])}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExcerptSheet({
|
||||
artifact,
|
||||
shape = "plate",
|
||||
lines = 3,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
shape?: "plate" | "cell" | "drawer";
|
||||
/** A drawer's depth in whole lines of prose (code and tables get one more row: their lines are shorter). */
|
||||
lines?: keyof typeof CLAMP;
|
||||
/** Sizing. A plate or a cell fills what it is given; a drawer is as tall as its lines. */
|
||||
className?: string;
|
||||
}) {
|
||||
const text = artifact.excerpt ?? "";
|
||||
const drawer = shape === "drawer";
|
||||
const cell = shape === "cell";
|
||||
const fit = drawer ? null : "max-h-(--solid-sheet-fit) overflow-hidden";
|
||||
// A drawer shows a set number of rows; a fitted sheet is given more than it can hold and keeps whole ones.
|
||||
const rows = drawer ? lines + 1 : 14;
|
||||
const mono = shape === "plate" ? MONO_PLATE : MONO;
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-solid="excerpt-sheet"
|
||||
data-solid-sheet={drawer ? undefined : "fit"}
|
||||
// A drawer is a container too (by width only), so its table can count its columns the same way.
|
||||
className={cn("relative overflow-hidden rounded-md bg-(--solid-sheet)", drawer ? "@container px-3 py-2.5" : cell ? "p-2" : "p-4", className ?? "w-full")}
|
||||
>
|
||||
{artifact.kind === "sheet" ? (
|
||||
<Table csv={text} rows={rows} single={cell} mono={mono} className={fit ?? undefined} />
|
||||
) : artifact.kind === "code" ? (
|
||||
<div className={cn(mono, SHEET_TEXT, fit)}>
|
||||
{text
|
||||
.split("\n")
|
||||
.slice(0, rows)
|
||||
.map((line, index) => (
|
||||
<span key={index} className="block overflow-hidden text-ellipsis whitespace-pre">
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// The size comes first: tailwind-merge drops a `leading-*` that precedes a font size.
|
||||
<div
|
||||
className={cn(
|
||||
drawer ? "text-xs leading-5" : cell ? "text-(length:--text-micro) leading-4" : "text-(length:--text-compact) leading-5",
|
||||
"wrap-break-word whitespace-pre-line",
|
||||
SHEET_TEXT,
|
||||
drawer ? CLAMP[lines] : fit,
|
||||
)}
|
||||
>
|
||||
{runs(artifact).map((run, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? run.lead : null}
|
||||
{run.strong ? <span className="font-medium text-foreground">{run.text}</span> : run.text}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{drawer ? null : <KindTag artifact={artifact} size={cell ? "sm" : "md"} compact={cell} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { Counts } from "./Counts";
|
||||
import { daySections, type DaySection } from "./gallery-model";
|
||||
import { GalleryTile } from "./GalleryTile";
|
||||
import { plural } from "./kinds";
|
||||
import { META_TEXT } from "./MetaLine";
|
||||
import { StackTile } from "./StackTile";
|
||||
|
||||
/**
|
||||
* GALLERY — the company Artifacts page, below its toolbar.
|
||||
*
|
||||
* The product's grid (1 / 2 / 3 columns at <768 / 768 / 1280px, 24px gutters
|
||||
* both ways, inside the page's 1152px column), with the direction's mono
|
||||
* count label above it — the line that heads the thread's attachments and the
|
||||
* panel's shelf.
|
||||
*
|
||||
* stacks 6 stacks · 43 artifacts ● 12 to review ● 1 change requested
|
||||
* [stack] [stack] [stack] …
|
||||
*
|
||||
* stack 21 artifacts ● 9 to review
|
||||
*
|
||||
* Monday, September 28 ← a day: the page's one large line
|
||||
* 17 artifacts ● 9 to review
|
||||
* [tile] [tile] [tile] …
|
||||
*
|
||||
* Saturday, September 26
|
||||
* 2 artifacts
|
||||
* [tile] [tile]
|
||||
*
|
||||
* (`?days=off`: the same tiles in the product's flat grid, without the days.)
|
||||
*/
|
||||
|
||||
const GRID = "grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3";
|
||||
const COUNT_LINE = cn(META_TEXT, "px-1 text-muted-foreground");
|
||||
|
||||
export function GalleryStacks({ groups }: { groups: LabGroup[] }) {
|
||||
if (groups.length === 0) return <p className="px-1 py-16 text-center text-sm font-medium text-foreground">No artifact stacks yet.</p>;
|
||||
const all = groups.flatMap((group) => group.artifacts);
|
||||
const total = groups.reduce((sum, group) => sum + group.count, 0);
|
||||
return (
|
||||
<div data-solid="gallery-stacks" className="flex flex-col gap-2">
|
||||
<p className={COUNT_LINE}>
|
||||
<Counts lead={plural(groups.length, "stack")} parts={[plural(total, "artifact")]} of={all} />
|
||||
</p>
|
||||
{/* Each tile keeps 28px above its face for the pile; the 24px row gap leaves a risen pile clear of the row above. */}
|
||||
<ul className={GRID}>
|
||||
{groups.map((group) => (
|
||||
<li key={group.id} className="min-w-0">
|
||||
<StackTile group={group} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Day({ id, section }: { id: string; section: DaySection }) {
|
||||
return (
|
||||
<section data-solid="gallery-day" aria-labelledby={id} className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-0.5 px-1">
|
||||
<h2 id={id} className="text-lg font-semibold text-foreground">
|
||||
{section.label}
|
||||
</h2>
|
||||
<p className={cn(META_TEXT, "text-muted-foreground")}>
|
||||
<Counts lead={plural(section.items.length, "artifact")} of={section.items} />
|
||||
</p>
|
||||
</header>
|
||||
<ul className={GRID}>
|
||||
{section.items.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<GalleryTile artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lab's one switch. Sectioning an opened stack by day is a change of
|
||||
* structure riding on a visual direction; `?days=off` lists the stack as the
|
||||
* product does today — one flat grid in the page's order — so the tiles can be
|
||||
* judged like for like.
|
||||
*/
|
||||
const flat = () => typeof window !== "undefined" && new URLSearchParams(window.location.search).get("days") === "off";
|
||||
|
||||
export function GalleryStack({ group }: { group: LabGroup }) {
|
||||
if (group.artifacts.length === 0) {
|
||||
return <p className="px-1 py-16 text-center text-sm font-medium text-foreground">No artifacts in this stack match the current filters.</p>;
|
||||
}
|
||||
return (
|
||||
<div data-solid="gallery-stack" className="flex flex-col gap-6">
|
||||
<p className={COUNT_LINE}>
|
||||
<Counts lead={plural(group.count, "artifact")} of={group.artifacts} />
|
||||
</p>
|
||||
{flat() ? (
|
||||
<ul className={GRID}>
|
||||
{group.artifacts.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<GalleryTile artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex flex-col gap-10">
|
||||
{daySections(group.artifacts).map((section) => (
|
||||
<Day key={section.id} id={`solid-gallery-day-${group.id}-${section.id}`} section={section} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { ExcerptSheet } from "./ExcerptSheet";
|
||||
import { previewKind } from "./gallery-model";
|
||||
import { extent, fileName, metaLabel } from "./kinds";
|
||||
import { CornerTag, KindDock, KindTag, PlatePicture } from "./KindBlock";
|
||||
import { META_TEXT } from "./MetaLine";
|
||||
import { StateMarker } from "./StateMarker";
|
||||
|
||||
/**
|
||||
* GALLERY — the preview of one artifact. Every kind gets a preview that says
|
||||
* something, and what fills it is the artifact's CONTENT, never its extension:
|
||||
*
|
||||
* picture the picture, full-bleed ink kind tab bottom-left
|
||||
* text its own words on a sheet (ExcerptSheet) ink kind tab bottom-left
|
||||
* facts a sheet that says what is known of a file the kind block docked bottom-left:
|
||||
* with nothing to show: how much of it there the tab grown to a block, because
|
||||
* is, at display size, and its filename here the kind is most of the story
|
||||
*
|
||||
* So ink is always the mark in the bottom-left corner and never the whole
|
||||
* plate; a row of previews keeps one baseline of ink, and three decks in a row
|
||||
* read as three decks. The other corner is the state: a bite the slab takes
|
||||
* out of the bottom-right (StateMarker). A picture that fails to load becomes
|
||||
* the facts sheet and says "No preview".
|
||||
*
|
||||
* `plate` is a tile's preview (18:11 at the width of its container) and the
|
||||
* large cell of a stack mosaic; `cell` is a mosaic's small cell.
|
||||
*/
|
||||
|
||||
function Facts({ artifact, cell, note }: { artifact: LabArtifact; cell: boolean; note?: string }) {
|
||||
const fact = note ?? extent(artifact) ?? artifact.sizeLabel ?? metaLabel(artifact);
|
||||
if (cell) {
|
||||
return (
|
||||
<div aria-hidden className="relative size-full overflow-hidden rounded-md bg-(--solid-sheet) p-2">
|
||||
<p className={cn(META_TEXT, "truncate font-medium text-foreground")}>{fact}</p>
|
||||
<KindDock artifact={artifact} size="sm" compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div aria-hidden className="relative size-full overflow-hidden rounded-md bg-(--solid-sheet) p-3 @2xs/unit:p-4">
|
||||
<p className="truncate text-xl font-medium tracking-tight text-foreground @2xs/unit:text-3xl">{fact}</p>
|
||||
{/* One line where the dock leaves room for one; two from 20rem of tile. */}
|
||||
<p className="mt-0.5 truncate text-sm font-medium text-(--solid-sheet-text) @xs/unit:line-clamp-2 @xs/unit:wrap-anywhere @xs/unit:whitespace-normal">
|
||||
{fileName(artifact)}
|
||||
</p>
|
||||
<KindDock artifact={artifact} size="plate" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryPreview({
|
||||
artifact,
|
||||
scale = "plate",
|
||||
state = true,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
scale?: "plate" | "cell";
|
||||
/** Show the state bite. Off inside a stack mosaic, where the stack's count line carries state. */
|
||||
state?: boolean;
|
||||
/** Sizing; the plate's 18:11 at the width of its container by default. */
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const kind = failed ? "facts" : previewKind(artifact);
|
||||
const cell = scale === "cell";
|
||||
return (
|
||||
<div
|
||||
data-solid="gallery-preview"
|
||||
data-preview={kind}
|
||||
className={cn("relative overflow-hidden rounded-md", className ?? "aspect-(--solid-plate-ratio) w-full")}
|
||||
>
|
||||
{kind === "text" ? (
|
||||
<ExcerptSheet artifact={artifact} shape={cell ? "cell" : "plate"} className="size-full" />
|
||||
) : kind === "picture" ? (
|
||||
<span aria-hidden className="absolute inset-0 bg-(--solid-sheet)">
|
||||
<PlatePicture artifact={artifact} onError={() => setFailed(true)} />
|
||||
{/* In a mosaic the tab is on the large cell only: three tabs on three photographs spend the signature where it says least. */}
|
||||
{cell ? (
|
||||
artifact.kind === "video" ? (
|
||||
<CornerTag size="sm">
|
||||
<Play className="size-2.5" fill="currentColor" />
|
||||
</CornerTag>
|
||||
) : null
|
||||
) : (
|
||||
<KindTag artifact={artifact} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Facts artifact={artifact} cell={cell} note={failed ? "No preview" : undefined} />
|
||||
)}
|
||||
{state ? <StateMarker review={artifact.review} on="corner" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { actionName, externalProps, type SolidAction } from "./actions";
|
||||
import { galleryActions } from "./gallery-model";
|
||||
import { GalleryPreview } from "./GalleryPreview";
|
||||
import { META_TEXT, MetaDot, MetaLine } from "./MetaLine";
|
||||
import { shortStamp } from "./panel-model";
|
||||
import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, SLAB, SLAB_HOVER } from "./slab";
|
||||
|
||||
/**
|
||||
* GALLERY — one artifact in an opened stack (or the ungrouped grid): a slab
|
||||
* tile, preview first.
|
||||
*
|
||||
* ┌ preview, 18:11 ───────────────────────────────┐
|
||||
* │ picture · words on a sheet · a file's facts │
|
||||
* │ [KIND] ● Review │ ink bottom-left, state bite bottom-right
|
||||
* └────────────────────────────────────────────────┘
|
||||
* Title, the full width of the tile, up to two lines
|
||||
* kind · size · rev · extent (mono)
|
||||
* Sep 28, 11:07 AM · Author [⧉] [↓] [→] (stamp mono, author sans)
|
||||
*
|
||||
* The thread row's grammar turned on its end: block, title, mono meta, and
|
||||
* the actions at the far end of the foot — where the thread row and the panel
|
||||
* card end theirs. The whole tile is the [→] action's target (the owning
|
||||
* task, at this artifact), which turns to ink when the tile is hovered. Open
|
||||
* file in new tab and Download file are ghosts beside it, always visible, so
|
||||
* they are reachable by touch and by keyboard and sit in no other link; each
|
||||
* has its own glyph (a box with an arrow leaves the app, a tray downloads,
|
||||
* the arrow goes to the task). In a tile under 18rem the actions take a row of
|
||||
* their own.
|
||||
*/
|
||||
|
||||
function Ghost({ action, artifact }: { action: SolidAction; artifact: LabArtifact }) {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<a data-secondary href={action.href} {...externalProps(action)} aria-label={actionName(action, artifact)} title={action.label} className={ICON_BUTTON}>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryTile({ artifact }: { artifact: LabArtifact }) {
|
||||
const { primary, secondary } = galleryActions(artifact);
|
||||
const PrimaryIcon = primary.icon;
|
||||
return (
|
||||
<article
|
||||
data-solid="gallery-tile"
|
||||
data-kind={artifact.kind}
|
||||
className={cn("group/slab @container/unit relative flex h-full min-w-0 flex-col gap-2.5 p-3", SLAB, SLAB_HOVER)}
|
||||
>
|
||||
<GalleryPreview artifact={artifact} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground">{artifact.title}</h3>
|
||||
<footer className="mt-auto flex flex-wrap items-end justify-end gap-x-2 gap-y-1.5">
|
||||
<p className="flex min-w-0 grow basis-40 flex-col">
|
||||
<MetaLine artifact={artifact} className="truncate" />
|
||||
<span className={cn(META_TEXT, "flex min-w-0 text-(--solid-meta)")}>
|
||||
<time dateTime={artifact.updatedAt} className="shrink-0">
|
||||
<span className="sr-only">Last edited </span>
|
||||
{shortStamp(artifact.updatedLabel)}
|
||||
</time>
|
||||
<MetaDot />
|
||||
<span className="min-w-0 truncate font-sans">{artifact.author}</span>
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* First in the DOM so it is first in the tab order; last in the row. */}
|
||||
<a
|
||||
data-primary
|
||||
href={primary.href}
|
||||
aria-label={actionName(primary, artifact)}
|
||||
title={[actionName(primary, artifact), artifact.summary, `Last edited ${artifact.updatedLabel} · ${artifact.author}`].filter(Boolean).join("\n")}
|
||||
className={cn(BUTTON, BUTTON_PRIMARY, COVER, "order-last w-7 cursor-pointer px-0 after:cursor-pointer")}
|
||||
>
|
||||
<PrimaryIcon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
{secondary.map((action) => (
|
||||
<Ghost key={action.label} action={action} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { blockLabel, hasThumbnail, kindGlyph } from "./kinds";
|
||||
import { SLAB_RING } from "./slab";
|
||||
|
||||
/**
|
||||
* SIGNATURE 1 — the kind block: the one inverted (INK) fill in the direction.
|
||||
*
|
||||
* One landscape plate (18:11) with the kind's mono-caps label in its
|
||||
* bottom-left corner and the kind's glyph in the opposite, top-right corner,
|
||||
* so both corners of the plate are used and kinds differ by shape as well as
|
||||
* by letters. It comes in three forms:
|
||||
*
|
||||
* KindBlock free-standing, the anchor of a row or a card:
|
||||
* xs 56×36 attachments (label only) sm 72×44 thread row, panel card
|
||||
* lg 128×96 a picture in the attachments block (4:3: a picture's plate, beside a narrow column of text)
|
||||
* Where the artifact has a picture, the picture fills the plate
|
||||
* and nothing is laid over it (a video keeps a play mark); a
|
||||
* picture that fails to load falls back to the ink plate, so a
|
||||
* broken thumbnail is never an empty box.
|
||||
* KindTag the label alone, on an ink tab flush in the bottom-left corner
|
||||
* of a preview that has content of its own (a picture, a sheet).
|
||||
* KindDock the block docked in that same corner for a preview that has
|
||||
* none — the tab grown to a block: 72×44 in a mosaic cell, 131×80
|
||||
* on a tile's plate (72×44 while the tile is under 18rem).
|
||||
*
|
||||
* Tabs and docks size themselves against the gallery unit they sit in (an
|
||||
* `@container/unit`: a tile, or a stack's face), never against the viewport.
|
||||
*/
|
||||
|
||||
export type KindBlockSize = "xs" | "sm" | "lg";
|
||||
type FaceSize = KindBlockSize | "plate";
|
||||
|
||||
const BOX: Record<FaceSize, string> = {
|
||||
xs: "h-9 w-14",
|
||||
sm: "h-11 w-18",
|
||||
lg: "h-24 w-32",
|
||||
plate: "h-11 w-18 @2xs/unit:h-20 @2xs/unit:w-auto @2xs/unit:aspect-(--solid-plate-ratio)",
|
||||
};
|
||||
const PAD: Record<FaceSize, string> = { xs: "p-1.5", sm: "px-2 py-1.5", lg: "p-3", plate: "px-2 py-1.5 @2xs/unit:p-3" };
|
||||
/** The glyph sits in the top-right corner, at the plate's own inset. xs has no room for one. */
|
||||
const GLYPH: Record<FaceSize, string | null> = {
|
||||
xs: null,
|
||||
sm: "top-1.5 right-2 size-5",
|
||||
lg: "top-3 right-3 size-7",
|
||||
plate: "top-1.5 right-2 size-5 @2xs/unit:top-3 @2xs/unit:right-3 @2xs/unit:size-7",
|
||||
};
|
||||
const LABEL: Record<FaceSize, string> = { xs: "text-xs", sm: "text-xs", lg: "text-lg", plate: "text-xs @2xs/unit:text-2xl" };
|
||||
|
||||
/** Mono caps. Always followed by a size and then `leading-none`: tailwind-merge drops a leading that precedes a font size. */
|
||||
const MONO_CAPS = "font-mono font-semibold uppercase tracking-(--tracking-label)";
|
||||
|
||||
const INK = "bg-(--solid-ink) text-(--solid-ink-foreground)";
|
||||
/** Ink laid over a preview is ringed with the slab fill (it follows the slab's steps), so it reads on a picture of any value. */
|
||||
const RING = SLAB_RING;
|
||||
|
||||
function InkFace({ artifact, size }: { artifact: LabArtifact; size: FaceSize }) {
|
||||
const Glyph = kindGlyph(artifact);
|
||||
return (
|
||||
<>
|
||||
{GLYPH[size] ? <Glyph className={cn("absolute", GLYPH[size])} /> : null}
|
||||
<span className={cn(MONO_CAPS, LABEL[size], "leading-none")}>{blockLabel(artifact)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A picture filling its (relative, overflow-hidden) parent. A page starts at its top-left; a photograph is about its centre. */
|
||||
export function PlatePicture({ artifact, onError }: { artifact: LabArtifact; onError: () => void }) {
|
||||
return (
|
||||
<img
|
||||
src={artifact.thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={onError}
|
||||
className={cn("absolute inset-0 size-full object-cover", artifact.kind === "link" ? "object-left-top" : null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG: Record<"sm" | "md", string> = { sm: "px-1.5 pb-1.5 pt-1", md: "px-2 pb-2 pt-1.5" };
|
||||
const TAG_TEXT: Record<"sm" | "md", string> = { sm: "text-(length:--text-nano)", md: "text-(length:--text-micro)" };
|
||||
|
||||
/** An ink tab in the bottom-left corner of a preview. The parent must be `relative overflow-hidden`. */
|
||||
export function CornerTag({ size = "md", className, children }: { size?: "sm" | "md"; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("absolute bottom-0 left-0 flex items-center gap-1 rounded-tr-md", INK, RING, TAG[size], MONO_CAPS, TAG_TEXT[size], "leading-none", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's label on a corner tab; a video's carries a play mark.
|
||||
* `compact`: in a mosaic cell of a stack face narrower than 16rem, where there
|
||||
* is no room for letters, the tab shows the kind's glyph instead.
|
||||
*/
|
||||
export function KindTag({ artifact, size = "md", compact = false }: { artifact: LabArtifact; size?: "sm" | "md"; compact?: boolean }) {
|
||||
const Glyph = kindGlyph(artifact);
|
||||
return (
|
||||
<CornerTag size={size}>
|
||||
{artifact.kind === "video" ? <Play className="size-2" fill="currentColor" /> : null}
|
||||
{compact ? <Glyph className="size-3 @3xs/unit:hidden" /> : null}
|
||||
<span className={compact ? "hidden @3xs/unit:inline" : undefined}>{blockLabel(artifact)}</span>
|
||||
</CornerTag>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind block docked in the bottom-left corner of a sheet that has no
|
||||
* content of its own to show. `compact` as for the tab: a glyph-only square
|
||||
* where a mosaic cell has no room for the block.
|
||||
*/
|
||||
export function KindDock({ artifact, size = "plate", compact = false }: { artifact: LabArtifact; size?: "sm" | "plate"; compact?: boolean }) {
|
||||
const Glyph = kindGlyph(artifact);
|
||||
const dock = cn("absolute bottom-0 left-0 rounded-tr-md", INK, RING);
|
||||
return (
|
||||
<>
|
||||
{compact ? (
|
||||
<span data-kind-block="dock" className={cn(dock, "grid size-7 place-items-center @3xs/unit:hidden")}>
|
||||
<Glyph className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
<span data-kind-block="dock" className={cn(dock, "items-end", compact ? "hidden @3xs/unit:flex" : "flex", BOX[size], PAD[size])}>
|
||||
<InkFace artifact={artifact} size={size} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KindBlock({
|
||||
artifact,
|
||||
size = "sm",
|
||||
ink = false,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
size?: KindBlockSize;
|
||||
/** Always the ink plate, even when the artifact has a picture (a head that sits under the picture itself). */
|
||||
ink?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const box = cn("relative shrink-0 overflow-hidden rounded-md", BOX[size], className);
|
||||
|
||||
if (hasThumbnail(artifact) && !failed && !ink) {
|
||||
return (
|
||||
<span aria-hidden className={cn(box, "block bg-(--solid-sheet)")} data-kind-block="picture">
|
||||
<PlatePicture artifact={artifact} onError={() => setFailed(true)} />
|
||||
{/* No kind tab on a picture this small: the meta line beside it says what it is. A video keeps its play mark. */}
|
||||
{artifact.kind === "video" ? (
|
||||
<CornerTag size="sm">
|
||||
<Play className="size-2.5" fill="currentColor" />
|
||||
</CornerTag>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-hidden className={cn(box, "flex items-end", INK, PAD[size])} data-kind-block="ink">
|
||||
<InkFace artifact={artifact} size={size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { metaParts } from "./kinds";
|
||||
|
||||
/**
|
||||
* SIGNATURE 2 — the mono meta line: kind · size · rev · extent, in the
|
||||
* monospace token, in that order on every surface.
|
||||
*/
|
||||
|
||||
/** The mono recipe for every machine value in the set: meta lines, filenames, stamps, counts, identifiers. */
|
||||
export const META_TEXT = "font-mono text-(length:--text-micro) leading-4";
|
||||
|
||||
/** A set-width separator: a mono " · " is three cells wide and leaves the line gappy. */
|
||||
export function MetaDot() {
|
||||
return <span className="px-1">·</span>;
|
||||
}
|
||||
|
||||
/** A host and path may break after a slash; nothing else in a part may break. */
|
||||
function breakable(part: string) {
|
||||
if (!part.includes("/")) return part;
|
||||
const pieces = part.split("/");
|
||||
return pieces.map((piece, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <wbr /> : null}
|
||||
{index < pieces.length - 1 ? `${piece}/` : piece}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
export function MetaLine({
|
||||
artifact,
|
||||
max,
|
||||
wrap = false,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
/** Keep only the first `max` parts when the surface is short of room. */
|
||||
max?: number;
|
||||
/**
|
||||
* Let the line wrap (a panel card, a picture slab). It then breaks only
|
||||
* between parts — never inside "90.1 KB" — and after a slash in a path.
|
||||
* Off, the surface truncates it.
|
||||
*/
|
||||
wrap?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const parts = max ? metaParts(artifact).slice(0, max) : metaParts(artifact);
|
||||
return (
|
||||
<span className={cn(META_TEXT, "text-(--solid-meta)", className)}>
|
||||
{parts.map((part, index) =>
|
||||
wrap ? (
|
||||
// The separator travels with the part before it, so a wrapped line never starts with a dot.
|
||||
<Fragment key={part}>
|
||||
<span className={part.includes("/") ? "wrap-anywhere" : "whitespace-nowrap"}>
|
||||
{breakable(part)}
|
||||
{index < parts.length - 1 ? <MetaDot /> : null}
|
||||
</span>
|
||||
{index < parts.length - 1 ? <wbr /> : null}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment key={part}>
|
||||
{index > 0 ? <MetaDot /> : null}
|
||||
{part}
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { Counts } from "./Counts";
|
||||
import { plural } from "./kinds";
|
||||
import { META_TEXT } from "./MetaLine";
|
||||
import { PanelCard } from "./PanelCard";
|
||||
import { panelSections, shortStamp, type PanelSection } from "./panel-model";
|
||||
|
||||
/**
|
||||
* PANEL — the scroll body of the task's Artifacts tab (434px pane, 402px of
|
||||
* content).
|
||||
*
|
||||
* 21 artifacts ● 9 to review ← the shelf, counted
|
||||
*
|
||||
* Task documents ← a section: who or what
|
||||
* 7 documents
|
||||
* [card] [card] …
|
||||
*
|
||||
* Brand and Presentation Designer
|
||||
* 5 artifacts ● 5 to review Sep 28, 10:52 AM ← counts · run start (mono)
|
||||
* [card] [card] …
|
||||
*
|
||||
* Sections and their order are the product's — one per producing run, task
|
||||
* documents on their own, as listed (panel-model.ts). The mono count label is
|
||||
* the thread attachments label again: the count in foreground, the breakdown
|
||||
* after it, a state dot where a state is counted.
|
||||
*
|
||||
* One column in the docked pane. A maximized pane sets the cards in 22rem
|
||||
* columns, filled row by row so the listed order still reads across.
|
||||
*/
|
||||
|
||||
function Section({ section, children }: { section: PanelSection; children: ReactNode }) {
|
||||
const headingId = `solid-panel-section-${section.id}`;
|
||||
return (
|
||||
<section data-solid="panel-section" aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<header className="flex flex-col px-1">
|
||||
<h3 id={headingId} className="truncate text-xs font-medium text-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className={cn(META_TEXT, "flex flex-wrap items-baseline justify-between gap-x-3 text-muted-foreground")}>
|
||||
<Counts lead={plural(section.items.length, section.noun)} of={section.items} />
|
||||
{section.startedLabel ? (
|
||||
<time dateTime={section.startedAt} title={section.startedLabel} className="shrink-0">
|
||||
{shortStamp(section.startedLabel)}
|
||||
</time>
|
||||
) : null}
|
||||
</p>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
if (artifacts.length === 0) {
|
||||
return <p className="px-1 py-6 text-sm text-muted-foreground">No artifacts yet. Work products, documents, and agent-produced files will appear here.</p>;
|
||||
}
|
||||
return (
|
||||
<div data-solid="panel-body" className="flex flex-col gap-5">
|
||||
<p className={cn(META_TEXT, "px-1 text-muted-foreground")}>
|
||||
<Counts lead={plural(artifacts.length, "artifact")} of={artifacts} />
|
||||
</p>
|
||||
{panelSections(artifacts).map((section) => (
|
||||
<Section key={section.id} section={section}>
|
||||
<ul className="grid grid-cols-(--solid-panel-cols) items-start gap-2">
|
||||
{section.items.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<PanelCard artifact={artifact} section={section} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { actionName, externalProps } from "./actions";
|
||||
import { ExcerptSheet, hasExcerpt } from "./ExcerptSheet";
|
||||
import { hasThumbnail, isWaiting, plural } from "./kinds";
|
||||
import { KindBlock } from "./KindBlock";
|
||||
import { META_TEXT, MetaDot, MetaLine } from "./MetaLine";
|
||||
import { panelActions } from "./panel-actions";
|
||||
import { stampUnder, type PanelSection } from "./panel-model";
|
||||
import { PanelLead } from "./PanelLead";
|
||||
import { PanelMenu } from "./PanelMenu";
|
||||
import { StateMarker } from "./StateMarker";
|
||||
import { BUTTON, BUTTON_HOVER, COUNT_BUTTON, FOCUS, primaryRest, SLAB } from "./slab";
|
||||
|
||||
/**
|
||||
* PANEL — one artifact on the task's shelf: a thread row with its drawer open.
|
||||
*
|
||||
* [kind block 72×44] Title, wraps a picture leads its card instead:
|
||||
* Summary, wraps ┌ picture 16:9, kind tab ┐
|
||||
* kind · size · rev · extent └────────────────────────┘
|
||||
* filename.ext / content type Title …
|
||||
* ┌ sheet: the excerpt as text ───────────────────┐ (only for text kinds)
|
||||
* └────────────────────────────────────────────────┘
|
||||
* ● Review 11:07 AM [💬 0] [⋯] [Read document ⌄]
|
||||
*
|
||||
* The head is the thread row at the same size. The foot is ONE row with the
|
||||
* same three places on every card: the state (or nothing); who and when in
|
||||
* mono (the author only where the section does not name them; the time alone
|
||||
* where the section carries the day; it breaks between the two when short of
|
||||
* room, never inside either); and at the far end the comments count (Markdown
|
||||
* files), "More actions" (where there are any) and the primary — the thread's
|
||||
* 136px block, in ink when the artifact waits for review.
|
||||
*
|
||||
* This is the reading surface, so the card is NOT a click target: text can be
|
||||
* selected, tooltips show, and nothing downloads from a stray click. Only the
|
||||
* controls act.
|
||||
*
|
||||
* Nothing in the head is clamped: titles, summaries, filenames and content
|
||||
* types wrap. Under 20rem of card (a pane dragged narrow) the foot's two ends
|
||||
* stack.
|
||||
*/
|
||||
|
||||
/** Kinds whose card is the product's file card, which carries the content type. */
|
||||
const FILE_KINDS = new Set<LabArtifact["kind"]>(["pdf", "slides", "sheet", "archive", "code", "file"]);
|
||||
|
||||
export function PanelCard({ artifact, section }: { artifact: LabArtifact; section: PanelSection }) {
|
||||
const [pictureFailed, setPictureFailed] = useState(false);
|
||||
const { primary, more } = panelActions(artifact);
|
||||
const PrimaryIcon = primary.icon;
|
||||
const waiting = isWaiting(artifact);
|
||||
const leads = hasThumbnail(artifact) && !pictureFailed;
|
||||
const showAuthor = artifact.author !== section.agent;
|
||||
const mono = cn(META_TEXT, "wrap-anywhere text-(--solid-meta)");
|
||||
|
||||
const button = {
|
||||
"aria-label": actionName(primary, artifact),
|
||||
// First in the DOM so it is first in the tab order; last in the row.
|
||||
className: cn(BUTTON, primaryRest(waiting), BUTTON_HOVER, FOCUS, "order-last min-w-34 cursor-pointer justify-between px-2.5"),
|
||||
};
|
||||
const face = (
|
||||
<>
|
||||
{primary.label}
|
||||
<PrimaryIcon aria-hidden className="size-3.5" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article data-solid="panel-card" data-kind={artifact.kind} className={cn("flex min-w-0 flex-col gap-2.5 p-3", SLAB)}>
|
||||
{leads ? <PanelLead artifact={artifact} onPictureError={() => setPictureFailed(true)} /> : null}
|
||||
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{leads ? null : <KindBlock artifact={artifact} size="sm" ink />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-medium wrap-break-word text-foreground">{artifact.title}</h4>
|
||||
{artifact.summary ? <p className="mt-0.5 text-xs wrap-break-word text-(--solid-meta)">{artifact.summary}</p> : null}
|
||||
<p className="mt-1 flex flex-col">
|
||||
<MetaLine artifact={artifact} wrap />
|
||||
{artifact.filename ? <span className={mono}>{artifact.filename}</span> : null}
|
||||
{FILE_KINDS.has(artifact.kind) && artifact.contentType ? <span className={mono}>{artifact.contentType}</span> : null}
|
||||
{hasThumbnail(artifact) && pictureFailed ? <span className={mono}>No preview available</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What waits for review is given room to be read here; the rest shows enough to be told apart. */}
|
||||
{hasExcerpt(artifact) ? <ExcerptSheet artifact={artifact} shape="drawer" lines={waiting ? 6 : 3} /> : null}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{/* State, then who and when. The line breaks between the two when the row is short of room, never inside either. */}
|
||||
<div className="flex min-w-0 grow basis-28 flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<StateMarker review={artifact.review} />
|
||||
<p className={cn(META_TEXT, "min-w-0 text-(--solid-meta)")}>
|
||||
{showAuthor ? (
|
||||
<span className="inline-block max-w-full truncate align-bottom font-sans">
|
||||
{artifact.author}
|
||||
<MetaDot />
|
||||
</span>
|
||||
) : (
|
||||
<span className="sr-only">{artifact.author}, </span>
|
||||
)}
|
||||
<wbr />
|
||||
<time dateTime={artifact.updatedAt} title={artifact.updatedLabel} className="whitespace-nowrap">
|
||||
{stampUnder(artifact.updatedLabel, section.startedLabel)}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{primary.href ? (
|
||||
<a href={primary.href} {...externalProps(primary)} {...button}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" {...button}>
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
{artifact.commentCount != null ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${plural(artifact.commentCount, "comment")}: ${artifact.title}`}
|
||||
title={plural(artifact.commentCount, "comment")}
|
||||
className={cn(COUNT_BUTTON, artifact.commentCount > 0 ? "text-foreground" : null)}
|
||||
>
|
||||
<MessageSquare aria-hidden className="size-3.5" />
|
||||
{artifact.commentCount}
|
||||
</button>
|
||||
) : null}
|
||||
<PanelMenu actions={more} artifact={artifact} />
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useRef, useState } from "react";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { KindTag, PlatePicture } from "./KindBlock";
|
||||
|
||||
/**
|
||||
* PANEL — what leads a card whose artifact is a picture: the picture itself,
|
||||
* at 16:9 across the card, with the ink kind tab in its bottom-left corner —
|
||||
* the same rule as the thread and the gallery (the picture is the plate and
|
||||
* carries the tab), so nothing is represented twice.
|
||||
*
|
||||
* A video plays in place: its poster is the play button, and the product's
|
||||
* controls take over once it has started. A picture that fails to load reports
|
||||
* it, and the card falls back to the ink kind block.
|
||||
*/
|
||||
|
||||
const LEAD = "relative aspect-(--solid-lead-ratio) w-full overflow-hidden rounded-md bg-(--solid-sheet)";
|
||||
|
||||
function VideoLead({ artifact }: { artifact: LabArtifact }) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
return (
|
||||
<div data-solid="panel-lead" data-lead="video" className={LEAD}>
|
||||
<video
|
||||
ref={video}
|
||||
src={artifact.videoUrl}
|
||||
poster={artifact.thumbnailUrl}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
controls={started}
|
||||
aria-label={artifact.title}
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
{started ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Play video: ${artifact.title}`}
|
||||
title="Play video"
|
||||
onClick={() => {
|
||||
setStarted(true);
|
||||
void video.current?.play();
|
||||
}}
|
||||
className="absolute inset-0 cursor-pointer rounded-md focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--solid-ink)"
|
||||
>
|
||||
<KindTag artifact={artifact} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelLead({ artifact, onPictureError }: { artifact: LabArtifact; onPictureError: () => void }) {
|
||||
if (artifact.kind === "video" && artifact.videoUrl) return <VideoLead artifact={artifact} />;
|
||||
return (
|
||||
<div aria-hidden data-solid="panel-lead" data-lead="picture" className={LEAD}>
|
||||
<PlatePicture artifact={artifact} onError={onPictureError} />
|
||||
<KindTag artifact={artifact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { externalProps, type SolidAction } from "./actions";
|
||||
import { ICON_BUTTON } from "./slab";
|
||||
|
||||
/**
|
||||
* PANEL — a card's "More actions": every way out that is not the primary, in
|
||||
* one named control, so the foot is the same single row on every card. The
|
||||
* list is the product's own menu (it opens in a portal, outside the kit's
|
||||
* wrapper, in the app's popover chrome); each item is the action by name, not
|
||||
* an unlabelled icon.
|
||||
*/
|
||||
export function PanelMenu({ actions, artifact }: { actions: SolidAction[]; artifact: LabArtifact }) {
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-secondary aria-label={`More actions: ${artifact.title}`} title="More actions" className={ICON_BUTTON}>
|
||||
<Ellipsis aria-hidden className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const face = (
|
||||
<>
|
||||
<Icon aria-hidden />
|
||||
{action.label}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem key={action.label} asChild>
|
||||
{action.href ? (
|
||||
<a href={action.href} {...externalProps(action)}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" className="w-full">
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { Counts } from "./Counts";
|
||||
import { kindTally, pileDepth, stackPreviews } from "./gallery-model";
|
||||
import { GalleryPreview } from "./GalleryPreview";
|
||||
import { plural } from "./kinds";
|
||||
import { META_TEXT, MetaDot } from "./MetaLine";
|
||||
import { CORNER_NOTCH } from "./StateMarker";
|
||||
import { BUTTON, BUTTON_PRIMARY, COVER, SLAB, SLAB_HOVER } from "./slab";
|
||||
|
||||
/**
|
||||
* GALLERY — one task's stack.
|
||||
*
|
||||
* ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ pile: the slabs behind the face, each narrower
|
||||
* ▁ MD 7 · PDF 4 · PPTX 4 · PNG 3 ▁▁▁ and one step nearer the canvas; the nearer one
|
||||
* ┌──────────────────────┬─────────┐ says what is in the pile, on its edge
|
||||
* │ │ 12 slides│
|
||||
* │ picture ├─────────┤ mosaic: the newest three, each as its own preview;
|
||||
* │ [PNG] │ [ZIP] +18│ the one with most to show is large
|
||||
* └──────────────────────┴─────────┘
|
||||
* ACM-3 Create visual directions and first acmehats brand deck
|
||||
* 21 artifacts ● 9 to review [→]
|
||||
* Updated Sep 28, 2026
|
||||
*
|
||||
* The pile is made of flat fills only. Its depth says how much is in it: no
|
||||
* slab behind one artifact, one behind two or three, two behind more. A stack
|
||||
* of one is therefore an artifact tile with a task's name on it — one preview
|
||||
* at full size, a flat top — and cannot be mistaken for a pile.
|
||||
*
|
||||
* The whole object — face and pile — is the [→] action's target ("Open
|
||||
* stack"). Hover or keyboard focus steps the face, turns the action to ink,
|
||||
* and the pile rises 6px a slab (not under reduced motion).
|
||||
*
|
||||
* Pile geometry: 28px is kept above every face; the nearer slab shows 16px of
|
||||
* itself 12px in from each side, the farther one 12px more, 32px in.
|
||||
*/
|
||||
|
||||
const LAYER =
|
||||
"pointer-events-none absolute h-10 overflow-hidden rounded-md transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none";
|
||||
const RISE_1 = "motion-safe:group-hover/pile:-translate-y-1.5 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-1.5";
|
||||
const RISE_2 = "motion-safe:group-hover/pile:-translate-y-3 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-3";
|
||||
|
||||
function Pile({ group, depth }: { group: LabGroup; depth: 0 | 1 | 2 }) {
|
||||
if (depth === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{depth > 1 ? <span aria-hidden data-solid="pile-layer" className={cn(LAYER, RISE_2, "inset-x-8 top-0 bg-(--solid-pile-2)")} /> : null}
|
||||
<span aria-hidden data-solid="pile-layer" className={cn(LAYER, RISE_1, "inset-x-3 top-3 bg-(--solid-pile-1)")}>
|
||||
{/* The slab's visible edge is 16px: one line of mono caps, cut clean where the pile runs out of room. */}
|
||||
<span className="block h-4 truncate px-3 font-mono text-(length:--text-nano) leading-4 font-medium tracking-(--tracking-label) text-(--solid-meta) uppercase">
|
||||
{kindTally(group.artifacts).map((entry, index) => (
|
||||
<span key={entry}>
|
||||
{index > 0 ? <MetaDot /> : null}
|
||||
{entry}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The corner bite of the mosaic: how many artifacts it leaves out, in mono. */
|
||||
function MoreTab({ count }: { count: number }) {
|
||||
return (
|
||||
<span aria-hidden className={cn(META_TEXT, CORNER_NOTCH, "px-2 pt-1 pb-0.5 font-medium text-foreground")}>
|
||||
+{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Mosaic({ group }: { group: LabGroup }) {
|
||||
const [lead, ...rest] = stackPreviews(group);
|
||||
if (!lead) return null;
|
||||
if (rest.length === 0) return <GalleryPreview artifact={lead} state={false} />;
|
||||
const more = group.count - 1 - rest.length;
|
||||
return (
|
||||
<div data-solid="stack-mosaic" className="relative grid aspect-(--solid-plate-ratio) w-full grid-cols-3 grid-rows-2 gap-1 overflow-hidden rounded-md">
|
||||
<div className="col-span-2 row-span-2 min-h-0 min-w-0">
|
||||
<GalleryPreview artifact={lead} state={false} className="size-full" />
|
||||
</div>
|
||||
{rest.map((artifact) => (
|
||||
<div key={artifact.id} className={cn("min-h-0 min-w-0", rest.length === 1 ? "row-span-2" : null)}>
|
||||
<GalleryPreview artifact={artifact} scale="cell" state={false} className="size-full" />
|
||||
</div>
|
||||
))}
|
||||
{more > 0 ? <MoreTab count={more} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StackTile({ group }: { group: LabGroup }) {
|
||||
const depth = pileDepth(group.count);
|
||||
const count = plural(group.count, "artifact");
|
||||
return (
|
||||
// 28px above every face, so faces line up along a row whether or not a pile sits behind them.
|
||||
<article data-solid="stack-tile" data-count={group.count} data-pile={depth} className="group/pile relative flex h-full min-w-0 flex-col pt-7">
|
||||
<Pile group={group} depth={depth} />
|
||||
<div className={cn("group/slab @container/unit relative flex min-w-0 flex-1 flex-col gap-2.5 p-3", SLAB, SLAB_HOVER)}>
|
||||
<Mosaic group={group} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/* h2: on the stacks page a stack sits directly under the page title. */}
|
||||
<h2 className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground">
|
||||
<span className={cn(META_TEXT, "mr-2 font-normal text-(--solid-meta)")}>{group.taskIdentifier}</span>
|
||||
{group.title}
|
||||
</h2>
|
||||
<footer className="mt-auto flex items-end gap-2">
|
||||
{/* Always two lines — what is in it, then when — so the feet of a row line up; a long count wraps inside its own line. */}
|
||||
<p className={cn(META_TEXT, "flex min-w-0 flex-1 flex-col text-(--solid-meta)")}>
|
||||
<Counts lead={count} of={group.artifacts} />
|
||||
<span className="truncate">Updated {group.updatedLabel}</span>
|
||||
</p>
|
||||
<a
|
||||
data-primary
|
||||
href={`#stack-${group.taskIdentifier}`}
|
||||
aria-label={`Open stack ${group.taskIdentifier}: ${group.title}, ${count}`}
|
||||
title={`Open stack ${group.taskIdentifier}: ${group.title}\n${count} · Updated ${group.updatedLabel}`}
|
||||
// The cover reaches up over the pile, so the whole object is one target.
|
||||
className={cn(BUTTON, BUTTON_PRIMARY, COVER, "w-7 cursor-pointer px-0 after:-top-7 after:cursor-pointer")}
|
||||
>
|
||||
<ArrowRight aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { MOTION, SLAB_FILL, SLAB_JOINTS } from "./slab";
|
||||
|
||||
/**
|
||||
* The state marker: the only chroma in the direction, and only a 6px dot of
|
||||
* it, followed by the production label in foreground text.
|
||||
*
|
||||
* On a slab it has no fill of its own — dot and label sit straight on the
|
||||
* slab, so it can never be taken for the button next to it. On a gallery
|
||||
* preview it is written in a BITE the slab takes out of the preview's
|
||||
* bottom-right corner, the mirror of the ink kind tab in the bottom-left: what
|
||||
* it is on the left, what it needs on the right.
|
||||
*
|
||||
* Nothing renders for an artifact with no pending state, as in the product.
|
||||
*/
|
||||
|
||||
/** The one state vocabulary: label and dot fill per state. Counts.tsx counts in the same dots. */
|
||||
export const STATE = {
|
||||
needs_review: { label: "Review", dot: "bg-(--solid-state-review)" },
|
||||
changes_requested: { label: "Changes requested", dot: "bg-(--solid-state-changes)" },
|
||||
} as const;
|
||||
|
||||
export function StateDot({ state, className }: { state: keyof typeof STATE; className?: string }) {
|
||||
return <span aria-hidden className={cn("inline-block size-1.5 shrink-0 rounded-full", STATE[state].dot, className)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bite out of a preview's bottom-right corner, in the slab's own fill (it
|
||||
* follows the slab's hover). The state marker uses it in the gallery; so does
|
||||
* a stack mosaic's "+18". The parent must be `relative overflow-hidden`.
|
||||
*
|
||||
* It is cut, not laid on: where the bite meets the preview's right and bottom
|
||||
* edges, two 6px joints (its pseudo-elements) round the preview's new corners
|
||||
* to the set's one radius — the slab painted into the corner outside a
|
||||
* quarter circle (--solid-joint).
|
||||
*/
|
||||
export const CORNER_NOTCH = cn(
|
||||
"absolute right-0 bottom-0 rounded-tl-md",
|
||||
SLAB_FILL,
|
||||
MOTION,
|
||||
// The joint above the bite, on the preview's right edge; and the one beside it, on the bottom edge.
|
||||
"before:absolute before:right-0 before:bottom-full before:size-1.5 before:rounded-br-md before:shadow-(--solid-joint)",
|
||||
"after:absolute after:right-full after:bottom-0 after:size-1.5 after:rounded-br-md after:shadow-(--solid-joint)",
|
||||
SLAB_JOINTS,
|
||||
);
|
||||
|
||||
export function StateMarker({
|
||||
review,
|
||||
on = "slab",
|
||||
collapse = false,
|
||||
className,
|
||||
}: {
|
||||
review: LabArtifact["review"];
|
||||
/** `slab`: dot and label on the slab. `corner`: in a bite out of a preview's bottom-right corner. */
|
||||
on?: "slab" | "corner";
|
||||
/** Inside an `@container` narrower than 36rem, show only the dot (the label stays for screen readers). */
|
||||
collapse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!review) return null;
|
||||
return (
|
||||
<span
|
||||
data-state={review}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 text-(length:--text-micro) font-medium leading-none whitespace-nowrap text-foreground",
|
||||
on === "corner" ? cn(CORNER_NOTCH, "h-6 px-2") : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<StateDot state={review} />
|
||||
<span className={collapse ? "sr-only @xl:not-sr-only" : undefined}>{STATE[review].label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { Counts } from "./Counts";
|
||||
import { fileName, hasThumbnail, plural } from "./kinds";
|
||||
import { KindBlock } from "./KindBlock";
|
||||
import { META_TEXT, MetaLine } from "./MetaLine";
|
||||
import { STATE, StateMarker } from "./StateMarker";
|
||||
import { FOCUS, SLAB, SLAB_HOVER } from "./slab";
|
||||
|
||||
/**
|
||||
* THREAD — the attachments block under an agent message.
|
||||
*
|
||||
* Today this is two unrelated blocks (an "Images · 1" thumbnail grid and a
|
||||
* scrolling "Files · 4" strip of square-cornered tiles). Here it is one grid
|
||||
* under one mono label that keeps both counts and says what waits:
|
||||
*
|
||||
* 5 attachments · 1 image · 4 files ● 5 to review
|
||||
* ┌ picture 128×96 ┐ contact-sheet.png [PPTX] name.pptx [PDF] name.pdf
|
||||
* │ │ Image · 422.9 KB Slides · 90.1 KB ● Review …
|
||||
* │ │ [MD] deck-guide.md [ZIP] editing-source.zip
|
||||
* └────────────────┘ ● Review Markdown · 5.9 KB ● Review …
|
||||
*
|
||||
* A file is a 52px slab: kind block, filename, mono meta, state. Anything with
|
||||
* a picture takes two rows (112px) so the picture is shown at a size it can be
|
||||
* read at, with nothing laid over it. The grid wraps, so no file is ever
|
||||
* scrolled out of sight.
|
||||
*/
|
||||
|
||||
function AttachmentSlab({ artifact }: { artifact: LabArtifact }) {
|
||||
const name = fileName(artifact);
|
||||
const picture = hasThumbnail(artifact);
|
||||
const opensGallery = artifact.kind === "image" || artifact.kind === "video";
|
||||
const shared = {
|
||||
// The name replaces the slab's content for assistive tech, so the state is part of it.
|
||||
"aria-label": [`Open ${name}`, artifact.review ? STATE[artifact.review].label : null].filter(Boolean).join(", "),
|
||||
title: [name, artifact.title === name ? null : artifact.title].filter(Boolean).join(" — "),
|
||||
className: cn(
|
||||
"group/slab flex size-full min-w-0 cursor-pointer gap-2.5 p-2 pr-3 text-left focus-visible:bg-(--solid-slab-hover)",
|
||||
picture ? "items-stretch" : "items-center",
|
||||
SLAB,
|
||||
SLAB_HOVER,
|
||||
FOCUS,
|
||||
),
|
||||
};
|
||||
const face = picture ? (
|
||||
<>
|
||||
<KindBlock artifact={artifact} size="lg" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
||||
<span className="line-clamp-2 shrink-0 text-xs font-medium wrap-anywhere text-foreground">{name}</span>
|
||||
<MetaLine artifact={artifact} max={2} wrap className="line-clamp-2 shrink-0" />
|
||||
{/* The one place a state label may take two lines: the column beside a picture is narrow. */}
|
||||
<StateMarker review={artifact.review} className="mt-auto items-baseline leading-4 whitespace-normal" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KindBlock artifact={artifact} size="xs" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-foreground">{name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{/* A long state takes the size's place; the kind always stays. */}
|
||||
<MetaLine artifact={artifact} max={artifact.review === "changes_requested" ? 1 : 2} className="min-w-0 flex-1 truncate" />
|
||||
<StateMarker review={artifact.review} />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return opensGallery ? (
|
||||
<button type="button" {...shared}>
|
||||
{face}
|
||||
</button>
|
||||
) : (
|
||||
<a href={artifact.kind === "link" ? (artifact.url ?? "#") : `#attachment-${artifact.id}`} {...(artifact.kind === "link" ? { target: "_blank", rel: "noreferrer" } : null)} {...shared}>
|
||||
{face}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadAttachments({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
if (artifacts.length === 0) return null;
|
||||
const images = artifacts.filter((a) => a.kind === "image");
|
||||
const files = artifacts.filter((a) => a.kind !== "image");
|
||||
const counts = [images.length > 0 ? plural(images.length, "image") : null, files.length > 0 ? plural(files.length, "file") : null].filter(
|
||||
(count): count is string => Boolean(count),
|
||||
);
|
||||
// Whatever has a picture goes first, so the two-row slabs start the grid and the files pack beside them.
|
||||
const ordered = [...artifacts.filter(hasThumbnail), ...artifacts.filter((a) => !hasThumbnail(a))];
|
||||
return (
|
||||
<section data-solid="thread-attachments" aria-label="Attachments" className="flex w-full flex-col gap-2 py-1">
|
||||
<p className={cn(META_TEXT, "px-1 text-muted-foreground")}>
|
||||
<Counts lead={plural(artifacts.length, "attachment")} parts={counts} of={artifacts} />
|
||||
</p>
|
||||
<ul className="grid grid-flow-row-dense auto-rows-(--solid-attach-row) grid-cols-(--solid-attach-cols) gap-2">
|
||||
{ordered.map((artifact) => (
|
||||
<li key={artifact.id} className={cn("min-w-0", hasThumbnail(artifact) ? "row-span-2" : null)}>
|
||||
<AttachmentSlab artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { actionName, downloadAction, externalProps, threadAction } from "./actions";
|
||||
import { extent, isWaiting, metaParts } from "./kinds";
|
||||
import { KindBlock } from "./KindBlock";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { StateMarker } from "./StateMarker";
|
||||
import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, primaryRest, SLAB, SLAB_HOVER } from "./slab";
|
||||
|
||||
/**
|
||||
* THREAD — one work product between turns: a 60px slab.
|
||||
*
|
||||
* [kind block 72×44] Title ● Review [Open gallery ⤢]
|
||||
* kind · size · rev · extent — summary
|
||||
*
|
||||
* One grammar for all ten kinds (today: three). The whole slab is the primary
|
||||
* action's target; hovering it steps the fill and turns the action to ink. An
|
||||
* artifact that waits for review has its action in ink already.
|
||||
*
|
||||
* The row gives way in order of what matters least (by its own width, inside its inset):
|
||||
* from 48rem one-line title, summary on the meta line after a dash
|
||||
* under 48rem two-line title, summary on its own line under the meta, two lines at most
|
||||
* (a row with a summary grows to 76px; nothing is left to a tooltip)
|
||||
* from 42rem every action is the same 136px block, as every row starts with the same 72px one
|
||||
* under 36rem the state is its dot alone
|
||||
* under 28rem the action is its icon alone
|
||||
* and the text column never goes under 8rem, so the title is the last thing to yield.
|
||||
*/
|
||||
export function ThreadWorkProduct({ artifact }: { artifact: LabArtifact }) {
|
||||
const action = threadAction(artifact);
|
||||
const Icon = action.icon;
|
||||
// A text-like file opens in the side panel, so Download becomes its own small target.
|
||||
const download = action.id === "tab" ? downloadAction(artifact) : null;
|
||||
const DownloadIcon = download?.icon;
|
||||
// "Slides · 90.1 KB · 12 slides — 12 editable slides…" stutters: the summary keeps the figure, the meta lets it go.
|
||||
const figure = extent(artifact)?.split(" ")[0];
|
||||
const echoed = Boolean(figure && artifact.summary?.startsWith(`${figure} `));
|
||||
|
||||
const primary = {
|
||||
"data-primary": true,
|
||||
"aria-label": actionName(action, artifact),
|
||||
title: actionName(action, artifact),
|
||||
// First in the DOM so it is first in the tab order; last in the row.
|
||||
className: cn(
|
||||
BUTTON,
|
||||
primaryRest(isWaiting(artifact)),
|
||||
BUTTON_PRIMARY,
|
||||
COVER,
|
||||
"order-last cursor-pointer after:cursor-pointer @2xl:min-w-34 @2xl:justify-between @2xl:px-2.5",
|
||||
),
|
||||
};
|
||||
const face = (
|
||||
<>
|
||||
<span className="hidden @md:inline">{action.label}</span>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
data-solid="thread-row"
|
||||
data-kind={artifact.kind}
|
||||
className={cn("group/slab @container relative flex min-w-0 items-center gap-3 p-2 pr-3", SLAB, SLAB_HOVER)}
|
||||
>
|
||||
<KindBlock artifact={artifact} size="sm" />
|
||||
<div className="min-w-32 flex-1">
|
||||
<p className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground @3xl:line-clamp-1">{artifact.title}</p>
|
||||
<p className="mt-0.5 flex min-w-0 items-baseline">
|
||||
<MetaLine artifact={artifact} max={echoed ? metaParts(artifact).length - 1 : undefined} className="min-w-0 truncate @3xl:shrink-0" />
|
||||
{artifact.summary ? (
|
||||
<span className="hidden min-w-0 truncate text-xs text-(--solid-meta) @3xl:inline">
|
||||
<span aria-hidden className="px-2">
|
||||
—
|
||||
</span>
|
||||
{artifact.summary}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{artifact.summary ? <p className="mt-0.5 line-clamp-2 text-xs text-(--solid-meta) @3xl:hidden">{artifact.summary}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{action.href ? (
|
||||
<a href={action.href} {...externalProps(action)} {...primary}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" {...primary}>
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
<StateMarker review={artifact.review} collapse />
|
||||
{download && DownloadIcon ? (
|
||||
<a data-secondary href={download.href} aria-label={actionName(download, artifact)} title={download.label} className={ICON_BUTTON}>
|
||||
<DownloadIcon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Download, ExternalLink, Maximize2, PanelRightOpen, type LucideIcon } from "lucide-react";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { isTaskDocument, isTextLike, slug } from "./kinds";
|
||||
|
||||
/**
|
||||
* The one thing you do with an artifact from the thread, in production
|
||||
* wording. The lab's targets are inert hashes; `gallery` is a button because
|
||||
* the product opens a modal from it.
|
||||
*
|
||||
* One glyph per destination on every surface, so no two controls on a unit
|
||||
* look alike: ExternalLink leaves for a new browser tab, PanelRightOpen opens
|
||||
* the side panel, Maximize2 opens the gallery, Download downloads, and
|
||||
* ArrowRight (the gallery only) goes to the owning task.
|
||||
*/
|
||||
|
||||
export interface SolidAction {
|
||||
id: "document" | "gallery" | "preview" | "tab" | "download";
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Absent for `gallery`, which is a button. */
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function downloadAction(a: LabArtifact): SolidAction {
|
||||
return { id: "download", label: "Download", icon: Download, href: `#download-${a.id}` };
|
||||
}
|
||||
|
||||
export function threadAction(a: LabArtifact): SolidAction {
|
||||
if (isTaskDocument(a)) return { id: "document", label: "Open document", icon: PanelRightOpen, href: `#document-${slug(a)}` };
|
||||
if (a.kind === "link") return { id: "preview", label: "Open preview", icon: ExternalLink, href: a.url ?? "#", external: true };
|
||||
if (a.kind === "image" || a.kind === "video") return { id: "gallery", label: "Open gallery", icon: Maximize2 };
|
||||
// Text-like files open in the side panel; Download stays reachable as its own target.
|
||||
if (isTextLike(a)) return { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` };
|
||||
return downloadAction(a);
|
||||
}
|
||||
|
||||
/** The accessible name of an action on a given artifact: "Download: Interface Craft revision — PDF". */
|
||||
export const actionName = (action: SolidAction, a: LabArtifact) => `${action.label}: ${a.title}`;
|
||||
|
||||
/** Link attributes for an action that leaves for a new browser tab. */
|
||||
export const externalProps = (action: SolidAction) => (action.external ? ({ target: "_blank", rel: "noreferrer" } as const) : null);
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ArrowRight, Download, ExternalLink } from "lucide-react";
|
||||
import type { LabArtifact, LabGroup } from "../../kit";
|
||||
import type { SolidAction } from "./actions";
|
||||
import { blockLabel, hasThumbnail, isTaskDocument, slug } from "./kinds";
|
||||
|
||||
/**
|
||||
* How the gallery reads its data: what a tile's preview is, which previews a
|
||||
* stack shows, how an opened stack is sectioned, and what a tile offers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three previews of the direction, by what the artifact has to show:
|
||||
* picture image, video poster, link preview → the picture, kind tab in the corner
|
||||
* text task document, Markdown, code, sheet → its own words on a sheet, kind tab in the corner
|
||||
* facts PDF, slides, archive, any other file → a sheet that says what is known of it, the kind block docked in the corner
|
||||
*/
|
||||
export type PreviewKind = "picture" | "text" | "facts";
|
||||
|
||||
export function previewKind(a: LabArtifact): PreviewKind {
|
||||
if (hasThumbnail(a)) return "picture";
|
||||
return a.excerpt ? "text" : "facts";
|
||||
}
|
||||
|
||||
const VISUAL: Record<PreviewKind, number> = { picture: 0, text: 1, facts: 2 };
|
||||
|
||||
/**
|
||||
* A stack's mosaic: its three newest artifacts (what the API already sends as
|
||||
* `previewArtifacts`), with the one that has most to show in the large cell —
|
||||
* a picture before text before bare facts.
|
||||
*/
|
||||
export function stackPreviews(group: LabGroup): LabArtifact[] {
|
||||
return group.artifacts
|
||||
.slice(0, 3)
|
||||
.map((artifact, index) => ({ artifact, index }))
|
||||
.sort((a, b) => VISUAL[previewKind(a.artifact)] - VISUAL[previewKind(b.artifact)] || a.index - b.index)
|
||||
.map((entry) => entry.artifact);
|
||||
}
|
||||
|
||||
/** How many slabs sit behind a stack's face: none for one artifact, one for two or three, two for a real pile. */
|
||||
export const pileDepth = (count: number): 0 | 1 | 2 => (count <= 1 ? 0 : count <= 3 ? 1 : 2);
|
||||
|
||||
/**
|
||||
* What is in the pile, for the edge of the slab behind a stack's face: kinds
|
||||
* by their block label, most numerous first ("PDF 4 · PPTX 4 · PNG 3").
|
||||
* Task documents are counted together as DOCS — their block label is a revision.
|
||||
*/
|
||||
export function kindTally(artifacts: LabArtifact[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const artifact of artifacts) {
|
||||
const label = isTaskDocument(artifact) ? "DOCS" : blockLabel(artifact);
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([label, count]) => `${label} ${count}`);
|
||||
}
|
||||
|
||||
export interface DaySection {
|
||||
/** "2026-09-28" */
|
||||
id: string;
|
||||
/** "Monday, September 28" */
|
||||
label: string;
|
||||
items: LabArtifact[];
|
||||
}
|
||||
|
||||
/** An opened stack by the day of the last edit, newest day first; inside a day the page's own order (newest first) is kept. */
|
||||
export function daySections(artifacts: LabArtifact[]): DaySection[] {
|
||||
const byDay = new Map<string, DaySection>();
|
||||
for (const artifact of artifacts) {
|
||||
const id = artifact.updatedAt.slice(0, 10);
|
||||
const section = byDay.get(id) ?? { id, label: artifact.dayLabel, items: [] };
|
||||
section.items.push(artifact);
|
||||
byDay.set(id, section);
|
||||
}
|
||||
return [...byDay.values()].sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
|
||||
}
|
||||
|
||||
export interface GalleryActions {
|
||||
/** The tile itself: the owning task, at this artifact. */
|
||||
primary: SolidAction;
|
||||
/** The product's two ways straight to the file, where it has them. */
|
||||
secondary: SolidAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a gallery tile offers, in the product's wording. The tile is a link to
|
||||
* the owning task at the artifact's anchor; "Open file in new tab" and
|
||||
* "Download file" exist for anything file-backed (a task document has no file,
|
||||
* a link only opens).
|
||||
*/
|
||||
export function galleryActions(a: LabArtifact): GalleryActions {
|
||||
const primary: SolidAction = {
|
||||
id: "document",
|
||||
label: `Open in ${a.taskIdentifier}`,
|
||||
icon: ArrowRight,
|
||||
href: isTaskDocument(a) ? `#document-${slug(a)}` : `#work-product-${a.id}`,
|
||||
};
|
||||
if (isTaskDocument(a)) return { primary, secondary: [] };
|
||||
if (a.kind === "link") {
|
||||
return { primary, secondary: [{ id: "preview", label: "Open link in new tab", icon: ExternalLink, href: a.url ?? "#", external: true }] };
|
||||
}
|
||||
return {
|
||||
primary,
|
||||
secondary: [
|
||||
{ id: "preview", label: "Open file in new tab", icon: ExternalLink, href: `#file-${a.id}`, external: true },
|
||||
{ id: "download", label: "Download file", icon: Download, href: `#download-${a.id}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { DirectionKit } from "../../kit";
|
||||
import { GalleryStack, GalleryStacks } from "./Gallery";
|
||||
import { PanelBody } from "./PanelBody";
|
||||
import { ThreadAttachments } from "./ThreadAttachments";
|
||||
import { ThreadWorkProduct } from "./ThreadWorkProduct";
|
||||
import "./tokens.css";
|
||||
|
||||
/**
|
||||
* DIRECTION 1 — SOLID. Flat, filled, mostly monochrome.
|
||||
*
|
||||
* The fix is fill, not line. Today an artifact is a 1px outline around the
|
||||
* canvas colour; here it is a SLAB — a filled block a clear value step off the
|
||||
* canvas, with no stroke, no shadow and no gradient — and what fills an
|
||||
* artifact's preview is its CONTENT, never its file extension.
|
||||
*
|
||||
* FILLS (tokens.css; OKLCH lightness, light / dark; contrast against what it sits on)
|
||||
* canvas 1.000 / 0.205 --background, untouched
|
||||
* slab 0.900 / 0.325 the artifact (1.35 / 1.44 on the canvas; today's outline colour is 1.26 / 1.32)
|
||||
* slab-hover 0.865 / 0.365 a slab that is a target, hovered or focused; slab-press 0.830 / 0.400 while pressed
|
||||
* button 0.800 / 0.400 a resting button on a slab (1.39 / 1.35 on the slab)
|
||||
* sheet 1.000 / 0.430 the artifact's own content — an excerpt, a file's facts: the lightest fill short of ink
|
||||
* in both themes (paper on a mat); body text in --solid-sheet-text, lead lines in --foreground
|
||||
* ink 0.205 / 0.800 the one inverted fill: the kind block and tab, and a primary action that is hovered or due
|
||||
* pile-1/-2 0.920 / 0.290 and 0.945 / 0.265 the slabs behind a gallery stack: each one step NEARER the canvas, both themes
|
||||
* Text on a slab: --foreground, and --solid-meta (0.44 / 0.78) for secondary; every pair ≥ 4.5:1 at rest and on hover.
|
||||
*
|
||||
* SIGNATURES — the same idea on thread, panel and gallery
|
||||
* 1. KIND BLOCK (KindBlock.tsx, kinds.ts) — an INK plate: mono-caps label
|
||||
* bottom-left, the kind's glyph top-right (20px in a thread row, so kinds
|
||||
* differ by shape as well as letters). A file is labelled by its extension
|
||||
* (PDF, PPTX, MD, PNG, ZIP, XLSX, WEBM, HTML, OTF; URL for a link); a task
|
||||
* document, which has none, by its revision (REV 3) under a notebook glyph.
|
||||
* KindBlock free-standing: xs 56×36 attachments (label only) · sm 72×44
|
||||
* thread row and panel card · lg 128×96 a picture attachment.
|
||||
* An artifact with a picture shows the picture in the plate,
|
||||
* with nothing over it (a video keeps a play mark).
|
||||
* KindTag the label on an ink tab in the bottom-left corner of a
|
||||
* preview that has content of its own (a picture, a sheet).
|
||||
* KindDock the block docked in that corner of a preview that has none
|
||||
* (131×80 on a tile, 72×44 in a mosaic cell): the tab grown
|
||||
* to a block. Ink is always the mark in the bottom-left
|
||||
* corner — never the whole plate.
|
||||
* 2. MONO META LINE (MetaLine.tsx, kinds.ts) — kind · size · rev · extent in
|
||||
* font-mono 11px, never reordered: "PDF · 57.7 KB · 12 pages",
|
||||
* "Document · rev 3", "Video · 3.4 MB · 0:06". Production wording; a
|
||||
* surface may take a prefix (`max`), never reorder. Every other machine
|
||||
* value (filenames, stamps, counts, content types) is the same META_TEXT.
|
||||
* 3. ONE RADIUS, ONE RHYTHM (slab.ts) — rounded-md on every slab, plate,
|
||||
* sheet, button, tab and bite; 8px inset around the kind block in the
|
||||
* thread, 12px in the panel and the gallery; 12px from block to text; 8px
|
||||
* between slabs, 24px gutters in the gallery.
|
||||
* 4. MONO COUNT LABEL (Counts.tsx) — heads the thread's attachments, the
|
||||
* panel's shelf and sections, and the gallery's grids, days and stacks:
|
||||
* "21 artifacts ● 9 to review". Items are unbreakable; the label wraps
|
||||
* between them.
|
||||
*
|
||||
* STATE (StateMarker.tsx) — the only chroma, and only a 6px dot of it: the
|
||||
* product's status hue + the production label ("Review", "Changes requested")
|
||||
* in foreground text, straight on the slab with no fill of its own, so it is
|
||||
* never taken for a button. What makes "this needs you" unmistakable is the
|
||||
* action beside it: the primary of an artifact that waits for review is INK AT
|
||||
* REST (thread and panel). On a gallery preview the state is written in a BITE
|
||||
* the slab takes out of the bottom-right corner (inverse radii at its joints),
|
||||
* the mirror of the ink kind tab bottom-left; a mosaic's "+18" is the same bite.
|
||||
*
|
||||
* INTERACTION (slab.ts) — in the thread and the gallery the whole slab is its
|
||||
* primary action's target (COVER). Hover or keyboard focus steps the slab and
|
||||
* turns that action to ink; pressing steps it once more. A secondary control
|
||||
* is an icon-only ghost that takes the ink itself and hands the primary back
|
||||
* to the button fill while hovered. The panel card is a reading surface and is
|
||||
* NOT a target: only its controls act. The primary is first in the tab order
|
||||
* everywhere; icon-only controls carry a tooltip naming the action. One glyph
|
||||
* per destination: a box-with-arrow leaves for a new tab, a tray downloads, a
|
||||
* side-panel glyph opens the pane, the plain arrow goes to the owning task.
|
||||
* focus-visible is a 2px ink outline 2px off the slab or the control. Colour
|
||||
* transitions only (and the pile's rise); none under reduced motion.
|
||||
*
|
||||
* THREAD — compact and calm (ThreadWorkProduct, ThreadAttachments)
|
||||
* Row 60px: [block 72×44] title / meta — summary … ● state [action 136px].
|
||||
* Gives way by priority, by its own width: from 48rem a one-line title with
|
||||
* the summary on the meta line; under that a two-line title and the summary
|
||||
* on its own line (76px); under 36rem the state is its dot; under 28rem the
|
||||
* action is its icon; the text column never goes under 8rem.
|
||||
* Attachments: one count label ("5 attachments · 1 image · 4 files ● 5 to
|
||||
* review") over a wrapping grid: a file is a 52px slab (block, filename, meta,
|
||||
* state); anything with a picture takes two rows, the picture at 128×96.
|
||||
*
|
||||
* PANEL — a thread row with its drawer open (PanelBody, PanelCard, PanelLead,
|
||||
* PanelMenu, panel-model.ts, panel-actions.ts)
|
||||
* Shelf one count line, then the product's sections in the product's
|
||||
* order: one per producing run (name, count line, run start), task
|
||||
* documents on their own. Nothing is reordered.
|
||||
* Card slab, 12px inset. A picture or a video LEADS its card at 16:9
|
||||
* with the kind tab on it (a video plays in place); every other
|
||||
* kind opens with [block 72×44] beside the text.
|
||||
* head title, summary, meta line, filename, and the content type on
|
||||
* file cards — all wrap, nothing is clamped.
|
||||
* sheet text kinds only: the excerpt on a sheet, opening line and
|
||||
* headings strong; six lines when it waits for review, three otherwise.
|
||||
* foot one row, the same three places on every card: ● state · who and
|
||||
* when (the time alone under a section dated the same day) · and
|
||||
* at the far end [comments] [⋯ More actions] [primary 136px]. All
|
||||
* secondaries are in the one menu, by name.
|
||||
* Widths under 20rem of card the foot's two ends stack; a maximized pane
|
||||
* sets the cards in 22rem columns, row by row.
|
||||
*
|
||||
* GALLERY — browsing: preview first (Gallery, StackTile, GalleryTile,
|
||||
* GalleryPreview, ExcerptSheet, gallery-model.ts)
|
||||
* Grid the product's: 1 / 2 / 3 columns at <768 / 768 / 1280px, 24px
|
||||
* gutters, under one count line. An opened stack is sectioned by
|
||||
* day of last edit: the day name is the page's one large line
|
||||
* (text-lg semibold), its count line beneath. `?days=off` lists it
|
||||
* as the product does today, in one flat grid.
|
||||
* Preview one 18:11 plate per artifact, filled by what the artifact is:
|
||||
* picture image, video poster, link the picture + ink kind tab
|
||||
* text document, Markdown, code, its own words on a sheet, as many whole
|
||||
* sheet lines as the plate holds + ink kind tab
|
||||
* facts PDF, slides, archive, file a sheet: how much of it there is at display
|
||||
* size ("12 slides"), its filename, and the
|
||||
* kind block docked bottom-left
|
||||
* Tile slab: preview / title across the full width (2 lines) / meta
|
||||
* line and stamp · author, with [⧉] [↓] [→] at the far end of the
|
||||
* foot. The tile is the [→] action's target (the owning task).
|
||||
* Stack a PILE of flat fills behind the face: 0 / 1 / 2 slabs for 1 /
|
||||
* 2–3 / more artifacts; the nearer one says what is in the pile on
|
||||
* its edge ("DOCS 7 · PDF 3 · PPTX 3"); they rise 6px a slab on
|
||||
* hover. On the face a MOSAIC of the three newest, each as its own
|
||||
* preview, the one with most to show large, "+18" bitten into the
|
||||
* corner. Then ACM-3 + title, and a two-line foot (counts, then
|
||||
* Updated) with [→]. Face and pile are one target. A stack of one
|
||||
* has no pile and one full preview.
|
||||
*/
|
||||
export const kit: DirectionKit = {
|
||||
id: "d1-solid",
|
||||
name: "1.Solid",
|
||||
tagline: "Flat, filled, mostly monochrome",
|
||||
ThreadWorkProduct,
|
||||
ThreadAttachments,
|
||||
PanelBody,
|
||||
GalleryStacks,
|
||||
GalleryStack,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Archive, CodeXml, File, FilePenLine, FileText, Film, Globe, Image, NotebookText, Presentation, Table2, type LucideIcon } from "lucide-react";
|
||||
import { fileKindForName } from "@/components/task-chat/task-chat-attachments";
|
||||
import type { LabArtifact, LabKind } from "../../kit";
|
||||
|
||||
/**
|
||||
* The Solid vocabulary: for each of the ten kinds, the glyph that goes in the
|
||||
* kind block, the mono-caps label under it, and the wording of the meta line.
|
||||
* Every surface reads kinds from here so a PDF is the same PDF in the thread,
|
||||
* the panel and the gallery.
|
||||
*
|
||||
* Glyphs are chosen to differ in shape at 20px, not only in their letters: a
|
||||
* notebook (task document — kept and revised in the task), a page with a pen
|
||||
* (Markdown — a source you edit), a lined page (PDF — the finished page), an
|
||||
* easel, a grid, a box, brackets, a blank page, a globe, a framed picture, a
|
||||
* film strip.
|
||||
*/
|
||||
|
||||
interface KindSpec {
|
||||
glyph: LucideIcon;
|
||||
/** Kind-block label when the artifact has no extension to show. */
|
||||
block: string;
|
||||
/** Meta-line label when the product's extension mapper only says "File". */
|
||||
meta: string;
|
||||
}
|
||||
|
||||
export const KINDS: Record<LabKind, KindSpec> = {
|
||||
document: { glyph: FilePenLine, block: "MD", meta: "Markdown" },
|
||||
image: { glyph: Image, block: "IMG", meta: "Image" },
|
||||
video: { glyph: Film, block: "VID", meta: "Video" },
|
||||
pdf: { glyph: FileText, block: "PDF", meta: "PDF" },
|
||||
slides: { glyph: Presentation, block: "PPT", meta: "Slides" },
|
||||
sheet: { glyph: Table2, block: "XLS", meta: "Sheet" },
|
||||
archive: { glyph: Archive, block: "ZIP", meta: "Archive" },
|
||||
code: { glyph: CodeXml, block: "CODE", meta: "Code" },
|
||||
file: { glyph: File, block: "FILE", meta: "File" },
|
||||
link: { glyph: Globe, block: "URL", meta: "Link" },
|
||||
};
|
||||
|
||||
/** A task document ("Document · rev N"), as opposed to a Markdown file an agent attached. */
|
||||
export const isTaskDocument = (a: LabArtifact) => a.kind === "document" && a.revision != null;
|
||||
|
||||
/** A task document is a notebook: it is kept and revised in the task, not a file on disk. */
|
||||
export const kindGlyph = (a: LabArtifact): LucideIcon => (isTaskDocument(a) ? NotebookText : KINDS[a.kind].glyph);
|
||||
|
||||
/**
|
||||
* The mono-caps label in the kind block. A file is labelled by its extension
|
||||
* (PDF, PPTX, MD, PNG, ZIP, CSV, WEBM); a task document has none, and is
|
||||
* labelled by what tells one from the next — its revision (REV 3).
|
||||
*/
|
||||
export function blockLabel(a: LabArtifact): string {
|
||||
if (a.kind === "link") return KINDS.link.block;
|
||||
if (isTaskDocument(a)) return `REV ${a.revision}`;
|
||||
const ext = a.ext?.replace(/[^a-z0-9]/gi, "");
|
||||
return ext ? ext.slice(0, 4).toUpperCase() : KINDS[a.kind].block;
|
||||
}
|
||||
|
||||
/** The first word of the meta line, in production wording where production has one. */
|
||||
export function metaLabel(a: LabArtifact): string {
|
||||
if (isTaskDocument(a)) return "Document";
|
||||
if (a.kind === "link") return KINDS.link.meta;
|
||||
if (a.kind === "image" || a.kind === "video") return KINDS[a.kind].meta;
|
||||
const shipped = a.filename ? fileKindForName(a.filename).label : "File";
|
||||
return shipped === "File" ? KINDS[a.kind].meta : shipped;
|
||||
}
|
||||
|
||||
export const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
||||
|
||||
/** The key a document or file is addressed by in the lab's inert hashes. */
|
||||
export const slug = (a: LabArtifact) => (a.filename ?? a.id).replace(/\.md$/, "");
|
||||
|
||||
function host(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of it there is: pages, slides, a duration, or where a link points. */
|
||||
export function extent(a: LabArtifact): string | null {
|
||||
if (a.kind === "video") return a.durationLabel ?? null;
|
||||
if (a.kind === "link") return host(a.url);
|
||||
if (!a.pageCount) return null;
|
||||
return plural(a.pageCount, a.kind === "slides" ? "slide" : "page");
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta line's parts, in one grammar everywhere: kind · size · rev · extent
|
||||
* (pages, slides, duration or host). A surface may take a prefix of this list
|
||||
* when it is short of room, never reorder it.
|
||||
*/
|
||||
export function metaParts(a: LabArtifact): string[] {
|
||||
return [metaLabel(a), a.sizeLabel, a.revision != null ? `rev ${a.revision}` : null, extent(a)].filter((part): part is string => Boolean(part));
|
||||
}
|
||||
|
||||
/** What the product can open in the side panel instead of downloading (`isTextAttachment`). */
|
||||
export function isTextLike(a: LabArtifact): boolean {
|
||||
if (a.kind === "link" || isTaskDocument(a)) return false;
|
||||
const type = a.contentType ?? "";
|
||||
return type.startsWith("text/") || type === "application/json" || /\.(md|txt|log|csv|json|ya?ml|html|css)$/i.test(a.filename ?? "");
|
||||
}
|
||||
|
||||
/** Whether the artifact has a picture to put where the kind block would go. */
|
||||
export const hasThumbnail = (a: LabArtifact) => Boolean(a.thumbnailUrl);
|
||||
|
||||
/** The name a file is listed under when the surface shows files rather than titles. A link has no file, so it keeps its title. */
|
||||
export const fileName = (a: LabArtifact) => a.filename ?? a.title;
|
||||
|
||||
/** Whether anything waits on the reader: the artifact's primary action is then ink at rest. */
|
||||
export const isWaiting = (a: LabArtifact) => a.review === "needs_review";
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChevronDown, Download, ExternalLink, Maximize2, PanelRightOpen, Table2 } from "lucide-react";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import type { SolidAction } from "./actions";
|
||||
import { isTaskDocument, isTextLike, slug } from "./kinds";
|
||||
|
||||
/**
|
||||
* What a panel card offers, in the panel's production wording. The primary is
|
||||
* the same deed the thread row offers for that artifact, under the name the
|
||||
* panel gives it:
|
||||
*
|
||||
* thread panel
|
||||
* Open document Read document task documents (a side-panel tab) and Markdown files (expands in place)
|
||||
* Open gallery View image / Open video
|
||||
* Open preview Open link
|
||||
* Open in tab Open in tab other text-like files
|
||||
* Download Download file everything else
|
||||
*
|
||||
* Everything else the product puts on the card is listed, by name, in the
|
||||
* card's one "More actions" menu: Open in tab, Open raw, Open file, Download,
|
||||
* Preview data. The comments count of a Markdown file is the card's own chip,
|
||||
* not listed here.
|
||||
*/
|
||||
|
||||
export interface PanelActions {
|
||||
primary: SolidAction;
|
||||
more: SolidAction[];
|
||||
}
|
||||
|
||||
export function panelActions(a: LabArtifact): PanelActions {
|
||||
const download = (label: string): SolidAction => ({ id: "download", label, icon: Download, href: `#download-${a.id}` });
|
||||
const openFile: SolidAction = { id: "preview", label: "Open file", icon: ExternalLink, href: `#file-${a.id}`, external: true };
|
||||
const openTab: SolidAction = { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` };
|
||||
|
||||
if (isTaskDocument(a)) {
|
||||
return { primary: { id: "document", label: "Read document", icon: PanelRightOpen, href: `#document-${slug(a)}` }, more: [] };
|
||||
}
|
||||
if (a.kind === "link") {
|
||||
return { primary: { id: "preview", label: "Open link", icon: ExternalLink, href: a.url ?? "#", external: true }, more: [] };
|
||||
}
|
||||
if (a.kind === "image") return { primary: { id: "gallery", label: "View image", icon: Maximize2 }, more: [] };
|
||||
if (a.kind === "video") return { primary: { id: "gallery", label: "Open video", icon: Maximize2 }, more: [] };
|
||||
if (a.kind === "document") {
|
||||
// A Markdown file an agent attached: reads in place, with the product's three ways out.
|
||||
return {
|
||||
primary: { id: "document", label: "Read document", icon: ChevronDown },
|
||||
more: [openTab, { id: "preview", label: "Open raw", icon: ExternalLink, href: `#raw-${a.id}`, external: true }, download("Download")],
|
||||
};
|
||||
}
|
||||
if (isTextLike(a)) {
|
||||
const data: SolidAction[] = /\.csv$/i.test(a.filename ?? "") ? [{ id: "tab", label: "Preview data", icon: Table2 }] : [];
|
||||
return { primary: openTab, more: [...data, openFile, download("Download file")] };
|
||||
}
|
||||
return { primary: download("Download file"), more: [openFile] };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { isTaskDocument } from "./kinds";
|
||||
|
||||
/**
|
||||
* How the panel is sectioned and ordered: exactly as the product does it.
|
||||
*
|
||||
* Work products are grouped by the run that produced them (agent + run start);
|
||||
* task documents, which no run produced, are a section of their own. Sections
|
||||
* and the cards inside them keep the order the product lists them in — Solid
|
||||
* changes how an artifact looks, not where it is found.
|
||||
*/
|
||||
|
||||
export interface PanelSection {
|
||||
id: string;
|
||||
/** The producing agent, or a name for what was not produced by a run. */
|
||||
title: string;
|
||||
/** The agent every artifact in the section shares, when it is a run. A card repeats an author only when it differs. */
|
||||
agent?: string;
|
||||
/** "Sep 28, 2026, 10:52 AM" — a run's start. */
|
||||
startedLabel?: string;
|
||||
startedAt?: string;
|
||||
/** "artifact", or "document" when the section holds task documents only. */
|
||||
noun: string;
|
||||
items: LabArtifact[];
|
||||
}
|
||||
|
||||
export function panelSections(artifacts: LabArtifact[]): PanelSection[] {
|
||||
// A Map keeps insertion order: sections appear where their first artifact does, cards as listed.
|
||||
const byRun = new Map<string, LabArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
const key = artifact.run?.id ?? "";
|
||||
byRun.set(key, [...(byRun.get(key) ?? []), artifact]);
|
||||
}
|
||||
return [...byRun.entries()].map(([runId, items]): PanelSection => {
|
||||
const run = items[0].run;
|
||||
const documentsOnly = items.every(isTaskDocument);
|
||||
return {
|
||||
id: runId || "loose",
|
||||
title: run ? run.agent : documentsOnly ? "Task documents" : "Other artifacts",
|
||||
agent: run?.agent,
|
||||
startedLabel: run?.startedLabel,
|
||||
startedAt: run?.startedAt,
|
||||
noun: !run && documentsOnly ? "document" : "artifact",
|
||||
items,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp for a narrow row: the product's label without the year when it
|
||||
* is this year ("Sep 28, 11:07 AM"). The full label stays in the tooltip.
|
||||
*/
|
||||
export const shortStamp = (label: string) => label.replace(`, ${new Date().getFullYear()}`, "");
|
||||
|
||||
const day = (label: string) => label.slice(0, label.lastIndexOf(", "));
|
||||
|
||||
/**
|
||||
* A card's stamp under a section header that already carries a date: the time
|
||||
* alone when the day is the section's ("11:07 AM"), the short stamp otherwise.
|
||||
*/
|
||||
export function stampUnder(label: string, sectionLabel: string | undefined): string {
|
||||
return sectionLabel && day(label) === day(sectionLabel) ? label.slice(label.lastIndexOf(", ") + 2) : shortStamp(label);
|
||||
}
|
||||