mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
perf(skills): store repository packages outside Postgres
Convert legacy packages automatically, restore exact commits on cache misses, and expire unused recoverable packages while keeping warm turn checks metadata-only. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
65fa1cd617
commit
0bb36b2c6b
53 files changed
+13120
-203
No files matched your search
@@ -1252,6 +1252,27 @@ register before writing, or they can remain unseen until the next startup or
|
||||
another notification. Tests should verify both the producer and its outer
|
||||
transaction boundary.
|
||||
|
||||
### Installed skill packages
|
||||
|
||||
`company_skill_repository_snapshots` retains its identity and company/content-hash
|
||||
uniqueness. Despite its historical name, it can also hold editable skill packages.
|
||||
Migration 0398 adds `storage` (archive/manifest receipts) and `upload` (a bounded
|
||||
pending attempt). `file_inventory` remains the legacy payload, defaulting to `[]`.
|
||||
`company_skill_versions.package_snapshot_id` and `package_file_map` preserve the
|
||||
public version file inventory without storing its contents again. Existing repository
|
||||
snapshot/entrypoint references and version IDs remain unchanged. Reference indexes
|
||||
support bounded orphan checks; restrictive FKs protect retained version packages.
|
||||
|
||||
`skill_package_deletions` is a durable object-deletion outbox populated by snapshot
|
||||
update/delete triggers. Its company ID deliberately has no cascading company FK:
|
||||
cleanup must retain exact object keys after company deletion. Upload promotion skips
|
||||
retirement; replacing a pending token retires only the old attempt's unique keys.
|
||||
Migration 0399 adds exact-commit restore recipes and indexed conversion/maintenance
|
||||
deadlines. A bounded coordinator worker automatically converts and clears verified
|
||||
legacy payloads; no migration command is required. Warm reads exclude recipe manifests.
|
||||
See [package storage](skill-package-storage.md) for acquisition, retention, compatibility
|
||||
overrides, independent restore verification and coordinated backup policy.
|
||||
|
||||
### Project repository skill synchronization
|
||||
|
||||
`project_skill_sources` stores durable workspace import jobs and originating project
|
||||
|
||||
@@ -2885,6 +2885,11 @@ Environment overrides:
|
||||
non-numeric value falls back to the default.
|
||||
- `PAPERCLIP_WORKSPACE_COMPACTION_ENABLED=false` turns compaction off. Any other
|
||||
value keeps it on.
|
||||
- `PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED=false` disables external writes and automatic
|
||||
package maintenance for a coordinated rolling upgrade. Default: enabled. Legacy
|
||||
conversion runs automatically in bounded batches, outside turn preparation.
|
||||
Existing external packages remain readable with the override. See
|
||||
[package storage and acquisition](skill-package-storage.md).
|
||||
- `PAPERCLIP_SKILL_VERSION_RETENTION_KEEP=<count>` sets how many of the newest
|
||||
materialized versions of each skill stay on disk (see "Skill Version Snapshot
|
||||
Retention" above). The default is `0`. A negative, empty, or non-numeric value
|
||||
|
||||
@@ -198,6 +198,12 @@ Missing references are advisory: a valid selected skill can still be imported.
|
||||
Unavailable files are listed, and existing installed dependencies are retained
|
||||
when a bounded refresh cannot preserve them. File limits and content validation
|
||||
still apply; importing never executes repository code.
|
||||
Authored and unproven legacy versions retain an independent recovery copy. Unused Git
|
||||
packages with an exact restore recipe may expire; later use requires access to that
|
||||
original repository and commit.
|
||||
External package storage keeps repository contents out of the control-plane database;
|
||||
per-turn checks reuse verified local files while rechecking current skill access.
|
||||
See [automatic storage and recovery](skill-package-storage.md).
|
||||
|
||||
Project-derived skills inherit current project access. Access through any originating
|
||||
project is sufficient, including the responsible-user restrictions on agent execution.
|
||||
|
||||
@@ -2362,7 +2362,13 @@ unavailable. Preserve current ownership and newer-work fences. See
|
||||
on their snapshot. Importing does not install dependencies or configure host tools.
|
||||
- Stage and audit complete packages before publishing. Scripts are allowed through
|
||||
the existing content audit and never run on import. Persist binary bytes and executable
|
||||
flags in immutable version inventories (legacy entries default to UTF-8/non-executable).
|
||||
flags in immutable package snapshots (legacy entries default to UTF-8/non-executable).
|
||||
Compatible readers support inline and external inventories. PostgreSQL retains
|
||||
receipts, file mappings and exact Git restore recipes; the storage backend retains
|
||||
bytes. No package is persisted without a valid selected skill. Legacy conversion
|
||||
is automatic and bounded. Unused Git caches can expire and reacquire the recorded
|
||||
commit after current authorization and hash checks; authored/unproven legacy bytes
|
||||
remain durable. See [skill package storage](skill-package-storage.md).
|
||||
- Refresh is serialized per source, checks saved selection revisions, and publishes
|
||||
valid skills plus per-entry outcomes with required activity records in one transaction.
|
||||
Failed packages retain prior versions; access/scan/download failures retain all content.
|
||||
@@ -2374,8 +2380,10 @@ unavailable. Preserve current ownership and newer-work fences. See
|
||||
- Runtime version directories are disposable caches. An hourly, bounded sweep may
|
||||
evict non-current copies unused for one hour, preserving active and unresolved
|
||||
execution pins. Selection, materialization, and pin persistence serialize with
|
||||
eviction across processes. Missing copies rebuild from immutable database bytes
|
||||
without upstream access. See [recoverable disk cleanup](recoverable-disk-cleanup.md)
|
||||
eviction across processes. Missing copies rebuild from immutable stored packages
|
||||
or legacy database bytes without upstream access. Warm preparation uses manifests
|
||||
and file identities, avoids repository-content SQL/object reads, and does not
|
||||
reconcile unrelated library entries for known selected repository skills. See [recoverable disk cleanup](recoverable-disk-cleanup.md)
|
||||
for rollout controls, legacy grace periods, and interrupted cleanup recovery.
|
||||
- Manually selected sources retain reviewed selection, with new candidates checked by default and
|
||||
explicit folder exclusions retained. Upstream deletion keeps installed content with
|
||||
|
||||
@@ -445,3 +445,13 @@ timer remains; an empty buffer performs no database work. Existing Muse durable
|
||||
sleep blockers, external worker stop evidence, and ingress requirements remain
|
||||
unchanged. This change establishes database quiet for inactive Muse, not host
|
||||
sleep eligibility for a configured Muse connection.
|
||||
|
||||
## Skill package maintenance
|
||||
|
||||
The package delivery worker automatically converts legacy snapshots and versions.
|
||||
Unfinished `migration_at` rows block idle sleep until their durable contents are
|
||||
verified and conversion finishes. Package/object/native-asset retention timestamps
|
||||
are earliest cleanup eligibility, not promised delivery deadlines; optional cleanup
|
||||
may wait for the next ingress wake. Workers reconstruct persisted rows and filesystem
|
||||
access leases on startup, and use the existing coordinator's drain/standby/shutdown
|
||||
controls. No external Cloud alarm or provider suspension is established by these tests.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,444 @@
|
||||
# Move installed skill packages out of PostgreSQL
|
||||
|
||||
Status: implemented in this checkout, including automatic conversion, exact-commit
|
||||
recovery and cache retention. Focused verification passed; the broad test run
|
||||
did not complete cleanly (details below). No production changes.
|
||||
Operational contract: [skill package storage](../skill-package-storage.md).
|
||||
Measured results: [benchmark receipts](2026-10-28-skill-package-performance-results.json).
|
||||
Investigation date: 2026-10-28. Code inspected: `1ed0af41867ba40fd1d23292d395588d18c2fcbe`.
|
||||
|
||||
## Decision
|
||||
|
||||
Store the exact audited installed package in the existing storage backend: S3-compatible
|
||||
object storage for hosted instances, persistent local storage for local installations.
|
||||
PostgreSQL retains package identities, storage receipts, versions, source tracking,
|
||||
permissions and run pins. A bounded, verified disk cache supplies execution and file reads.
|
||||
|
||||
New Git packages retain an immutable recipe with repository identity, exact commit,
|
||||
included-file manifest and packager version. Authored and unproven legacy packages
|
||||
retain independent durable bytes. Also retain the installed package's content hash: the package can exclude unsafe, oversized or unsupported files and can be
|
||||
a reduced repository subset. A commit SHA alone does not identify that transformation,
|
||||
provide the bytes when upstream disappears, or represent an edited local copy.
|
||||
|
||||
Preserve today's packaging and access behavior during the storage migration. In particular,
|
||||
do not switch all imports back to directory-only mode or infer a complete dependency
|
||||
graph from Markdown references. The original dependency-loss bug was real.
|
||||
|
||||
The first delivery removes file-content traffic from PostgreSQL for migrated repository
|
||||
packages, avoids repeated remote reads of the same immutable package, and stops creating
|
||||
packages for repositories with no installable selected skills. Deduplicating the final
|
||||
per-skill runtime transport is a separate, explicitly scoped follow-up.
|
||||
|
||||
## What the trace established
|
||||
|
||||
### Import and refresh
|
||||
|
||||
1. Project workspace creation/update calls `enqueueProjectSkillSource` inside the
|
||||
workspace transaction. An eligible GitHub URL is sufficient to enqueue; this happens
|
||||
before discovery. Existing legacy projects have an explicit adoption path.
|
||||
2. `skillSourceSyncService.importBinding` checks authority, reserves bounded work credits,
|
||||
and requests `packageMode: 'repository'`. The scanner resolves a commit and uses a
|
||||
shallow, temporary Git repository to list paths and read blobs.
|
||||
3. `github-skill-source.ts` discovers entrypoints, builds and audits package files, and
|
||||
filters unsupported/unsafe files. Repository mode normally includes the supported
|
||||
tree, with a skill-folder-plus-reference fallback for oversized repositories and a
|
||||
further skill-only fallback. It does not store Git history or `.git` metadata.
|
||||
4. `skillSourceService.publish` hashes `scan.repositoryFiles` and inserts their **contents**
|
||||
into `company_skill_repository_snapshots.file_inventory`. Deduplication is by company
|
||||
and content hash. The insert happens before the selected-valid-skill loop; a nonempty
|
||||
package can therefore be stored with no discovered or selected usable skills.
|
||||
`ON CONFLICT DO NOTHING` avoids a duplicate row, but still sends the proposed file
|
||||
array to PostgreSQL whenever publication reaches that insert. The earlier unchanged
|
||||
commit/tree shortcut avoids this path for qualifying automatic checks.
|
||||
5. Each selected skill version references that snapshot and its original entrypoint.
|
||||
Publication also passes `candidate.files` into `createVersion`, which persists those
|
||||
contents again in `company_skill_versions.file_inventory`. Thus the full repository is
|
||||
shared, but the selected skills' own files have an additional persisted copy.
|
||||
6. Automatic refresh already skips downloading when the commit or root tree is unchanged.
|
||||
It uses saved setup authority, leases, budgets and five-minute deadlines. An included
|
||||
file change changes the package hash and versions every selected skill. This conservative
|
||||
invalidation preserves access to shared and dynamically chosen dependencies.
|
||||
|
||||
The similarly named fields are different: `company_skills.file_inventory` and
|
||||
`company_skill_sources.repository_package.files` are file metadata; repository snapshot
|
||||
and version `file_inventory` fields contain actual contents. Moving the former alone
|
||||
would not address the measured payload.
|
||||
|
||||
Source pointers: [project attachment](../../server/src/services/projects.ts),
|
||||
[binding and identity](../../server/src/services/project-skill-source-bindings.ts),
|
||||
[worker](../../server/src/services/skill-source-sync.ts),
|
||||
[scanner](../../server/src/services/github-skill-source.ts),
|
||||
[publication](../../server/src/services/skill-sources.ts),
|
||||
[snapshot/version schema](../../packages/db/src/schema/company_skills.ts).
|
||||
|
||||
### Reads and execution
|
||||
|
||||
`company-skills.ts::runtimeVersionInventory` selects the entire repository snapshot,
|
||||
then `repositorySkillProjection` builds a generated discovery wrapper plus the full
|
||||
original tree under `.paperclip-repository/`.
|
||||
|
||||
| Consumer | Current unnecessary or amplified work | Replacement |
|
||||
| --- | --- | --- |
|
||||
| Runtime preparation | Loads JSONB before checking the materialized directory and before entering the per-version shared promise | Resolve a small package descriptor; coalesce package loading before I/O; validate disk files against hashes |
|
||||
| Skill audit | Materialization loads the repository; audit requests the inventory again | Reuse the verified package handle and manifest; audit actual delivered bytes |
|
||||
| Skill detail/file tree | Loads contents, then discards them to keep paths and kinds | Read the package manifest only |
|
||||
| One supporting file | Falls back to loading the whole repository inventory | Read the requested file from the verified local package cache |
|
||||
| Fork, create-from-existing, disconnect-and-keep | Projects repository files; editable versions can persist a full projected tree inline | Copy from a package handle; save resulting snapshots to the same external package store |
|
||||
| Cache retention | `hasRestorableSnapshot` reads repository JSONB on a process-cache miss to validate entrypoints | Verify external durability before eviction; never evict the last usable copy on storage failure |
|
||||
| Version API / Studio test context | Version objects and one heartbeat join directly consume inline version inventories | Metadata-only internal queries plus explicit package hydration where content is required |
|
||||
|
||||
The existing per-version disk cache does **not** prevent the initial database read.
|
||||
Existing cache locks serialize publication, pinning and eviction, but do not turn
|
||||
repository identity into a shared content cache.
|
||||
|
||||
Each selected entrypoint also gets its own full runtime copy. Native remote launch loops
|
||||
over skill bundles and stages each directory separately. Moving storage alone does not
|
||||
remove this disk/transport multiplication. Never assume 79 installed skills means all
|
||||
79 are assigned or transferred on every run.
|
||||
|
||||
Source pointers: [runtime, audit, browsing and version creation](../../server/src/services/company-skills.ts),
|
||||
[projection](../../server/src/services/skill-repository-package.ts),
|
||||
[cache locking and restorability](../../server/src/services/skill-version-cache.ts),
|
||||
[Studio context](../../server/src/services/heartbeat/run-preparation.ts),
|
||||
[remote staging](../../server/src/services/native-runtime/remote-launch-assets.ts),
|
||||
[company export](../../server/src/services/company-portability.ts).
|
||||
|
||||
### Evidence and limits
|
||||
|
||||
The preceding Railway investigation recorded six snapshot reads returning **93.6 MB**
|
||||
in a 15-minute window: **12.94%** of observed PostgreSQL response bytes. A later metadata
|
||||
inspection found a package containing **1,349 files**, **22,611,267 serialized JSON bytes**,
|
||||
and **79 current referencing skills**. These are prior observations, not a fresh benchmark
|
||||
of this proposal. The measured build was `71b448b9`; the later example was observed on
|
||||
`b25f3e2a`. The principal import/runtime files inspected here match the earlier linked
|
||||
`2e5bbde8` revision. Do not extrapolate either capture to every current instance.
|
||||
|
||||
Serialized JSON bytes are neither compressed on-disk PostgreSQL size nor raw repository
|
||||
size. PostgreSQL can compress/store large values out of line using
|
||||
[TOAST](https://www.postgresql.org/docs/current/storage-toast.html). Selecting the complete
|
||||
inventory still returns that value to the application. New snapshot payloads also add
|
||||
database storage, WAL, backup and decoding work; those costs were not quantified here.
|
||||
|
||||
Removing this traffic does not establish a 12.94% latency improvement, solve all Railway
|
||||
slowness, or justify a specific CPU reduction. The capture measures bytes, not causality
|
||||
for every slow request.
|
||||
|
||||
## Why this implementation arose
|
||||
|
||||
- [#14713](https://github.com/paperclipai/paperclip/pull/14713), September 30, introduced
|
||||
GitHub-synchronized skills on the existing immutable-version foundation.
|
||||
- [#15731](https://github.com/paperclipai/paperclip/pull/15731), October 12, fixed missing
|
||||
shared scripts, policies, sibling skills and credits by preserving repository packages.
|
||||
It explicitly chose shared database snapshots and self-contained per-skill delivery.
|
||||
- [#16066](https://github.com/paperclipai/paperclip/pull/16066), October 13, extended that
|
||||
behavior to automatic project imports and refresh.
|
||||
- [#16308](https://github.com/paperclipai/paperclip/pull/16308) preserved referenced files
|
||||
for oversized repositories. [#16433](https://github.com/paperclipai/paperclip/pull/16433)
|
||||
subsequently made old disk copies disposable, with database restoration as the guarantee.
|
||||
|
||||
The #15731 description reports strong byte/mode, offline, pinning and real runtime proof,
|
||||
and explicitly acknowledges per-skill runtime duplication. Its inline reviews caught
|
||||
audit/projection mismatches, lost binary/mode information, unsafe files, expensive regexes,
|
||||
and per-blob Git process overhead. The review was not indifferent to correctness or cost.
|
||||
|
||||
The inspected acceptance evidence does not establish a bound on PostgreSQL response bytes
|
||||
for warm preparation, one-file browsing, or multiple entrypoints sharing a large package.
|
||||
The supported explanation is that a legitimate dependency-preservation feature reused a
|
||||
content-bearing version contract, while performance proof focused on acquisition and
|
||||
functional delivery. Automatic adoption enlarged the affected workload. Original author
|
||||
sessions were not identified, so this is not a claim about unrecorded reasoning.
|
||||
|
||||
The preventive change is a storage boundary whose normal return type cannot contain a
|
||||
repository's bytes, backed by real SQL-byte and storage-read assertions for shared packages.
|
||||
|
||||
## Proposed implementation
|
||||
|
||||
### 1. One package service, existing storage providers
|
||||
|
||||
Add an owning service such as `skill-package-store.ts` for encoding, publication receipts,
|
||||
manifest reads, verified extraction and cache acquisition. `company-skills.ts` consumes it;
|
||||
the heartbeat only asks for a prepared skill. Provider details stay under `server/src/storage`.
|
||||
|
||||
The existing [storage API](../../server/src/storage/types.ts) supports streamed uploads,
|
||||
reads, HEAD and deletion, with [local](../../server/src/storage/local-disk-provider.ts)
|
||||
and [S3](../../server/src/storage/s3-provider.ts) providers. Reuse this infrastructure.
|
||||
Do not put packages in user-visible issue attachments or give them attachment retention.
|
||||
|
||||
Use a versioned `tar+gzip` bundle of raw file bytes and a separate bounded JSON manifest.
|
||||
The manifest contains original path, kind, byte length, SHA-256 and executable flag for
|
||||
each file. Archive entries live under `files/`; reserved format metadata stays outside
|
||||
that namespace. Preserve binary bytes and supported modes. No base64 repository JSON is
|
||||
needed in the stored archive. Stream encoding/compression to a bounded temporary file,
|
||||
hash it, then stream upload. Do not synchronously compress on the server event loop.
|
||||
|
||||
Keep the existing semantic content hash for migrated packages, so migration does not
|
||||
create new skill revisions. Separately record archive and manifest SHA-256 values and
|
||||
format version. Do not silently change the current hashing order or rely on an S3 ETag
|
||||
as a content checksum. Any future hash-algorithm change needs its own versioned identity.
|
||||
|
||||
A manifest is sufficient for a file tree; a cold file read may fetch the archive once.
|
||||
An archive deliberately favors simple bulk materialization over random remote file reads.
|
||||
If cold single-file traffic proves material, add an indexed/range-readable format later.
|
||||
Do not start with thousands of remote objects and database rows per package file.
|
||||
|
||||
### 2. Metadata schema and contracts
|
||||
|
||||
Extend the existing snapshot row with storage state/format and a receipt containing the
|
||||
provider, server-owned archive/manifest keys, hashes, compressed/uncompressed sizes and
|
||||
file count. Keep the existing snapshot ID and `(company_id, content_hash)` uniqueness.
|
||||
Add company-scoped relational constraints where a new package reference is introduced.
|
||||
|
||||
During migration a snapshot is one of: legacy inline; inline plus verified external copy;
|
||||
external only. An explicit discriminator distinguishes absent payload from an empty
|
||||
package. Keep pending upload attempts separate from externally readable versions, using
|
||||
small preparation receipts/leases in the owning snapshot storage metadata. A final version
|
||||
may reference only a ready package.
|
||||
|
||||
Repository-backed version rows retain their snapshot and entrypoint references but stop
|
||||
duplicating `candidate.files`. Preserve the directory-local view as a manifest projection.
|
||||
Existing historical version inventories must be migrated from their actual stored bytes;
|
||||
do not assume an older or checkpointed inventory exactly matches a current projection.
|
||||
If it differs, store that inventory as its own immutable package.
|
||||
|
||||
Support ordinary local, directory-only and forked versions with the same package reference
|
||||
in a second compatible writer path. Keep a bounded inline legacy reader until backfill
|
||||
finishes. Small searchable skill Markdown/metadata can remain on `company_skills`; the
|
||||
goal is to remove file-tree payloads, not prohibit useful database text.
|
||||
|
||||
Split internal `VersionMetadata` from content-bearing responses. Public
|
||||
`CompanySkillVersion.fileInventory`, the version routes, Studio and export consumers
|
||||
currently expect contents: preserve their response behavior through explicit hydration
|
||||
from storage, or add an opt-in metadata view and move clients to it. Never return an empty
|
||||
inventory to an old caller merely because the database payload was cleared. A storage
|
||||
location or opaque digest is not an authorization capability and need not be exposed.
|
||||
|
||||
### 3. Publication without long database transactions
|
||||
|
||||
```text
|
||||
authorize and scan immutable Git revision
|
||||
-> audit/filter the installed package
|
||||
-> obtain a bounded preparation claim for (company, content hash)
|
||||
-> write manifest + archive to unique attempt-owned storage keys
|
||||
-> verify stored lengths, hashes and manifest/file agreement
|
||||
-> short DB transaction: recheck source revision/lease/authority;
|
||||
publish ready receipt, versions, current pointers and activity together
|
||||
-> settle preparation receipt and release temporary files
|
||||
```
|
||||
|
||||
Perform provider I/O outside the publication transaction. Refresh's current source and
|
||||
lease fencing still decides whether the result may be published. A failed upload keeps
|
||||
all installed versions. A failed or uncertain commit must be reconciled before cleanup;
|
||||
never delete an object simply because the transaction call returned an error.
|
||||
|
||||
Use immutable unique attempt keys, with logical deduplication in PostgreSQL. This matters:
|
||||
`createStorageService.putFile` currently deletes its upload key on failure. Reusing a
|
||||
single deterministic key across writers could let a losing/failed writer delete a winner.
|
||||
Do not use that API as a create-if-absent content-addressed store. A concurrent winner's
|
||||
receipt is retained; the losing attempt's own keys become cleanup candidates only after
|
||||
its outcome is known. Preserve pending receipts through restart and company deletion.
|
||||
|
||||
Amazon S3's [strong read-after-write consistency](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html)
|
||||
helps verify a successful object write, but does not make an S3 write and SQL commit atomic.
|
||||
Qualify other supported S3-compatible backends separately. Pass cancellation through the
|
||||
storage facade; its current public read API omits the provider's AbortSignal capability,
|
||||
and uploads need bounded cancellation/settlement too. Avoid abandoned streams after timeouts.
|
||||
|
||||
### 4. Shared verified cache before per-skill projection
|
||||
|
||||
Use a cache identity containing instance/storage namespace, company, format and package
|
||||
content identity. Reauthorize the skill/version before every use. Deduplicating content
|
||||
within a company must not expand a private project's audience; authorization stays on
|
||||
the referencing skill/source, including forks and detached copies.
|
||||
|
||||
Acquire a package-scoped shared promise **before** opening legacy payloads or object
|
||||
storage. One cold request fetches/verifies/extracts the package; concurrent consumers reuse
|
||||
it. Different service instances in one process share this work. Use filesystem locking
|
||||
and atomic publication for processes sharing a volume; replicas with separate disks may
|
||||
each need one fetch. Keep cache ownership distinct from authorization and version pins.
|
||||
|
||||
Validate compressed size, expanded size, file count, normalized paths, duplicate/colliding
|
||||
entries, per-file digest and executable mode. Reject links, devices and traversal; never
|
||||
run repository code on extraction. An extracted directory or `.complete` marker alone
|
||||
is not proof of byte integrity. Warm runtime validation compares actual disk files with
|
||||
the hashed manifest, including unexpected files and modes, without rereading SQL payloads.
|
||||
Audit continues to inspect actual content and may require new policy scans independently
|
||||
of package immutability.
|
||||
|
||||
Keep the current self-contained `.paperclip-repository` skill layout initially. Build it
|
||||
from the shared package cache and reuse the manifest in materialization and audit. Preserve
|
||||
the existing company cache lock through final publication and durable run pinning. Stage
|
||||
slow downloads before that lock, then recheck authority/version identity inside it; if
|
||||
the caller already holds an outer preparation lock, change that owner deliberately rather
|
||||
than weakening pin safety. Cache eviction must respect active handles during staging.
|
||||
|
||||
Bound cached disk bytes, active downloads, expanded temporary bytes and in-memory manifests;
|
||||
do not introduce an unbounded process-wide map of decoded repository arrays. The existing
|
||||
61 MiB package cap is not a bound on concurrent copies or historical cache growth.
|
||||
|
||||
### 5. Avoid content work when nothing can be installed
|
||||
|
||||
After listing the immutable tree, return a successful empty discovery when no valid
|
||||
entrypoint path exists, before batching support blobs and constructing a repository package.
|
||||
Persist the source's checked commit/tree and next check, not its contents. The initial
|
||||
shallow Git fetch still transfers Git objects; do not claim zero acquisition cost.
|
||||
|
||||
At publication, require at least one selected, valid candidate needing a package. A scan
|
||||
with only excluded/invalid skills can retain candidate metadata and warnings without a
|
||||
durable payload. A scan in which upstream skills disappear retains previously installed
|
||||
versions and their packages. A later commit can still discover and import a new skill.
|
||||
|
||||
Keep current unchanged-commit/tree checks and work-budget accounting. Changing the
|
||||
five-minute refresh policy or adding provider events is independent work, not a prerequisite
|
||||
for moving bytes out of PostgreSQL.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Assessment |
|
||||
| --- | --- |
|
||||
| Store SHA; clone from upstream on use | Small database, but loses offline/deleted/revoked-upstream recovery and adds execution-time network work; does not reproduce filtered packages by itself |
|
||||
| Durable bare Git mirror with retained refs | Useful for acquisition deduplication; requires durable backup, pruning/GC rules, isolation and a filtered-export contract; does not naturally cover edited/generated skills |
|
||||
| Self-contained Git bundles in object storage | Viable for raw Git retention; Git distinguishes self-contained bundles from incremental bundles with prerequisites. More Git state than the installed-package contract needs |
|
||||
| Compress JSONB or add a large RAM cache | Can reduce a symptom; keeps database payload storage and cold-read cost, and adds heap pressure |
|
||||
| Always keep only a skill directory | Reintroduces known missing shared-file dependencies |
|
||||
| Per-file content-addressed storage | Better cross-version dedup and single-file access; adds object count, manifest graph and garbage-collection complexity before that benefit is measured |
|
||||
| Immutable package archive + manifest + disk cache | Recommended first implementation: existing providers, bounded bulk I/O, exact installed bytes, stable version references |
|
||||
|
||||
The [Git bundle documentation](https://git-scm.com/docs/git-bundle) describes prerequisite
|
||||
verification. A bundle or mirror is a real retained copy; a remote SHA is only a reference.
|
||||
A reusable Git acquisition cache remains worthwhile if profiling shows repeated shallow
|
||||
fetches dominate, but it must preserve the scanner's credential and caller isolation.
|
||||
|
||||
## Automatic conversion, acquisition and retention
|
||||
|
||||
The accepted follow-up removes the manual migration requirement. New writes default
|
||||
to external packages, and a finite delivery worker converts legacy rows, independently
|
||||
verifies storage and then clears duplicate payloads. It uses indexed row deadlines,
|
||||
transaction notifications and restart reconstruction, rather than per-turn work or
|
||||
an empty-table polling interval. The CLI is optional diagnostics. See the current
|
||||
[operational contract](../skill-package-storage.md) for upgrade/rollback details.
|
||||
|
||||
New Git imports persist a restore recipe alongside the package: repository identity,
|
||||
source authorization reference, exact commit, precise included-file manifest and
|
||||
packager version. Warm queries exclude this larger metadata. A cold miss first uses
|
||||
local verified files, then stored bytes, then legacy DB bytes, then the original
|
||||
Git commit using current source authorization. Every recovered file must match the
|
||||
saved size/mode/hash; moving branches and current filtering rules cannot change it.
|
||||
Historical packages without a provable recipe remain durable instead of inferring
|
||||
an origin from mutable current skill metadata.
|
||||
|
||||
Unused Git archives become eligible after 30 days, keeping current versions, explicit
|
||||
pins and active/recovering runs. Metadata and recipes survive expiry. Retired storage
|
||||
keys have another 30-day deletion grace. Locally authored and edited packages remain
|
||||
durable while referenced. Canonical extraction retains its bounded cold-fill eviction;
|
||||
version directories use existing retention; native runtime assets now have a 30-day
|
||||
idle lifetime, pin-aware cleanup and a shared publication/deletion lock. A pin can
|
||||
conservatively defer cleanup until the next relevant mutation/use or restart.
|
||||
|
||||
Source and package writes remain company-scoped. Saved grants are rechecked on Git
|
||||
acquisition, with a persisted five-minute failure backoff and existing singleflight.
|
||||
No upstream network request, storage HEAD, cleanup scan, or TTL write is added to
|
||||
warm package preparation. Native access timestamps are coalesced hourly on disk.
|
||||
|
||||
Lifecycle ownership remains in the package/native asset services. `index.ts` only
|
||||
registers them with the existing coordinator. `migration_at`/`maintenance_at` and the
|
||||
existing deletion receipts are durable truth; no additional worker queue table was
|
||||
introduced. Unfinished conversion blocks idle sleep. Optional retention may wait for
|
||||
next ingress wake. This does not establish deployed sleep or a Cloud alarm contract.
|
||||
|
||||
## Delivery slices and proof
|
||||
|
||||
| Slice | Owning files/area | Exit condition |
|
||||
| --- | --- | --- |
|
||||
| A. Storage boundary and compatibility | New `skill-package-store.ts`; storage facade; DB/shared version descriptors; `company-skills.ts` readers | Real local and S3-compatible upload/restore round trips; old rows work; metadata reads contain no file bytes; no behavior change yet |
|
||||
| B. Publisher and runtime | `skill-sources.ts`, scanner, version/fork/copy writers, package cache, cache retention | New package bytes never enter SQL; two or more selected skills share one cold package load; warm preparation does not reload it; zero-skill scan persists no package |
|
||||
| C. Consumers and migration | Version routes/client contracts, Studio context, export/disconnect, bounded migration command | Old API content behavior preserved; historical pins and copies restore; full current/legacy consumer matrix passes; backfill can restart safely |
|
||||
| D. Qualification and rollout | Existing skill suites and runner journey; operational docs/maps | Byte/performance evidence, fault injection, staged backfill, rollback boundary and cold recovery demonstrated |
|
||||
|
||||
Slices A–D form the complete database-storage change. Keep runtime transport deduplication
|
||||
separate: add a shared package bundle plus entrypoint descriptors to the runtime context,
|
||||
transfer each package once, and construct each discovery entrypoint in the destination.
|
||||
Preserve the existing fallback for adapters that require independent directories. Do not
|
||||
use host-only symlinks or shared writable hardlinks as a shortcut; qualify read-only mounts,
|
||||
copy-on-write copies or destination-local materialization against each supported transport.
|
||||
This later change reduces transfer/disk multiplication; it is not needed to remove DB bytes.
|
||||
|
||||
Use the existing `skill-sources`, `skill-version-cache`, `skill-version-retention`, project
|
||||
skills and native runner suites. Add a representative large synthetic package and an
|
||||
assembled PostgreSQL/storage benchmark, not mocks that eliminate serialization and I/O.
|
||||
|
||||
| Scenario | Required evidence |
|
||||
| --- | --- |
|
||||
| 1,349 files / approximately 22.6 MB JSON / 1, 2 and many entrypoints | Record exact fixture size, actual PostgreSQL DataRow bytes, object GET count/bytes, CPU, peak RSS, disk bytes and preparation duration; sizes need not reproduce private content |
|
||||
| One cold preparation using several skills from one package | Zero repository file-content bytes in SQL; at most one payload download per package per shared cache scope; equal delivered bytes/modes |
|
||||
| Warm preparation, audit and restart with intact disk cache | Zero repository payload bytes from SQL; zero payload GETs while cache is valid; actual integrity checks retained |
|
||||
| File tree and one-file read | Tree reads manifest only; first file read may fetch one archive, repeated reads reuse it; unrelated package contents never pass through SQL |
|
||||
| Unchanged refresh and no-skill repository | No new package/version payload; empty scan remains discoverable on a later commit; existing installed content survives removal/failure |
|
||||
| Storage failure, corrupt archive/manifest/cache, cancellation | No partially published version; old installation remains; no silent SHA refetch; unrelated requests remain responsive |
|
||||
| Concurrent import, uncertain commit, process crash, deletion | No winner object deleted; persisted attempt reconciliation; stable IDs and source lease/revision fencing |
|
||||
| Private project, revoked access, detached/forked content | Fresh application authorization on cache hits; no company/project boundary widening |
|
||||
| Pins, cold restore, Studio, forks, disconnect, export, retention | Equivalent supported files, binaries/modes and discovery policy; historical semantics preserved without upstream |
|
||||
| Backfill interrupted at each boundary | Retry changes no version IDs or contents; no early payload clearing; dual-reader rollback works |
|
||||
|
||||
Report SQL byte reduction separately from object-store traffic and execution latency.
|
||||
The target is **zero package file-content bytes in PostgreSQL responses for migrated
|
||||
packages**, not zero database queries or zero network bytes. No percentage CPU/latency
|
||||
saving is claimed until measured on the replacement.
|
||||
|
||||
Update `doc/PRODUCT.md`, `doc/SPEC-implementation.md`, `doc/SPEC.md`, `doc/DATABASE.md`,
|
||||
`doc/DEVELOPING.md` and recoverable-cleanup documentation to replace the database-byte
|
||||
restoration guarantee with durable-package restoration. Update affected skills/version
|
||||
feature and access map entries, and run `node scripts/check-maps.mjs`. Generate schema
|
||||
migrations with `pnpm db:generate` and follow migration snapshot rules. Run focused checks
|
||||
first; before a PR-ready implementation handoff run `pnpm -r typecheck`, `pnpm test:run`
|
||||
and `pnpm build`, plus the affected real runner journey. See the operational contract
|
||||
and verification record below for the checks actually completed in this checkout.
|
||||
|
||||
|
||||
## Implementation verification record
|
||||
|
||||
Implemented the storage migration, dual reader and guarded writer, bounded migration
|
||||
CLI, deletion receipts, package/runtime caches, metadata batching and native asset
|
||||
reuse. The approved follow-up adds default-on writes, automatic verified conversion,
|
||||
immutable Git restore recipes, persisted acquisition backoff, automatic object cleanup
|
||||
and native asset retention. The public API continues to hydrate explicit file-content responses. New source
|
||||
packages are prepared before publication transactions; existing generic authoring
|
||||
transactions keep their serialization boundary and use an independent upload connection.
|
||||
|
||||
- Full workspace typecheck passed; the server was rebuilt after the final refinements.
|
||||
- Full repository build passed. Migration numbering/safety and map contract checks passed.
|
||||
- Focused package, source, company-skill, retention/cache, concurrency, heartbeat preparation
|
||||
and native context suites passed. The final migration/retention rerun passed 36 tests;
|
||||
the final project-source/authoring-route rerun passed 92 tests.
|
||||
- Real loopback S3-compatible multipart upload and historical restoration passed; its
|
||||
disposable server/data were removed after verification.
|
||||
- The benchmark records 60 warm samples across 1, 2 and 10 selected entrypoints, plus
|
||||
cold and fresh-service cases. It includes actual PostgreSQL wire bytes and native
|
||||
asset creation. It does not prove deployed Railway latency or a live agent turn.
|
||||
- The serial full-suite invocation was deliberately cancelled and replaced with the
|
||||
repository's supported CI groups/shards for the same inventory. The broad run
|
||||
recorded setup timeouts and assertions in the skill concurrency, native recovery,
|
||||
workspace and other suites. It was stopped to isolate those failures before the
|
||||
remaining chat, route and workspace groups completed. The skill concurrency case,
|
||||
two representative recovery cases and the workspace assertion all passed on isolated
|
||||
reruns. This is **not** a clean full-suite result or complete CI qualification.
|
||||
|
||||
Per-entrypoint runtime/transport deduplication remains the separately scoped follow-up
|
||||
from this design. Writers and automatic conversion now default on; the explicit false
|
||||
override remains available for a coordinated reader-first rolling upgrade.
|
||||
|
||||
### Automatic lifecycle follow-up
|
||||
|
||||
The user approved automatic conversion, acquisition on miss and cleanup. This is
|
||||
implemented without a required data-migration command. Final proof: 30 focused tests
|
||||
passed, followed by 15 package/worker cases including additional missing-object and
|
||||
retention regressions; 73 source/native/retention integration cases passed. Idle-safety
|
||||
coverage passed, workspace typecheck/build passed, and the final server rebuild passed.
|
||||
The supported fresh-cluster test path bypassed shared-template startup failures.
|
||||
Two actual regressions found during development—JSONB key reordering during recipe
|
||||
verification and empty native-cache startup—were fixed and covered in the passing run.
|
||||
|
||||
Forty new warm benchmark samples (one and ten selected skills) plus cold/fresh-service
|
||||
samples retain five ordinary SQL queries and zero object/body reads. Receipts and timing
|
||||
limits are in the [operational guide](../skill-package-storage.md#automatic-lifecycle-follow-up-verification)
|
||||
and the linked JSON. No production cleanup, deployment or data migration was performed.
|
||||
The earlier broad full-suite limitation remains open.
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
The server materializes immutable skill versions under
|
||||
`<instance>/skills/<company>/__versions__/<skill>/<version>`. These are disposable
|
||||
disk copies: the company-scoped version and repository snapshot rows retain the
|
||||
contents, including binary files and executable flags. Normal skill resolution
|
||||
rebuilds a missing copy from the database without contacting its upstream source.
|
||||
disk copies: company-scoped versions reference durable packages in the configured
|
||||
storage backend, or retain legacy database inventories. Normal skill resolution
|
||||
rebuilds missing copies, preserving binary bytes and executable flags. New Git packages
|
||||
can also restore the exact original commit from their saved recipe using current
|
||||
repository access. Authored/legacy packages without that recipe require independent
|
||||
external durability before eviction; unavailable storage leaves those caches intact. Shared extracted packages have a
|
||||
separate cold-fill capacity policy. See [package storage](skill-package-storage.md).
|
||||
|
||||
## Skill version caches
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Installed skill package storage
|
||||
|
||||
Installed repository and editable-skill contents can live in the configured storage
|
||||
backend instead of PostgreSQL JSONB. The database keeps company-scoped package
|
||||
identities, storage receipts, source tracking, versions, permissions and run pins.
|
||||
The original repository tree, wrappers, binary bytes and executable flags retain
|
||||
their existing behavior. New Git packages also retain an immutable restore recipe;
|
||||
locally authored/edited packages and unproven legacy packages keep a durable copy.
|
||||
|
||||
## Automatic upgrade
|
||||
|
||||
No data-migration command is required. The normal schema upgrade applies migrations
|
||||
0398/0399. External writes and automatic conversion are enabled by default. Startup
|
||||
restores pending work from indexed `migration_at` / `maintenance_at` columns. The
|
||||
shared delivery coordinator converts four snapshots and four versions per batch,
|
||||
reading one legacy body at a time. It verifies the hash, uploads, reads the objects
|
||||
back, validates extraction, then clears the legacy JSONB. Version IDs, revision numbers
|
||||
and run pins stay unchanged. Failures retain the original bytes and retry after an hour.
|
||||
Bundled release inventories keep their existing small inline validation contract.
|
||||
|
||||
Readers can use legacy contents while conversion is pending, including when conversion
|
||||
commits between the metadata read and the content read. Conversion runs separately
|
||||
from turn preparation. Completed migration history has no recurring scan. Normal
|
||||
vacuum and backup retention determine when PostgreSQL reclaims physical space.
|
||||
|
||||
`PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED=false` is an explicit compatibility override
|
||||
for a coordinated rolling upgrade: it disables new external writes and automatic
|
||||
package maintenance. All processes must support the new reader before allowing
|
||||
external-only writes or clearing old payloads. Once automatic conversion runs, this
|
||||
reader is the rollback floor; an older binary needs rehydrated database contents.
|
||||
Use durable, backed-up local storage or S3-compatible storage for authored packages.
|
||||
The extracted cache is not that storage backend.
|
||||
|
||||
The optional `server/scripts/migrate-skill-packages.ts` command remains available for
|
||||
diagnostics and controlled batches. It defaults to dry run, supports
|
||||
`--phase=snapshots|versions|orphans|deletions`, `--company=<uuid>`, `--limit=1..50`,
|
||||
`--after=<uuid>`, `--apply` and `--clear-legacy`. It is not part of normal installation
|
||||
or upgrade. Never point it at another instance's database/storage configuration.
|
||||
|
||||
## Acquisition and retention
|
||||
|
||||
A missing package follows this order: verified local files, stored archive, legacy DB
|
||||
contents, then an authorized exact-commit Git fetch. A new Git recipe records repository
|
||||
identity, the original source, a full commit SHA, packager version, the precise included
|
||||
file manifest (paths, encodings, modes, sizes and hashes), and the content hash. It is
|
||||
loaded only on a cold miss, never with warm metadata queries. Recovery reads exactly
|
||||
those files without rerunning discovery or today's filtering rules. A reused repository
|
||||
name, wrong commit, changed file/mode/hash, missing original commit, or revoked source
|
||||
credentials fails explicitly; it never substitutes the latest branch. Concurrent misses
|
||||
coalesce and failed acquisition has a persisted five-minute backoff.
|
||||
|
||||
After 30 days, unused Git packages with a verified recipe can lose their cached archive
|
||||
and extracted copy while retaining their identity, manifest and recipe. Current library
|
||||
versions, explicit agent pins, active runs and unresolved recovery keep their packages.
|
||||
A source/version change schedules another eligibility check; packages held by a run or
|
||||
pin are also reconsidered on restart. This is conservative retention, not an immediate
|
||||
promise to purge on unassignment. Legacy packages without a provable recipe and authored
|
||||
or edited copies retain their durable bytes as long as a version references them.
|
||||
|
||||
Native runtime assets expire after 30 days without use. An hourly coalesced filesystem
|
||||
access lease protects preparation through durable run pinning. Cleanup shares the
|
||||
publication lock, preserves assets referenced by active/recovering runs, and defers if
|
||||
an unfinished input cannot be interpreted safely. After a pin blocks cleanup, the next
|
||||
asset use or startup reconsiders it. Missing or damaged copies rebuild from verified
|
||||
source bytes. The canonical package cache retains its capacity policy below, and runtime
|
||||
version directories retain their existing pin-aware retention.
|
||||
|
||||
Package/version publication and deletion use transaction-aware work notifications;
|
||||
local asset use signals its filesystem worker. Both reuse the delivery coordinator,
|
||||
restore deadlines on startup, pause in drain/standby, and stop on shutdown. With no
|
||||
pending conversion, retry, or eligible cache deadline they perform no recurring work.
|
||||
The supported live writer is the owning server; independent SQL writers/other servers
|
||||
need restart reconstruction or an explicit wake. No permanent DB listener is added.
|
||||
Conversion is an idle-sleep blocker while unfinished. Cache/deletion times are earliest
|
||||
eligibility, may wait until the next ingress wake, and do not claim an external Cloud
|
||||
alarm or measured host suspension.
|
||||
|
||||
## Execution and cache behavior
|
||||
|
||||
- New packages store one gzip tar archive and one manifest per company/content hash.
|
||||
The manifest contains paths, kinds, encodings, executable flags, byte sizes and
|
||||
SHA-256 values. PostgreSQL stores keys, hashes, sizes and ownership metadata.
|
||||
- Preparation uses metadata-only version/package queries. Multiple selected versions
|
||||
are batched, and a shared repository descriptor is read once per preparation.
|
||||
- Known selected repository skills skip unrelated library reconciliation. Selected
|
||||
rows and project visibility are still read fresh. Public rows are constrained with
|
||||
`access_source_id IS NULL` at the final read, so a concurrent scope change fails closed.
|
||||
Aliases, legacy operational skills, missing installations and mutable local sources
|
||||
retain the discovery/reconciliation path.
|
||||
- A cold package download is coalesced in process; the existing company cache lock
|
||||
serializes cross-process restoration and pinning. The archive and each extracted
|
||||
file are verified before atomic publication. Extraction rejects unexpected entries,
|
||||
path escapes, links, duplicate paths, wrong sizes, modes and digests.
|
||||
- Warm checks inspect directory entries and file identity/size/mode/mtime/ctime.
|
||||
Unchanged files avoid content reads and hashing. Changed identities are hashed again;
|
||||
altered runtime copies are rebuilt. No marker alone establishes integrity.
|
||||
- Native asset packaging also caches manifests and asset references, checking both
|
||||
the selected source and delivered asset on reuse. It retains no repository bodies.
|
||||
Source edits invalidate the reference; missing or corrupt copies rebuild from verified bytes.
|
||||
- Concurrent cold package I/O is limited to two operations, with a queue of at most 32.
|
||||
Packages are bounded to 10,000 files and 64 MiB decoded bytes. Manifest memory is
|
||||
limited to 16 MiB serialized size. Repository projection metadata is shared; generated
|
||||
wrappers/policies have a 16 MiB budget and 256-entry limit. The stat/digest cache holds
|
||||
at most 128,000 file records, with 24 concurrent file checks.
|
||||
Native skill asset manifests hold at most 32,000 file records and 128 entries.
|
||||
- Shared extracted packages target 512 MiB and 64 directories per company, trimming
|
||||
on cold fills only, with at most two eviction attempts and a ten-second storage
|
||||
verification budget per fill. This is a soft limit during a storage outage: cleanup preserves
|
||||
the last recoverable copy. Version projections retain the existing pin-aware cleanup.
|
||||
Abandoned package staging directories receive a one-hour crash grace.
|
||||
|
||||
Each entrypoint still has a self-contained runtime tree, and remote staging can
|
||||
duplicate those trees. This change does not introduce a new runner transport format
|
||||
or cross-entrypoint asset deduplication.
|
||||
|
||||
## Publication, cleanup, and backups
|
||||
|
||||
Source imports prepare and verify packages before opening their publication
|
||||
transaction. Generic authoring calls can arrive inside an existing edit/idempotency
|
||||
transaction; these retain that serialization lock during upload and use an independent
|
||||
package connection to avoid a one-connection pool deadlock. No upload happens on the
|
||||
unchanged warm preparation path.
|
||||
|
||||
Uploads use unique attempt keys. An incomplete attempt has a durable claim with a
|
||||
ten-minute expiry. Retries after expiry use fresh keys; an ambiguous database result
|
||||
never triggers deletion of a possibly successful upload. A database trigger records
|
||||
retired keys in `skill_package_deletions`, including company cascades. Promotion of
|
||||
an upload to a published receipt does not enqueue its objects for deletion.
|
||||
|
||||
The maintenance phases `orphans` and `deletions` have the same dry-run/cursor controls.
|
||||
`orphans` only removes unreferenced snapshot rows older than one day whose upload
|
||||
claim is settled. Foreign keys protect referenced versions. `deletions` rechecks that
|
||||
no live receipt owns the keys, deletes the two objects, confirms absence, then removes
|
||||
the deletion receipt. Failed attempts have at least one hour of grace; retired
|
||||
published objects have 30 days. These phases also run automatically from their saved
|
||||
deadlines. Do not apply a blanket bucket lifecycle rule: authored packages and legacy
|
||||
packages without exact restore recipes still require durable retention.
|
||||
|
||||
Database backups must be paired with object backups/history. Do not process deletion
|
||||
receipts while a retained database backup outside the 30-day window still needs those
|
||||
objects; extend receipt deadlines or retain object versions according to that backup
|
||||
policy. A storage provider/bucket/prefix change requires copying and verifying the
|
||||
objects first. The current storage facade does not find objects in an old bucket.
|
||||
|
||||
## Verification
|
||||
|
||||
`skill-package-store.test.ts` exercises real PostgreSQL/local storage, cold/warm
|
||||
reads, same-size edits, offline historical restoration, missing durable objects,
|
||||
upload recovery, company-deletion receipts, cancellation and resumable migration.
|
||||
A reader that obtained its descriptor before the legacy clear pass follows the new
|
||||
receipt, including when its old manifest is already cached. Existing source,
|
||||
version-cache, retention, concurrency, authoring and runtime-context suites preserve
|
||||
their original behavior checks. `runtime-context.test.ts` checks warm asset reuse,
|
||||
source edits and repair through the normal native context builder. Additional recovery,
|
||||
worker-lifecycle and asset-retention tests cover exact-commit restoration, automatic
|
||||
conversion, persisted retry, idle/drain behavior and rebuilding expired native copies.
|
||||
|
||||
The native preparation benchmark accepts `--package-store=external` and
|
||||
`--native-assets=true`. It reports elapsed time, PostgreSQL wire response bytes,
|
||||
root-pool query count, object GET count/bytes, process RSS and preparation spans.
|
||||
Use `--snapshot-file=<inventory.json>` for a representative saved package,
|
||||
`--actor=agent --staging-shape=true` for authorization and unrelated-library load,
|
||||
and `--network-delay-ms=5` for an emulated database delay. Synthetic local timings
|
||||
are regression evidence, not a claim about deployed Railway latency.
|
||||
|
||||
|
||||
To generate the same-size synthetic fixture without copying a private repository:
|
||||
|
||||
```sh
|
||||
python3 - <<'PYTHON'
|
||||
import hashlib, json
|
||||
files = [{"path": "skills/diagnostic/SKILL.md", "kind": "skill", "content":
|
||||
"---\nname: diagnostic\ndescription: Native preparation benchmark\n---\nUse the bundled repository.\n"}]
|
||||
for i in range(1348):
|
||||
text = hashlib.shake_256(str(i).encode()).hexdigest(8300)
|
||||
content = "\n".join(text[n:n + 64] for n in range(0, len(text), 64))[:16600]
|
||||
files.append({"path": f"assets/{i}.txt", "kind": "reference", "content": content})
|
||||
with open("/tmp/skill-package-benchmark.json", "w") as output:
|
||||
json.dump(files, output, separators=(",", ":"))
|
||||
PYTHON
|
||||
pnpm --filter @paperclipai/server exec tsx scripts/benchmark-native-skill-preparation.ts --snapshot-file=/tmp/skill-package-benchmark.json --package-store=external --native-assets=true --actor=agent --staging-shape=true --network-delay-ms=5 --selected-skills=10 --warm-runs=20
|
||||
```
|
||||
|
||||
The fixture generator reproduces the recorded file counts and JSON size, with new
|
||||
synthetic content. Use `--selected-skills=1`, `2`, or `10` for comparable cases.
|
||||
|
||||
## Measured results, 2026-10-28
|
||||
|
||||
[Recorded samples](plans/2026-10-28-skill-package-performance-results.json) use a
|
||||
synthetic 1,349-file, 22,799,129-byte JSON inventory. The multi-skill cases add one
|
||||
entrypoint per additional selected skill. Each case includes native asset preparation,
|
||||
fresh agent authorization, 155 unrelated library skills, and 5 ms of database network
|
||||
delay in each direction. The table summarizes all 20 numbered warm samples; a fresh
|
||||
service instance in the same process was measured separately.
|
||||
|
||||
| Selected skills | Warm median | Warm p95 | PostgreSQL response bytes | Ordinary queries | Object GETs |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 245 ms | 326 ms | 19,288 | 5 | 0 |
|
||||
| 2 | 298 ms | 332 ms | 20,312 | 5 | 0 |
|
||||
| 10 | 573 ms | 674 ms | 28,536 | 5 | 0 |
|
||||
|
||||
All warm cases transferred zero package file-content bytes through SQL and zero
|
||||
object bytes. Query counts exclude the dedicated advisory-lock connection; wire bytes
|
||||
include the measured proxy traffic. Cold preparation fetched one manifest and one
|
||||
archive, about 13.45 MB total, even with ten selected entrypoints. Cold elapsed times
|
||||
were 3.7, 6.3 and 11.0 seconds respectively. These local samples ran alongside other
|
||||
verification work and do not establish production latency or a CPU reduction against
|
||||
an unchanged baseline.
|
||||
|
||||
Median process CPU per warm preparation was 83/175/820 ms for 1/2/10 skills; filesystem
|
||||
checks run concurrently, so CPU can exceed wall time. Process peak RSS was about
|
||||
887/937/1,235 MB, including the application, synthetic fixture and cold allocations;
|
||||
these are not incremental cache-memory measurements. Allocated instance file bytes
|
||||
were about 97/152/596 MB. Runtime/native tree duplication remains visible here and is
|
||||
a separate optimization. Cache budgets also mean sufficiently large selected sets or
|
||||
many active companies can evict metadata/digests and require new hashing or asset
|
||||
assembly; the warm timing is not a constant-time guarantee for arbitrary workloads.
|
||||
|
||||
The real S3-compatible proof used a disposable loopback MinIO server built from its
|
||||
official `RELEASE.2025-04-22T22-12-26Z` source. An 18,008,626-byte archive containing
|
||||
22 files (18,000,095 decoded bytes) passed multipart upload, independent read-back
|
||||
verification and cold restoration. `server/scripts/verify-skill-package-s3.ts` repeats
|
||||
this test against a loopback endpoint and creates/deletes its own bucket and database.
|
||||
No production storage or Railway instance was changed.
|
||||
|
||||
|
||||
Workspace typecheck, build, migration checks and focused integration tests passed.
|
||||
The attempted full repository test run did not complete cleanly: parallel shards
|
||||
reported fixture setup timeouts and assertions, then were stopped for isolation.
|
||||
The affected skill concurrency assertion and sampled recovery/workspace failures passed
|
||||
on isolated reruns. The remaining broad inventory is not claimed as verified; see the
|
||||
[implementation record](plans/2026-10-28-skill-package-storage.md#implementation-verification-record).
|
||||
|
||||
## Automatic lifecycle follow-up verification
|
||||
|
||||
The final implementation passed 30 focused package/recovery/worker/native tests,
|
||||
then 15 package/worker tests including two additional regressions for missing stored
|
||||
objects with a live receipt and retaining unproven historical bytes. Source-sync,
|
||||
version-retention and native integrations passed 73 tests. The idle-safety suite also
|
||||
passed. Full workspace typecheck/build passed; the final server was rebuilt after the
|
||||
manifest normalization fix. Migrations and map checks passed.
|
||||
|
||||
The shared PostgreSQL test-template path timed out (and exited a standalone benchmark
|
||||
before measurement). The passing final runs used the supported
|
||||
`PAPERCLIP_TEST_POSTGRES_TEMPLATE=0` fresh-cluster path. Earlier retirement assertions
|
||||
caught PostgreSQL JSONB key reordering; manifest serialization now preserves its v1
|
||||
wire encoding after a DB round trip. An empty native cache startup case was also fixed.
|
||||
|
||||
The final 20-sample warm runs measured:
|
||||
|
||||
| Selected skills | Median | p95 | SQL response bytes | Ordinary queries | Object reads |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 256 ms | 302 ms | 19,423 | 5 | 0 |
|
||||
| 10 | 691 ms | 816 ms | 28,969 | 5 | 0 |
|
||||
|
||||
All warm samples still transfer zero package bodies through SQL and zero object bytes.
|
||||
The larger restore recipe stays out of those queries. Ten-skill cold preparation was
|
||||
slower on this busy host; the raw receipts retain that result as well as warm samples.
|
||||
The earlier full repository test attempt remains incomplete; this follow-up does not
|
||||
claim a clean full-suite result, deployed Railway measurements, or a live agent run.
|
||||
@@ -13,6 +13,22 @@ Each capability needs operation-level review before its entire scope can be
|
||||
qualified. No live agent execution is claimed. Follow the [map contract](../README.md)
|
||||
for format, permission checks, evidence and maintenance.
|
||||
|
||||
Cold Git package recovery rechecks the original source's current setup identity,
|
||||
company membership, policy and repository grant. It never borrows another company's
|
||||
credentials or substitutes a moving branch for the saved commit. A missing/revoked
|
||||
source fails with a reconnect error and persisted retry backoff. Warm access still
|
||||
uses the ordinary skill/project permission check.
|
||||
|
||||
External package storage changes persistence, not agent authority. Runtime selection
|
||||
still checks current company/project access before consulting immutable package caches;
|
||||
no storage key is exposed as an agent capability. Public selected rows are constrained
|
||||
to `access_source_id IS NULL` at their final read. Warm package and native asset caches
|
||||
hold file metadata, never authorization decisions. The
|
||||
[concurrency regression](../../server/src/__tests__/company-skills-runtime-concurrency.test.ts)
|
||||
covers separate actors and revoked project access; the
|
||||
[package regression](../../server/src/__tests__/skill-package-store.test.ts) covers
|
||||
company isolation and warm-cache behavior. This does not claim live agent qualification.
|
||||
|
||||
## `discovery`
|
||||
|
||||
Capability ID: `skills/discovery`.
|
||||
|
||||
@@ -81,6 +81,14 @@ version pins. The [concurrency regression](../../server/src/__tests__/company-sk
|
||||
checks shared work and separate actor access; the cache tests check that cleanup
|
||||
cannot remove a snapshot before the run stores its pins.
|
||||
|
||||
The [package storage regression](../../server/src/__tests__/skill-package-store.test.ts)
|
||||
covers external snapshots, automatic legacy conversion and exact-commit recovery. A warm selected-repository
|
||||
check must perform no object reads or repository-content SQL. Source/library
|
||||
reconciliation remains available for discovery and mutable sources. The native context
|
||||
regression checks that warm asset packaging reuses verified files, repairs missing/corrupt copies,
|
||||
and notices source edits. Verify both writer-switch states during rollout; see
|
||||
[package storage](../../doc/skill-package-storage.md).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The release picker requires `enableBetaSkills`. Do not invent an Instructions
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
CREATE TABLE IF NOT EXISTS "skill_package_deletions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"archive_key" text NOT NULL,
|
||||
"manifest_key" text NOT NULL,
|
||||
"delete_after" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ALTER COLUMN "file_inventory" SET DEFAULT '[]'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "storage" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "upload" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "restore" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "migration_at" timestamp with time zone DEFAULT now();--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "maintenance_at" timestamp with time zone DEFAULT now();--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_repository_snapshots" ADD COLUMN IF NOT EXISTS "restore_retry_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "package_snapshot_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "package_file_map" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "migration_at" timestamp with time zone DEFAULT now();--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "skill_package_deletions_due_idx" ON "skill_package_deletions" USING btree ("delete_after","id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "company_skill_versions" ADD CONSTRAINT "company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk" FOREIGN KEY ("package_snapshot_id") REFERENCES "public"."company_skill_repository_snapshots"("id") ON DELETE restrict ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skill_snapshots_migration_idx" ON "company_skill_repository_snapshots" USING btree ("migration_at") WHERE "company_skill_repository_snapshots"."migration_at" is not null;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skill_snapshots_maintenance_idx" ON "company_skill_repository_snapshots" USING btree ("maintenance_at") WHERE "company_skill_repository_snapshots"."maintenance_at" is not null;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skill_versions_migration_idx" ON "company_skill_versions" USING btree ("migration_at") WHERE "company_skill_versions"."migration_at" is not null;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skill_versions_package_snapshot_idx" ON "company_skill_versions" USING btree ("package_snapshot_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skill_versions_repository_snapshot_idx" ON "company_skill_versions" USING btree ("repository_snapshot_id");
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION paperclip_queue_skill_package_deletion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF OLD.storage IS NOT NULL AND (TG_OP = 'DELETE' OR OLD.storage IS DISTINCT FROM NEW.storage) THEN
|
||||
INSERT INTO skill_package_deletions(company_id, provider, archive_key, manifest_key, delete_after)
|
||||
VALUES (OLD.company_id, OLD.storage->>'provider', OLD.storage->>'archiveKey', OLD.storage->>'manifestKey', now() + interval '30 days');
|
||||
END IF;
|
||||
IF OLD.upload IS NOT NULL AND (TG_OP = 'DELETE' OR (
|
||||
OLD.upload->>'token' IS DISTINCT FROM NEW.upload->>'token'
|
||||
AND OLD.upload->>'archiveKey' IS DISTINCT FROM NEW.storage->>'archiveKey'
|
||||
)) THEN
|
||||
INSERT INTO skill_package_deletions(company_id, provider, archive_key, manifest_key, delete_after)
|
||||
VALUES (OLD.company_id, OLD.upload->>'provider', OLD.upload->>'archiveKey', OLD.upload->>'manifestKey',
|
||||
greatest(now() + interval '1 hour', (OLD.upload->>'expiresAt')::timestamptz));
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS skill_package_deletion_receipts ON company_skill_repository_snapshots;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER skill_package_deletion_receipts AFTER UPDATE OF storage, upload OR DELETE
|
||||
ON company_skill_repository_snapshots FOR EACH ROW EXECUTE FUNCTION paperclip_queue_skill_package_deletion();
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION paperclip_schedule_skill_package_cleanup() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE version_id uuid;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'company_skills' THEN
|
||||
version_id := OLD.current_version_id;
|
||||
IF version_id IS NOT NULL AND (TG_OP = 'DELETE' OR version_id IS DISTINCT FROM NEW.current_version_id) THEN
|
||||
UPDATE company_skill_repository_snapshots SET maintenance_at = now() + interval '30 days'
|
||||
WHERE id IN (SELECT repository_snapshot_id FROM company_skill_versions WHERE id = version_id
|
||||
UNION SELECT package_snapshot_id FROM company_skill_versions WHERE id = version_id);
|
||||
END IF;
|
||||
ELSE
|
||||
UPDATE company_skill_repository_snapshots SET maintenance_at = now() + interval '1 day'
|
||||
WHERE id = OLD.repository_snapshot_id OR id = OLD.package_snapshot_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS skill_package_current_cleanup ON company_skills;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER skill_package_current_cleanup AFTER UPDATE OF current_version_id OR DELETE ON company_skills
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_schedule_skill_package_cleanup();
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS skill_package_version_cleanup ON company_skill_versions;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER skill_package_version_cleanup AFTER DELETE ON company_skill_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_schedule_skill_package_cleanup();
|
||||
@@ -70,14 +70,77 @@ export type CompanySkillVersionFileInventoryEntry = CompanySkillFileInventoryEnt
|
||||
executable?: boolean;
|
||||
};
|
||||
|
||||
export type SkillPackageStorageReceipt = {
|
||||
format: "tar-gzip-v1";
|
||||
provider: "local_disk" | "s3";
|
||||
archiveKey: string;
|
||||
archiveSha256: string;
|
||||
archiveBytes: number;
|
||||
manifestKey: string;
|
||||
manifestSha256: string;
|
||||
manifestBytes: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type SkillPackageUpload = {
|
||||
token: string;
|
||||
legacy?: boolean;
|
||||
provider: "local_disk" | "s3";
|
||||
archiveKey: string;
|
||||
manifestKey: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
/** Exact input to the v1 packager. File bodies never live in this recipe. */
|
||||
export type SkillPackageRestoreRecipe = {
|
||||
version: 1;
|
||||
sourceId: string;
|
||||
repositoryId: string;
|
||||
repositoryUrl: string;
|
||||
commitSha: string;
|
||||
skillPath: string | null;
|
||||
manifest: {
|
||||
format: "paperclip-skill-package/v1";
|
||||
contentHash: string;
|
||||
totalBytes: number;
|
||||
files: Array<{ path: string; kind: CompanySkillFileInventoryEntry["kind"]; encoding: "utf8" | "base64";
|
||||
executable: boolean; size: number; sha256: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillPackageFileMapping = CompanySkillFileInventoryEntry & { sourcePath: string };
|
||||
|
||||
/** Durable deletion receipts survive company/snapshot deletion until storage confirms removal. */
|
||||
export const skillPackageDeletions = pgTable("skill_package_deletions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
archiveKey: text("archive_key").notNull(),
|
||||
manifestKey: text("manifest_key").notNull(),
|
||||
deleteAfter: timestamp("delete_after", { withTimezone: true }).notNull(),
|
||||
}, table => ({ due: index("skill_package_deletions_due_idx").on(table.deleteAfter, table.id) }));
|
||||
|
||||
/** Immutable repository bytes shared by all skill versions from the same package. */
|
||||
export const companySkillRepositorySnapshots = pgTable("company_skill_repository_snapshots", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
contentHash: text("content_hash").notNull(),
|
||||
fileInventory: jsonb("file_inventory").$type<CompanySkillVersionFileInventoryEntry[]>().notNull(),
|
||||
// Legacy payload only. New packages retain bytes in the configured storage backend.
|
||||
fileInventory: jsonb("file_inventory").$type<CompanySkillVersionFileInventoryEntry[]>().notNull().default([]),
|
||||
storage: jsonb("storage").$type<SkillPackageStorageReceipt>(),
|
||||
upload: jsonb("upload").$type<SkillPackageUpload>(),
|
||||
restore: jsonb("restore").$type<SkillPackageRestoreRecipe>(),
|
||||
// These deadlines are consumed by the delivery coordinator, never polled per turn.
|
||||
migrationAt: timestamp("migration_at", { withTimezone: true }).defaultNow(),
|
||||
maintenanceAt: timestamp("maintenance_at", { withTimezone: true }).defaultNow(),
|
||||
restoreRetryAt: timestamp("restore_retry_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({ content: uniqueIndex("company_skill_repository_snapshots_content_idx").on(table.companyId, table.contentHash) }));
|
||||
}, table => ({
|
||||
content: uniqueIndex("company_skill_repository_snapshots_content_idx").on(table.companyId, table.contentHash),
|
||||
migration: index("company_skill_snapshots_migration_idx").on(table.migrationAt).where(sql`${table.migrationAt} is not null`),
|
||||
maintenance: index("company_skill_snapshots_maintenance_idx").on(table.maintenanceAt).where(sql`${table.maintenanceAt} is not null`),
|
||||
}));
|
||||
|
||||
export const companySkillVersions = pgTable(
|
||||
"company_skill_versions",
|
||||
@@ -87,6 +150,9 @@ export const companySkillVersions = pgTable(
|
||||
companySkillId: uuid("company_skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }),
|
||||
repositorySnapshotId: uuid("repository_snapshot_id").references(() => companySkillRepositorySnapshots.id, { onDelete: "restrict" }),
|
||||
repositorySkillPath: text("repository_skill_path"),
|
||||
packageSnapshotId: uuid("package_snapshot_id").references(() => companySkillRepositorySnapshots.id, { onDelete: "restrict" }),
|
||||
packageFileMap: jsonb("package_file_map").$type<SkillPackageFileMapping[]>(),
|
||||
migrationAt: timestamp("migration_at", { withTimezone: true }).defaultNow(),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
label: text("label"),
|
||||
releaseId: text("release_id"),
|
||||
@@ -98,6 +164,9 @@ export const companySkillVersions = pgTable(
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
migrationIdx: index("company_skill_versions_migration_idx").on(table.migrationAt).where(sql`${table.migrationAt} is not null`),
|
||||
packageSnapshotIdx: index("company_skill_versions_package_snapshot_idx").on(table.packageSnapshotId),
|
||||
repositorySnapshotIdx: index("company_skill_versions_repository_snapshot_idx").on(table.repositorySnapshotId),
|
||||
companySkillRevisionUniqueIdx: uniqueIndex("company_skill_versions_skill_revision_idx").on(
|
||||
table.companySkillId,
|
||||
table.revisionNumber,
|
||||
|
||||
@@ -193,13 +193,14 @@ export {
|
||||
} from "./tool_access.js";
|
||||
export {
|
||||
companySkills,
|
||||
companySkillVersions, companySkillRepositorySnapshots,
|
||||
companySkillVersions, companySkillRepositorySnapshots, skillPackageDeletions,
|
||||
companySkillStars,
|
||||
companySkillComments,
|
||||
companySkillTestInputs,
|
||||
companySkillTestRunTemplates,
|
||||
companySkillTestRuns,
|
||||
} from "./company_skills.js";
|
||||
export type { SkillPackageStorageReceipt, SkillPackageUpload, SkillPackageRestoreRecipe, SkillPackageFileMapping } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"smol-toml": "^1.9.1",
|
||||
"ssh2": "^1.17.0",
|
||||
"svix": "1.76.1",
|
||||
"tar-stream": "3.2.0",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "^4.4.3",
|
||||
"@opentelemetry/resources": "2.10.0"
|
||||
@@ -142,6 +143,7 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/tar-stream": "^3.1.5",
|
||||
"@types/ws": "^8.18.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
|
||||
@@ -18,11 +18,20 @@ import { authUsers, companyMemberships, companySkillSources, heartbeatRuns, proj
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { createRunPreparationTiming, withRunExecutionTiming } from "../src/services/execution-preparation-timing.js";
|
||||
import { repositorySkillProjection } from "../src/services/skill-repository-package.js";
|
||||
import { materializeSelectedSkills } from "../src/services/native-runtime/runtime-context.js";
|
||||
import { skillPackageStore } from "../src/services/skill-package-store.js";
|
||||
import { getStorageService } from "../src/storage/index.js";
|
||||
import { skillFileBytes, skillSnapshotHash } from "../src/services/skill-snapshot.js";
|
||||
|
||||
const argument = (name: string) => process.argv.find(value => value.startsWith(`${name}=`))?.slice(name.length + 1);
|
||||
const exportOtlp = argument("--export-otlp") === "true";
|
||||
const authenticated = argument("--actor") === "agent";
|
||||
const warmRuns = Number(argument("--warm-runs") ?? 2);
|
||||
assert.ok(Number.isSafeInteger(warmRuns) && warmRuns >= 1 && warmRuns <= 100);
|
||||
const selectedSkillCount = Number(argument("--selected-skills") ?? 1);
|
||||
assert.ok(Number.isSafeInteger(selectedSkillCount) && selectedSkillCount >= 1 && selectedSkillCount <= 100);
|
||||
const nativeAssets = argument("--native-assets") === "true";
|
||||
const externalPackages = argument("--package-store") === "external";
|
||||
const stagingShape = argument("--staging-shape") === "true";
|
||||
// This standalone benchmark owns its tracer. Load the service graph with the
|
||||
// app-wide SDK disabled, then restore the operator's export configuration.
|
||||
@@ -44,7 +53,9 @@ const sourceRevision = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
}).trim();
|
||||
const sourceHash = createHash("sha256");
|
||||
for (const file of [import.meta.url, new URL("../src/services/company-skills.ts", import.meta.url),
|
||||
new URL("../src/services/skill-version-cache.ts", import.meta.url), new URL("../src/services/execution-preparation-timing.ts", import.meta.url)]) {
|
||||
new URL("../src/services/skill-version-cache.ts", import.meta.url), new URL("../src/services/execution-preparation-timing.ts", import.meta.url),
|
||||
...["skill-package-store", "skill-package-runtime", "skill-package-files", "skill-package-cache", "file-manifest-cache",
|
||||
"skill-package-recovery", "skill-package-work", "native-runtime/runtime-context", "native-runtime/skill-asset-cache", "native-runtime/runtime-asset-retention"].map(name => new URL(`../src/services/${name}.ts`, import.meta.url))]) {
|
||||
sourceHash.update(await fs.readFile(new URL(file)));
|
||||
}
|
||||
const sourceChangesHash = sourceHash.digest("hex");
|
||||
@@ -55,6 +66,14 @@ const files: CompanySkillVersionFileInventoryEntry[] = snapshotFile
|
||||
? JSON.parse(await fs.readFile(snapshotFile, "utf8"))
|
||||
: [{ path: entrypoint, kind: "skill", content: "---\nname: diagnostic\ndescription: Native preparation benchmark\n---\nReport the model ID.\n" },
|
||||
...Array.from({ length: 207 }, (_, index) => ({ path: `assets/${index}.txt`, kind: "reference" as const, content: "x".repeat(52_000) }))];
|
||||
const entrypoints = [entrypoint];
|
||||
for (let i = 1; i < selectedSkillCount; i++) {
|
||||
const source = files.find(file => file.path === entrypoint)!;
|
||||
const next = `skills/diagnostic-${i}/SKILL.md`;
|
||||
assert.ok(!files.some(file => file.path === next));
|
||||
files.push({ ...source, path: next, content: source.content.replace("name: diagnostic", `name: diagnostic-${i}`) });
|
||||
entrypoints.push(next);
|
||||
}
|
||||
const projected = repositorySkillProjection(files, entrypoint);
|
||||
const previous = { home: process.env.PAPERCLIP_HOME, instance: process.env.PAPERCLIP_INSTANCE_ID, endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT };
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "native-skill-benchmark-"));
|
||||
@@ -113,12 +132,15 @@ try {
|
||||
catch (error) { await cleanup(); throw error; }
|
||||
return { connectionString, cleanup };
|
||||
}
|
||||
console.error("benchmark: starting isolated database");
|
||||
database = await startDatabase();
|
||||
console.error("benchmark: seeding isolated fixture");
|
||||
const networkDelayMs = Number(argument("--network-delay-ms") ?? 0);
|
||||
assert.ok(Number.isFinite(networkDelayMs) && networkDelayMs >= 0 && networkDelayMs <= 100);
|
||||
const target = new URL(database.connectionString);
|
||||
const upstreamPort = Number(target.port);
|
||||
proxy = networkDelayMs ? createServer(client => {
|
||||
let sqlResponseBytes = 0;
|
||||
proxy = createServer(client => {
|
||||
const upstream = connect({ host: "127.0.0.1", port: upstreamPort });
|
||||
for (const socket of [client, upstream]) {
|
||||
socket.setNoDelay(true);
|
||||
@@ -126,6 +148,7 @@ try {
|
||||
socket.on("error", () => { client.destroy(); upstream.destroy(); });
|
||||
socket.on("close", () => { connections.delete(socket); });
|
||||
}
|
||||
upstream.on("data", chunk => { sqlResponseBytes += chunk.length; });
|
||||
const relay = (source: Socket, destination: Socket) => {
|
||||
let pendingBytes = 0;
|
||||
source.on("data", chunk => {
|
||||
@@ -142,7 +165,7 @@ try {
|
||||
source.on("end", () => setTimeout(() => destination.end(), networkDelayMs));
|
||||
};
|
||||
relay(client, upstream); relay(upstream, client);
|
||||
}) : null;
|
||||
});
|
||||
if (proxy) {
|
||||
const server = proxy;
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
||||
@@ -154,8 +177,7 @@ try {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const userId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const key = `company/${companyId}/diagnostic`;
|
||||
const selections = new Map<string, string>();
|
||||
const samples: unknown[] = [];
|
||||
let rootPoolQueries = 0;
|
||||
db.$client.options.debug = () => { rootPoolQueries++; };
|
||||
@@ -188,27 +210,50 @@ try {
|
||||
})));
|
||||
await db.insert(companySkills).values({ companyId, key: "fixture/catalog", slug: "catalog", name: "Unselected catalog", sourceType: "catalog", markdown: "x".repeat(1_578) });
|
||||
}
|
||||
await db.insert(companySkills).values({ id: skillId, companyId, key, slug: "diagnostic", name: "Diagnostic",
|
||||
markdown: files.find(file => file.path === entrypoint)!.content, sourceType: "github",
|
||||
sourceLocator: "https://example.invalid/immutable-fixture", metadata: { packageMode: "repository", snapshotHash: "fixture" } });
|
||||
const [snapshot] = await db.insert(companySkillRepositorySnapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skillId, revisionNumber: 1,
|
||||
repositorySnapshotId: snapshot!.id, repositorySkillPath: entrypoint }).returning();
|
||||
await db.update(companySkills).set({ currentVersionId: version!.id }).where(eq(companySkills.id, skillId));
|
||||
const snapshotId = externalPackages ? await skillPackageStore(db).prepare(companyId, files)
|
||||
: (await db.insert(companySkillRepositorySnapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning())[0]!.id;
|
||||
let objectGets = 0, objectReadBytes = 0;
|
||||
const backend = getStorageService();
|
||||
const getObject = backend.getObject.bind(backend);
|
||||
backend.getObject = async (...args) => {
|
||||
const object = await getObject(...args);
|
||||
objectGets++; objectReadBytes += object.contentLength ?? 0;
|
||||
return object;
|
||||
};
|
||||
for (const [index, selectedPath] of entrypoints.entries()) {
|
||||
const skillId = randomUUID();
|
||||
const key = `company/${companyId}/diagnostic-${index}`;
|
||||
await db.insert(companySkills).values({ id: skillId, companyId, key, slug: `diagnostic-${index}`, name: `Diagnostic ${index}`,
|
||||
markdown: files.find(file => file.path === selectedPath)!.content, sourceType: "github",
|
||||
sourceLocator: "https://example.invalid/immutable-fixture", metadata: { packageMode: "repository", snapshotHash: "fixture" } });
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skillId, revisionNumber: 1,
|
||||
repositorySnapshotId: snapshotId, repositorySkillPath: selectedPath }).returning();
|
||||
await db.update(companySkills).set({ currentVersionId: version!.id }).where(eq(companySkills.id, skillId));
|
||||
selections.set(key, version!.id);
|
||||
}
|
||||
const benchmarkDb = db;
|
||||
const service = companySkillService(db);
|
||||
for (const label of ["cold", "warm-1", "warm-2", "warm-new-service"]) {
|
||||
console.error("benchmark: measuring preparations");
|
||||
for (const label of ["cold", ...Array.from({ length: warmRuns }, (_, index) => `warm-${index + 1}`), "warm-new-service"]) {
|
||||
exporter.reset();
|
||||
rootPoolQueries = 0;
|
||||
const selected: ReturnType<typeof companySkillService> = label === "warm-new-service" ? companySkillService(db) : service;
|
||||
const runId = randomUUID();
|
||||
if (authenticated) await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, contextSnapshot: { executionPolicy: {} } });
|
||||
rootPoolQueries = 0;
|
||||
sqlResponseBytes = 0; objectGets = 0; objectReadBytes = 0;
|
||||
let nativeAssetMs = 0;
|
||||
const cpuStarted = process.cpuUsage();
|
||||
const measuredStarted = performance.now();
|
||||
const resolve = () => selected.withRuntimeSkillEntries(companyId, {
|
||||
onlyKeys: new Set([key]), versionSelections: new Map([[key, version!.id]]), snapshotLatest: true,
|
||||
onlyKeys: new Set(selections.keys()), versionSelections: selections, snapshotLatest: true,
|
||||
}, async entries => {
|
||||
if (authenticated) await benchmarkDb.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions: { [key]: version!.id }, skillSelectionSnapshot: true })}::jsonb` }).where(eq(heartbeatRuns.id, runId));
|
||||
if (authenticated) await benchmarkDb.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions: Object.fromEntries(selections), skillSelectionSnapshot: true })}::jsonb` }).where(eq(heartbeatRuns.id, runId));
|
||||
if (nativeAssets) {
|
||||
const started = performance.now();
|
||||
await materializeSelectedSkills({ paperclipSkillSync: { desiredSkills: [...selections.keys()] } }, entries, true);
|
||||
nativeAssetMs = performance.now() - started;
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
const entries: Awaited<ReturnType<typeof selected.listRuntimeSkillEntries>> = await withRunExecutionTiming(runId, () => createRunPreparationTiming(runId)("configuration_skills", () => authenticated
|
||||
@@ -216,11 +261,15 @@ try {
|
||||
: resolve()));
|
||||
const elapsedMs = performance.now() - measuredStarted;
|
||||
const queryCount = rootPoolQueries;
|
||||
assert.equal(entries.length, 1);
|
||||
const cpu = process.cpuUsage(cpuStarted);
|
||||
const measuredIo = { nativeAssetMs, sqlResponseBytes, objectGets, objectReadBytes, rssBytes: process.memoryUsage().rss,
|
||||
cpuUserMs: cpu.user / 1000, cpuSystemMs: cpu.system / 1000, processPeakRssBytes: process.resourceUsage().maxRSS * 1024 };
|
||||
assert.equal(entries.length, selectedSkillCount);
|
||||
for (const entry of entries) assert.equal(entry.sourceStatus, "available", entry.missingDetail ?? "Skill unavailable");
|
||||
assert.equal(entries[0]!.sourceStatus, "available", entries[0]!.missingDetail ?? "Skill unavailable");
|
||||
// Verify the whole delivered repository, not just its authored SKILL.md.
|
||||
for (const file of projected) {
|
||||
const location = path.join(entries[0]!.source, file.path);
|
||||
const location = path.join(entries.find(entry => entry.key === selections.keys().next().value)!.source, file.path);
|
||||
assert.deepEqual(await fs.readFile(location), skillFileBytes(file));
|
||||
if (process.platform !== "win32") assert.equal(Boolean((await fs.stat(location)).mode & 0o111), Boolean(file.executable));
|
||||
}
|
||||
@@ -230,17 +279,32 @@ try {
|
||||
assert.ok(spans.some(span => span.name === "skills.version.verify" && span.attributes["paperclip.execution.fileCount"] === projected.length));
|
||||
assert.equal(new Set(spans.map(span => span.spanContext().traceId)).size, 1);
|
||||
assert.ok(spans.every(span => Object.keys(span.attributes).every(key => ["paperclip.task.run.run_id", "paperclip.execution.fileCount", "paperclip.execution.bytes"].includes(key))));
|
||||
samples.push({ label, elapsedMs, rootPoolQueries: queryCount, traceId: spans[0]!.spanContext().traceId,
|
||||
samples.push({ label, elapsedMs, ...measuredIo, rootPoolQueries: queryCount, traceId: spans[0]!.spanContext().traceId,
|
||||
phases: spans.filter(span => span.name.startsWith("skills.")).map(span => ({ name: span.name,
|
||||
durationMs: span.duration[0] * 1000 + span.duration[1] / 1e6,
|
||||
spanId: span.spanContext().spanId, parentSpanId: span.parentSpanContext?.spanId,
|
||||
attributes: span.attributes })) });
|
||||
console.error(JSON.stringify({ label, elapsedMs, rootPoolQueries: queryCount }));
|
||||
console.error(JSON.stringify({ label, elapsedMs, ...measuredIo, rootPoolQueries: queryCount }));
|
||||
}
|
||||
async function diskUsage(root: string): Promise<{ allocatedBytes: number; fileBytes: number; files: number }> {
|
||||
const total = { allocatedBytes: 0, fileBytes: 0, files: 0 };
|
||||
for (const item of await fs.readdir(root, { withFileTypes: true })) {
|
||||
const target = path.join(root, item.name);
|
||||
if (item.isDirectory()) {
|
||||
const child = await diskUsage(target);
|
||||
total.allocatedBytes += child.allocatedBytes; total.fileBytes += child.fileBytes; total.files += child.files;
|
||||
} else if (item.isFile()) {
|
||||
const stat = await fs.stat(target);
|
||||
total.allocatedBytes += stat.blocks * 512; total.fileBytes += stat.size; total.files++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
const instanceDisk = await diskUsage(home);
|
||||
console.log(JSON.stringify({ schema: "paperclip.native-skill-preparation-benchmark.v1",
|
||||
sourceRevision, sourceChangesHash, exportedOtlp: exportOtlp,
|
||||
source: snapshotFile ? "supplied immutable snapshot" : "synthetic repository",
|
||||
networkDelayMsPerDirection: networkDelayMs, authenticated, stagingShape,
|
||||
networkDelayMsPerDirection: networkDelayMs, authenticated, stagingShape, externalPackages, nativeAssets, selectedSkillCount, instanceDisk,
|
||||
repositoryHash: skillSnapshotHash(files), repositoryFiles: files.length, runtimeFiles: projected.length,
|
||||
runtimeBytes: projected.reduce((sum, file) => sum + skillFileBytes(file).length, 0), samples }, null, 2));
|
||||
} finally {
|
||||
@@ -256,5 +320,12 @@ try {
|
||||
for (const [name, value] of Object.entries({ PAPERCLIP_HOME: previous.home, PAPERCLIP_INSTANCE_ID: previous.instance, OTEL_EXPORTER_OTLP_ENDPOINT: previous.endpoint })) {
|
||||
if (value === undefined) delete process.env[name]; else process.env[name] = value;
|
||||
}
|
||||
async function writableDirectories(root: string): Promise<void> {
|
||||
const stat = await fs.lstat(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
||||
await fs.chmod(root, 0o700);
|
||||
for (const child of await fs.readdir(root, { withFileTypes: true })) if (child.isDirectory()) await writableDirectories(path.join(root, child.name));
|
||||
}
|
||||
await writableDirectories(home);
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Run from the server package with its configured storage and DATABASE_URL. */
|
||||
import { createDb } from "@paperclipai/db";
|
||||
import { skillPackageMaintenance } from "../src/services/skill-package-maintenance.js";
|
||||
|
||||
const value = (flag: string) => process.argv.find(arg => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
||||
const phase = value("--phase") ?? "snapshots";
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL must explicitly select the database to migrate");
|
||||
const limit = Number(value("--limit") ?? 10);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error("--limit must be between 1 and 50");
|
||||
const db = createDb(url, { maxConnections: 2 });
|
||||
try {
|
||||
const service = skillPackageMaintenance(db);
|
||||
const actions = { snapshots: service.migrateSnapshots, versions: service.migrateVersions,
|
||||
orphans: service.collectOrphans, deletions: service.deleteObjects };
|
||||
const action = actions[phase as keyof typeof actions];
|
||||
if (!action) throw new Error("--phase must be snapshots, versions, orphans, or deletions");
|
||||
const apply = process.argv.includes("--apply");
|
||||
const result = await action({ apply, clearLegacy: process.argv.includes("--clear-legacy"), limit, after: value("--after"), companyId: value("--company") });
|
||||
console.log(JSON.stringify({ phase, apply, ...result }));
|
||||
} finally { await db.$client.end({ timeout: 1 }); }
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Disposable, loopback-only S3 compatibility check. No application data is used. */
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { S3Client, CreateBucketCommand, DeleteBucketCommand } from "@aws-sdk/client-s3";
|
||||
import { companies, createDb, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { createStorageService } from "../src/storage/service.js";
|
||||
import { createS3StorageProvider } from "../src/storage/s3-provider.js";
|
||||
import { skillPackagePaths, skillPackageStore } from "../src/services/skill-package-store.js";
|
||||
import { skillSnapshotHash } from "../src/services/skill-snapshot.js";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
|
||||
const endpoint = process.env.PAPERCLIP_SKILL_PACKAGE_TEST_S3_ENDPOINT;
|
||||
assert.ok(endpoint, "Set PAPERCLIP_SKILL_PACKAGE_TEST_S3_ENDPOINT to a disposable loopback S3 server");
|
||||
assert.ok(["localhost", "127.0.0.1", "[::1]"].includes(new URL(endpoint).hostname), "Only loopback test storage is allowed");
|
||||
const region = "us-east-1", bucket = `skill-package-test-${randomUUID()}`;
|
||||
const client = new S3Client({ region, endpoint, forcePathStyle: true });
|
||||
const database = await startEmbeddedPostgresTestDatabase("skill-package-s3-");
|
||||
const db = createDb(database.connectionString);
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-package-s3-"));
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
const storage = createStorageService(createS3StorageProvider({ bucket, region, endpoint, forcePathStyle: true }));
|
||||
const companyId = randomUUID();
|
||||
const keys: string[] = [];
|
||||
let bucketCreated = false;
|
||||
try {
|
||||
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
bucketCreated = true;
|
||||
await db.insert(companies).values({ id: companyId, name: "Disposable S3 check", issuePrefix: "S3T" });
|
||||
const files: CompanySkillVersionFileInventoryEntry[] = [
|
||||
{ path: "SKILL.md", kind: "skill", content: "---\nname: s3-check\ndescription: Disposable compatibility check\n---\nTest only.\n" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({ path: `assets/${index}.bin`, kind: "asset" as const,
|
||||
content: randomBytes(900_000).toString("base64"), encoding: "base64" as const })),
|
||||
{ path: "scripts/check.sh", kind: "script", content: "#!/bin/sh\nexit 0\n", executable: true },
|
||||
];
|
||||
const store = skillPackageStore(db, storage);
|
||||
const id = await store.prepare(companyId, files);
|
||||
const receipt = (await store.descriptor(companyId, id)).storage!;
|
||||
keys.push(receipt.archiveKey, receipt.manifestKey);
|
||||
assert.ok(receipt.archiveBytes > 16 * 1024 * 1024, "Exercise at least three multipart upload parts");
|
||||
assert.equal(skillSnapshotHash(await store.files(companyId, id)), skillSnapshotHash(files));
|
||||
await fs.rm(skillPackagePaths(companyId, id).directory, { recursive: true });
|
||||
assert.equal(skillSnapshotHash(await store.files(companyId, id)), skillSnapshotHash(files));
|
||||
assert.equal(await store.verifyDurable(companyId, id), true);
|
||||
console.log(JSON.stringify({ provider: "s3", files: files.length, archiveBytes: receipt.archiveBytes,
|
||||
decodedBytes: receipt.totalBytes, multipartRoundTrip: true, coldRestore: true, verifiedDurable: true }));
|
||||
} finally {
|
||||
await Promise.allSettled(keys.map(key => storage.deleteObject(companyId, key)));
|
||||
if (bucketCreated) await client.send(new DeleteBucketCommand({ Bucket: bucket })).catch(error => console.error("Disposable bucket cleanup failed", error));
|
||||
client.destroy();
|
||||
await db.$client.end({ timeout: 1 });
|
||||
await database.cleanup();
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
}
|
||||
@@ -53,7 +53,9 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
const previousPackageStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-");
|
||||
oldPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
oldPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
@@ -80,6 +82,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPackageStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousPackageStorage;
|
||||
if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = oldPaperclipHome;
|
||||
if (oldPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
@@ -233,7 +237,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await db.insert(companies).values({ id: companyId, name: "Rollback retry", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const input = { name: "Retryable", slug: "retryable", markdown: "# durable bytes\n" };
|
||||
await expect(db.transaction(async (tx) => {
|
||||
await companySkillService(tx as any).createLocalSkill(companyId, input);
|
||||
await companySkillService(tx as any, { packageDb: db }).createLocalSkill(companyId, input);
|
||||
throw new Error("simulate outer rollback");
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
@@ -383,7 +387,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
await db.insert(companies).values({ id: companyId, name: "Rollback conflict", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
const original = { name: "Original", slug: "rollback-conflict", markdown: "# original bytes\n" };
|
||||
await expect(db.transaction(async (tx) => {
|
||||
await companySkillService(tx as any).createLocalSkill(companyId, original);
|
||||
await companySkillService(tx as any, { packageDb: db }).createLocalSkill(companyId, original);
|
||||
throw new Error("simulate outer rollback");
|
||||
})).rejects.toThrow("simulate outer rollback");
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { skillPackageWork } from "../services/skill-package-work.js";
|
||||
import * as recovery from "../services/skill-package-recovery.js";
|
||||
import type { GitHubRead } from "../services/github-skill-source.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
||||
skillPackageDeletions, createDb } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import * as storageModule from "../storage/index.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { skillPackageStore, skillPackagePaths, withSkillPackageReadScope } from "../services/skill-package-store.js";
|
||||
import { skillPackageMaintenance } from "../services/skill-package-maintenance.js";
|
||||
import { skillPackageManifest, validateSkillPackageManifest, writeSkillPackageArchive, extractSkillPackageArchive,
|
||||
verifySkillPackageDirectory } from "../services/skill-package-files.js";
|
||||
import { skillFileBytes, skillSnapshotHash } from "../services/skill-snapshot.js";
|
||||
import { hasRestorableSnapshot } from "../services/skill-version-cache.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("external skill packages", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let home: string;
|
||||
let storage: storageModule.StorageService;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const files: CompanySkillVersionFileInventoryEntry[] = [
|
||||
{ path: "skills/one/SKILL.md", kind: "skill", content: "---\nname: one\ndescription: Test one\n---\nUse ../../shared/run.sh\n" },
|
||||
{ path: "skills/two/SKILL.md", kind: "skill", content: "---\nname: two\ndescription: Test two\n---\nUse ../../shared/run.sh\n" },
|
||||
{ path: "shared/run.sh", kind: "script", content: "#!/bin/sh\necho original\n", executable: true },
|
||||
{ path: "assets/binary", kind: "reference", content: Buffer.from([0, 255, 7]).toString("base64"), encoding: "base64" },
|
||||
{ path: "assets/base64-text", kind: "reference", content: Buffer.from("text encoded explicitly").toString("base64"), encoding: "base64" },
|
||||
];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("skill-packages-");
|
||||
db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-packages-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
storage = createStorageService(createLocalDiskStorageProvider(path.join(home, "objects")));
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
async function company() {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: "Package test", issuePrefix: `P${id.slice(0, 6)}` });
|
||||
return id;
|
||||
}
|
||||
async function fixture() {
|
||||
const companyId = await company();
|
||||
vi.spyOn(storageModule, "getStorageService").mockReturnValue(storage);
|
||||
const store = skillPackageStore(db, storage);
|
||||
const id = await store.prepare(companyId, files);
|
||||
const skills = [];
|
||||
for (const name of ["one", "two"]) {
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: `company/${companyId}/${name}`, slug: name,
|
||||
name, markdown: files.find(file => file.path === `skills/${name}/SKILL.md`)!.content, sourceType: "github",
|
||||
metadata: { skillSourceId: randomUUID(), snapshotHash: "saved" } }).returning();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 1,
|
||||
repositorySnapshotId: id, repositorySkillPath: `skills/${name}/SKILL.md`, packageSnapshotId: id,
|
||||
packageFileMap: [{ path: "SKILL.md", kind: "skill", sourcePath: `skills/${name}/SKILL.md` }] }).returning();
|
||||
await db.update(companySkills).set({ currentVersionId: version!.id }).where(eq(companySkills.id, skill!.id));
|
||||
skills.push({ skill: skill!, version: version! });
|
||||
}
|
||||
const service = companySkillService(db);
|
||||
const resolve = () => service.listRuntimeSkillEntries(companyId, { onlyKeys: new Set(skills.map(({ skill }) => skill.key)), snapshotLatest: true });
|
||||
return { companyId, id, store, skills, service, resolve };
|
||||
}
|
||||
|
||||
it("automatically converts legacy rows, clears verified payloads and preserves version identities", async () => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: "legacy", slug: "legacy", name: "Legacy", markdown: files[0]!.content }).returning();
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 7,
|
||||
repositorySnapshotId: snapshot!.id, repositorySkillPath: files[0]!.path,
|
||||
fileInventory: [{ ...files[0]!, path: "SKILL.md" }] }).returning();
|
||||
const work = skillPackageWork(db, storage);
|
||||
await work.tick(new AbortController().signal);
|
||||
const [saved] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
const [converted] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id));
|
||||
expect(saved!.fileInventory).toEqual([]);
|
||||
expect(saved!.migrationAt).toBeNull();
|
||||
expect(saved!.storage).not.toBeNull();
|
||||
expect(converted!.fileInventory).toEqual([]);
|
||||
expect(converted!.migrationAt).toBeNull();
|
||||
expect(converted!.packageSnapshotId).toBe(snapshot!.id);
|
||||
expect(converted!.revisionNumber).toBe(7);
|
||||
expect(await skillPackageStore(db, storage).versionFiles(converted!)).toEqual([{ ...files[0]!, path: "SKILL.md" }]);
|
||||
});
|
||||
|
||||
it("expires an unused Git package and reacquires its exact bytes without a migration command", async () => {
|
||||
const f = await fixture();
|
||||
const recipe = { version: 1 as const, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: null, manifest: skillPackageManifest(files) };
|
||||
await f.store.prepare(f.companyId, files, true, undefined, recipe);
|
||||
await f.store.files(f.companyId, f.id);
|
||||
await db.update(snapshots).set({ createdAt: new Date(Date.now() - 40 * 86_400_000), maintenanceAt: new Date(0), migrationAt: null }).where(eq(snapshots.id, f.id));
|
||||
// Current versions stay pinned even after their cache age expires.
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).not.toBeNull();
|
||||
for (const { skill } of f.skills) await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, skill.id));
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date(0) }).where(eq(snapshots.id, f.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).toBeNull();
|
||||
expect(await fs.stat(skillPackagePaths(f.companyId, f.id).directory).catch(() => null)).toBeNull();
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: file.path, mode: file.executable ? "100755" : "100644", type: "blob", size: file.size, sha: String(i).repeat(40) }));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), { openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries,
|
||||
readBlob: async (entry: typeof entries[number]) => skillFileBytes(files[entries.indexOf(entry)]!), release: async () => {} })) }) as unknown as GitHubRead;
|
||||
const authorization = vi.spyOn(recovery, "skillPackageRecoveryReader").mockResolvedValue(read);
|
||||
expect(await f.store.files(f.companyId, f.id)).toEqual(files);
|
||||
expect(read.openSnapshot).toHaveBeenCalledOnce();
|
||||
await f.store.files(f.companyId, f.id);
|
||||
expect(authorization).toHaveBeenCalledOnce();
|
||||
await fs.rm(skillPackagePaths(f.companyId, f.id).directory, { recursive: true });
|
||||
authorization.mockRejectedValue(new Error("grant revoked"));
|
||||
await expect(f.store.files(f.companyId, f.id)).rejects.toThrow("grant revoked");
|
||||
await expect(f.store.files(f.companyId, f.id)).rejects.toThrow("backoff");
|
||||
expect(authorization).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers missing archive and manifest objects while a published receipt still exists", async () => {
|
||||
const companyId = await company();
|
||||
const store = skillPackageStore(db, storage);
|
||||
const recipe = { version: 1 as const, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: null, manifest: skillPackageManifest(files) };
|
||||
const id = await store.prepare(companyId, files, true, undefined, recipe);
|
||||
const receipt = (await store.descriptor(companyId, id)).storage!;
|
||||
await storage.deleteObject(companyId, receipt.archiveKey);
|
||||
await storage.deleteObject(companyId, receipt.manifestKey);
|
||||
await expect(recovery.skillPackageRecoveryReader(db, companyId, recipe)).rejects.toThrow("Reconnect its original repository");
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: file.path, mode: file.executable ? "100755" : "100644", type: "blob", size: file.size, sha: String(i).repeat(40) }));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), { openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries,
|
||||
readBlob: async (entry: typeof entries[number]) => skillFileBytes(files[entries.indexOf(entry)]!), release: async () => {} })) }) as unknown as GitHubRead;
|
||||
vi.spyOn(recovery, "skillPackageRecoveryReader").mockResolvedValue(read);
|
||||
// PostgreSQL JSONB has reordered recipe object keys; its canonical bytes must
|
||||
// still agree with the original externally written manifest checksum.
|
||||
expect(await store.files(companyId, id)).toEqual(files);
|
||||
expect(read.openSnapshot).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains referenced historical bytes without an exact Git recipe", async () => {
|
||||
const f = await fixture();
|
||||
for (const { skill } of f.skills) await db.update(companySkills).set({ currentVersionId: null }).where(eq(companySkills.id, skill.id));
|
||||
await db.update(snapshots).set({ createdAt: new Date(0), migrationAt: null, maintenanceAt: new Date(0) }).where(eq(snapshots.id, f.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
expect((await f.store.descriptor(f.companyId, f.id)).storage).not.toBeNull();
|
||||
expect(await f.store.files(f.companyId, f.id)).toEqual(files);
|
||||
});
|
||||
|
||||
it("restores one shared archive, then prepares a warm turn without object reads or SQL file bodies", async () => {
|
||||
const f = await fixture();
|
||||
const read = vi.spyOn(storage, "getObject");
|
||||
const cold = await f.resolve();
|
||||
expect(cold.map(entry => entry.sourceStatus)).toEqual(["available", "available"]);
|
||||
expect(read.mock.calls.filter(call => call[1].endsWith("files.tar.gz"))).toHaveLength(1);
|
||||
expect(read.mock.calls.filter(call => call[1].endsWith("manifest.json"))).toHaveLength(1);
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.id, f.id));
|
||||
expect(row!.fileInventory).toEqual([]);
|
||||
const sql: string[] = [];
|
||||
const oldDebug = db.$client.options.debug;
|
||||
db.$client.options.debug = (_connection, query) => { sql.push(query); };
|
||||
read.mockClear();
|
||||
try { expect(await f.resolve()).toEqual(cold); }
|
||||
finally { db.$client.options.debug = oldDebug; }
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(sql.length).toBeLessThanOrEqual(6);
|
||||
for (const query of sql.filter(query => /from "company_skill_(repository_snapshots|versions)"/.test(query))) {
|
||||
// Bundled release validation returns only a DB-side digest.
|
||||
if (!query.includes("encode(sha256(")) expect(query).not.toMatch(/"file_inventory"/);
|
||||
}
|
||||
expect((await f.service.getVersion(f.companyId, f.skills[0]!.skill.id, f.skills[0]!.version.id))!.fileInventory)
|
||||
.toEqual([{ path: "SKILL.md", kind: "skill", content: files[0]!.content }]);
|
||||
const restored = await f.store.files(f.companyId, f.id);
|
||||
expect(restored.map(skillFileBytes)).toEqual(files.map(skillFileBytes));
|
||||
await expect(f.store.files(await company(), f.id)).rejects.toThrow("unavailable");
|
||||
});
|
||||
|
||||
it("repairs a same-size cached edit and restores old versions without upstream access", async () => {
|
||||
const f = await fixture();
|
||||
const entries = await f.resolve();
|
||||
const target = path.join(entries[0]!.source, ".paperclip-repository/shared/run.sh");
|
||||
const stat = await fs.stat(target);
|
||||
await fs.chmod(target, 0o755);
|
||||
await fs.writeFile(target, "#!/bin/sh\necho modified\n");
|
||||
await fs.utimes(target, stat.atime, stat.mtime);
|
||||
const read = vi.spyOn(storage, "getObject");
|
||||
await f.resolve();
|
||||
expect(await fs.readFile(target, "utf8")).toBe(files[2]!.content);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
const upstream = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
||||
for (const entry of entries) await fs.rm(entry.source, { recursive: true });
|
||||
await fs.rm(skillPackagePaths(f.companyId, f.id).directory, { recursive: true });
|
||||
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||
expect(upstream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses cache eviction when durable objects are missing, even after an earlier verification", async () => {
|
||||
const f = await fixture();
|
||||
await f.resolve();
|
||||
expect(await hasRestorableSnapshot(db, f.companyId, f.skills[0]!.version.id)).toBe(true);
|
||||
const receipt = (await f.store.descriptor(f.companyId, f.id)).storage!;
|
||||
await storage.deleteObject(f.companyId, receipt.archiveKey);
|
||||
await expect(hasRestorableSnapshot(db, f.companyId, f.skills[0]!.version.id)).rejects.toThrow();
|
||||
// Existing local verified files remain usable while storage is unavailable.
|
||||
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||
});
|
||||
|
||||
it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => {
|
||||
const companyId = await company();
|
||||
const store = skillPackageStore(db, storage);
|
||||
const put = vi.spyOn(storage, "putFile").mockRejectedValueOnce(new Error("connection lost"));
|
||||
await expect(store.prepare(companyId, files)).rejects.toThrow("connection lost");
|
||||
put.mockRestore();
|
||||
const [pending] = await db.select().from(snapshots).where(eq(snapshots.companyId, companyId));
|
||||
expect(pending!.storage).toBeNull();
|
||||
await expect(store.prepare(companyId, files)).rejects.toThrow("being saved");
|
||||
await db.update(snapshots).set({ upload: { ...pending!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, pending!.id));
|
||||
const id = await store.prepare(companyId, files);
|
||||
expect(id).toBe(pending!.id);
|
||||
const receipt = (await store.descriptor(companyId, id)).storage!;
|
||||
expect(receipt.archiveKey).not.toBe(pending!.upload!.archiveKey);
|
||||
const [deletion] = await db.select().from(skillPackageDeletions).where(eq(skillPackageDeletions.companyId, companyId));
|
||||
expect(deletion!.archiveKey).toBe(pending!.upload!.archiveKey);
|
||||
await db.update(skillPackageDeletions).set({ deleteAfter: new Date(0) }).where(eq(skillPackageDeletions.id, deletion!.id));
|
||||
expect((await skillPackageMaintenance(db, storage).deleteObjects({ companyId, apply: true })).removed).toBe(1);
|
||||
expect(await store.verifyDurable(companyId, id)).toBe(true);
|
||||
// Company cascades retain a receipt after removing the snapshot's DB row.
|
||||
await db.delete(companies).where(eq(companies.id, companyId));
|
||||
expect(await db.select().from(skillPackageDeletions).where(eq(skillPackageDeletions.companyId, companyId)))
|
||||
.toEqual([expect.objectContaining({ archiveKey: receipt.archiveKey })]);
|
||||
});
|
||||
|
||||
it("cancels queued and active uploads without publishing or leaking I/O capacity", async () => {
|
||||
const ids = await Promise.all([company(), company(), company()]);
|
||||
const active = new AbortController();
|
||||
const queued = new AbortController();
|
||||
const put = vi.spyOn(storage, "putFile").mockImplementation(async input => {
|
||||
expect(input.signal).toBeDefined();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
input.signal!.addEventListener("abort", () => reject(input.signal!.reason), { once: true });
|
||||
});
|
||||
throw new Error("Unreachable cancelled upload");
|
||||
});
|
||||
const store = skillPackageStore(db, storage);
|
||||
const running = ids.slice(0, 2).map(id => store.prepare(id!, files, false, active.signal).catch(error => error));
|
||||
try {
|
||||
await vi.waitFor(() => expect(put).toHaveBeenCalledTimes(2));
|
||||
const waiting = store.prepare(ids[2]!, files, false, queued.signal).catch(error => error);
|
||||
await vi.waitFor(async () => expect(await db.select({ id: snapshots.id }).from(snapshots)
|
||||
.where(eq(snapshots.companyId, ids[2]!))).toHaveLength(1));
|
||||
queued.abort(new Error("queued cancellation"));
|
||||
expect((await waiting).message).toBe("queued cancellation");
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
active.abort(new Error("active cancellation"));
|
||||
await Promise.all(running);
|
||||
put.mockRestore();
|
||||
}
|
||||
for (const id of ids) {
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.companyId, id!));
|
||||
expect(row!.storage).toBeNull();
|
||||
expect(row!.upload).not.toBeNull();
|
||||
}
|
||||
expect(await store.prepare(await company(), files)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("migrates both legacy payload locations in bounded resumable pages without changing version IDs", async () => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const [skill] = await db.insert(companySkills).values({ companyId, key: "legacy", slug: "legacy", name: "Legacy", markdown: files[0]!.content }).returning();
|
||||
const original = { path: "SKILL.md", kind: "skill" as const, content: files[0]!.content };
|
||||
const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skill!.id, revisionNumber: 7,
|
||||
repositorySnapshotId: snapshot!.id, repositorySkillPath: files[0]!.path, fileInventory: [original] }).returning();
|
||||
const maintenance = skillPackageMaintenance(db, storage);
|
||||
expect((await maintenance.migrateSnapshots({ companyId })).migrated).toBe(0);
|
||||
const fail = vi.spyOn(storage, "getObject").mockRejectedValueOnce(new Error("verification interrupted"));
|
||||
await expect(maintenance.migrateSnapshots({ companyId, apply: true })).rejects.toThrow("verification interrupted");
|
||||
fail.mockRestore();
|
||||
const [pending] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(pending!.fileInventory).toEqual(files);
|
||||
await db.update(snapshots).set({ upload: { ...pending!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, snapshot!.id));
|
||||
expect((await maintenance.migrateSnapshots({ companyId, limit: 1, apply: true })).migrated).toBe(1);
|
||||
const [soaked] = await db.select().from(snapshots).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(soaked!.storage).not.toBeNull();
|
||||
expect(soaked!.fileInventory).toEqual(files);
|
||||
expect((await maintenance.migrateSnapshots({ companyId, limit: 1, apply: true, clearLegacy: true })).migrated).toBe(1);
|
||||
expect((await maintenance.migrateSnapshots({ companyId, apply: true })).inspected).toBe(0);
|
||||
expect((await maintenance.migrateVersions({ companyId, apply: true, clearLegacy: true })).migrated).toBe(1);
|
||||
const [saved] = await db.select().from(companySkillVersions).where(and(eq(companySkillVersions.id, version!.id), eq(companySkillVersions.companyId, companyId)));
|
||||
expect(saved).toMatchObject({ id: version!.id, revisionNumber: 7, fileInventory: [], repositorySnapshotId: snapshot!.id, packageSnapshotId: snapshot!.id });
|
||||
expect(await skillPackageStore(db, storage).versionFiles(saved!)).toEqual([original]);
|
||||
});
|
||||
|
||||
it.each([false, true])("restores across legacy payload clearing with a cached manifest: %s", async cachedManifest => {
|
||||
const companyId = await company();
|
||||
const [snapshot] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const store = skillPackageStore(db, storage);
|
||||
await withSkillPackageReadScope(db, async () => {
|
||||
expect((await store.descriptor(companyId, snapshot!.id)).storage).toBeNull();
|
||||
if (cachedManifest) await store.manifest(companyId, snapshot!.id);
|
||||
await store.prepare(companyId, files, true);
|
||||
await db.update(snapshots).set({ fileInventory: [] }).where(eq(snapshots.id, snapshot!.id));
|
||||
expect(skillSnapshotHash(await store.files(companyId, snapshot!.id))).toBe(skillSnapshotHash(files));
|
||||
expect(await store.verifyDurable(companyId, snapshot!.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds and validates archives, binaries, paths, executable modes, and corruption", async () => {
|
||||
const root = await fs.mkdtemp(path.join(home, "codec-"));
|
||||
const archive = path.join(root, "files.tar.gz");
|
||||
const manifest = skillPackageManifest(files);
|
||||
await writeSkillPackageArchive(archive, files);
|
||||
const output = path.join(root, "out");
|
||||
await fs.mkdir(output);
|
||||
await extractSkillPackageArchive(archive, output, manifest);
|
||||
expect(await verifySkillPackageDirectory(output, manifest.files)).toBe(true);
|
||||
expect((await fs.stat(path.join(output, "shared/run.sh"))).mode & 0o111).toBe(0o111);
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, files: [{ ...manifest.files[0], path: "../escape" }] })).toThrow();
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, files: [manifest.files[0], manifest.files[0]] })).toThrow();
|
||||
expect(() => validateSkillPackageManifest({ ...manifest, totalBytes: 1 })).toThrow();
|
||||
const bad = path.join(root, "bad");
|
||||
await fs.mkdir(bad);
|
||||
await fs.truncate(archive, 20);
|
||||
await expect(extractSkillPackageArchive(archive, bad, manifest)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,9 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
let files: Record<string, string | Buffer> = initial;
|
||||
let commit = sha;
|
||||
const context: SkillSourceContext = { actor: { type: 'user', userId: 'board' }, read: () => githubFixture(files, { 'deep/one/scripts/run.sh': '100755', 'shared/check.sh': '100755' }, commit), authorize: async () => {}, audit: async () => {} };
|
||||
const previousPackageStorage = process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = "true";
|
||||
if (externalUrl) await applyPendingMigrations(externalUrl);
|
||||
else testDb = await startEmbeddedPostgresTestDatabase('paperclip-skill-sources-');
|
||||
db = createDb(externalUrl ?? testDb.connectionString);
|
||||
@@ -39,10 +41,24 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
await db.insert(companies).values({ id: companyId, name: 'Skills', issuePrefix: 'SKL' });
|
||||
}, 30000);
|
||||
afterAll(async () => {
|
||||
if (previousPackageStorage === undefined) delete process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED;
|
||||
else process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED = previousPackageStorage;
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await testDb?.cleanup(); if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
it('tracks repositories with no selected valid skills without saving their contents', async () => {
|
||||
for (const repository of [{ 'README.md': 'No skills here' }, { 'one/SKILL.md': md('one'), 'shared.txt': 'Unused package content' }]) {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: 'Discovery only', issuePrefix: `E${id.slice(0, 6)}` });
|
||||
const result = await skillSourceService(db).create(id, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main',
|
||||
packageMode: 'repository', commitSha: sha, selectedPaths: [] }, { ...context, read: () => githubFixture(repository) });
|
||||
expect(result.imported).toEqual([]);
|
||||
expect(result.source.lastScanCommit).toBe(sha);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.companyId, id))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes two assigned skills while a concurrent version save holds the second skill', async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: 'Concurrent skill publication', issuePrefix: 'CSP' });
|
||||
@@ -566,7 +582,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
|
||||
const v1 = (await skills.getVersion(companyId, main.id, main.currentVersionId!))!;
|
||||
const helper = installed.imported.find(skill => skill.name === 'helper')!;
|
||||
expect((await skills.getVersion(companyId, helper.id, helper.currentVersionId!))?.repositorySnapshotId).toBe(v1.repositorySnapshotId);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.companyId, companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(companySkillRepositorySnapshots).where(eq(companySkillRepositorySnapshots.id, v1.repositorySnapshotId!))).toEqual([expect.objectContaining({ fileInventory: [], storage: expect.objectContaining({ format: "tar-gzip-v1" }) })]);
|
||||
await db.update(companySkills).set({ trustLevel: 'markdown_only' }).where(eq(companySkills.id, main.id));
|
||||
expect((await sources.refresh(companyId, installed.source.id, context)).unchanged).toBe(2);
|
||||
expect((await skills.getById(companyId, main.id))?.trustLevel).toBe('scripts_executables');
|
||||
|
||||
@@ -21,6 +21,8 @@ import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-w
|
||||
import { loginCleanupSchedule } from "./services/login-cleanup-state.js";
|
||||
import { registerLoginCleanupWork } from "./services/login-cleanup-work.js";
|
||||
import { timeStartupRecovery } from "./startup-recovery-timing.js";
|
||||
import { runtimeAssetRetention } from "./services/native-runtime/runtime-asset-retention.js";
|
||||
import { skillPackageWork } from "./services/skill-package-work.js";
|
||||
import { skillSourceSyncService } from "./services/skill-source-sync.js";
|
||||
import {
|
||||
SKILL_VERSION_RETENTION_INTERVAL_MS,
|
||||
@@ -1396,6 +1398,9 @@ async function startServerWithDatabaseTeardown(
|
||||
const accountingWorker = registerCostAccountingWork(db, deliveryWork, accountingHeartbeat.reconcileCostAccounting);
|
||||
server.on("close", accountingWorker.stop);
|
||||
skillSourceSync.start(deliveryWork);
|
||||
skillPackageWork(db).start(deliveryWork);
|
||||
const runtimeAssetCleanup = runtimeAssetRetention(db).start(deliveryWork);
|
||||
server.on("close", () => { void runtimeAssetCleanup.stop(); });
|
||||
const caseHandoffDeliveries = caseHandoffDeliveryService(db as any, { heartbeat: environmentLeaseCleanupHeartbeat });
|
||||
deliveryWork.register(DELIVERY_QUEUES.caseHandoffs, {
|
||||
retryMs: 5_000,
|
||||
|
||||
@@ -1221,7 +1221,7 @@ export function companySkillRoutes(db: Db) {
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_created"),
|
||||
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
||||
));
|
||||
const service = companySkillService(tx as unknown as Db);
|
||||
const service = companySkillService(tx as unknown as Db, { packageDb: db });
|
||||
if (prior) {
|
||||
if (prior.details?.fingerprint !== fingerprint) throw conflict("Skill idempotency key was used with different inputs");
|
||||
const skill = await service.getById(companyId, prior.entityId);
|
||||
@@ -1298,7 +1298,7 @@ export function companySkillRoutes(db: Db) {
|
||||
}
|
||||
let publication: ActivityPublication | null = null;
|
||||
let receipt: { skillId: string; path: string; versionId: string | null; studioPath: string } | null = null;
|
||||
await companySkillService(tx as unknown as Db).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
await companySkillService(tx as unknown as Db, { packageDb: db }).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
encoding: input.encoding, executable: input.executable, expectedVersionId,
|
||||
onRollback: (restore, versionId) => { rollbackState.current = { restore, versionId }; },
|
||||
afterUpdate: async (versionId) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
await notifyChatCompanyConfiguration(tx, id);
|
||||
await chatCompanyDeletion.deleteCompanyData(tx, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, id);
|
||||
await notifyToolReviewChange(tx, id);
|
||||
await agentObservabilityService(tx as unknown as Db).removeForCompanyDeletion(id);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { verifyFileManifest } from "./file-manifest-cache.js";
|
||||
import { skillPackageStorageEnabled, skillPackageStore, skillVersionMetadataColumns, withSkillPackageReadScope, type StoredSkillVersion } from "./skill-package-store.js";
|
||||
import { skillPackageRuntime } from "./skill-package-runtime.js";
|
||||
import type { SkillPackageFileMapping } from "@paperclipai/db";
|
||||
import { ensureSkillCacheDirectory, touchSkillVersionCache, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { verifyExistingBundledSkillRelease } from "./bundled-skill-release-validation.js";
|
||||
@@ -24,7 +29,7 @@ import {
|
||||
companySkillTestInputs,
|
||||
companySkillTestRunTemplates,
|
||||
companySkillTestRuns,
|
||||
companySkillVersions, companySkillRepositorySnapshots,
|
||||
companySkillVersions, withDedicatedDbConnection,
|
||||
companySkills,
|
||||
costEvents,
|
||||
documents,
|
||||
@@ -406,6 +411,8 @@ type BundledSkillReleaseManifestEntry = {
|
||||
type CreateVersionOptions = {
|
||||
repositorySnapshotId?: string;
|
||||
repositorySkillPath?: string;
|
||||
packageSnapshotId?: string;
|
||||
packageFileMap?: SkillPackageFileMapping[] | null;
|
||||
fileInventory?: CompanySkillVersionFileInventoryEntry[];
|
||||
release?: { id: string; name: string; releasedAt: Date };
|
||||
updateCurrentVersion?: boolean;
|
||||
@@ -1929,8 +1936,9 @@ function serializeVersionFileInventory(
|
||||
}
|
||||
|
||||
function toCompanySkillVersion(row: CompanySkillVersionRow): CompanySkillVersion {
|
||||
const { packageSnapshotId: _package, packageFileMap: _map, migrationAt: _migration, ...publicRow } = row;
|
||||
return {
|
||||
...row,
|
||||
...publicRow,
|
||||
label: row.label ?? null,
|
||||
releaseId: row.releaseId ?? null,
|
||||
releaseName: row.releaseName ?? null,
|
||||
@@ -2964,7 +2972,10 @@ async function listLastEditorsBySkillId(
|
||||
return editors;
|
||||
}
|
||||
|
||||
export function companySkillService(db: Db, options: { actor?: AuthorizationActor } = {}) {
|
||||
export function companySkillService(db: Db, options: { actor?: AuthorizationActor; packageDb?: Db } = {}) {
|
||||
const packageDb = options.packageDb ?? db;
|
||||
const packages = skillPackageStore(packageDb);
|
||||
const packageRuntime = skillPackageRuntime(packageDb);
|
||||
const visibility = (database: DbOrTransaction = db) => skillAccessService(database as Db, options.actor);
|
||||
const folderSvc = folderService(db);
|
||||
const agents = agentService(db);
|
||||
@@ -3228,7 +3239,10 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
|
||||
await removeRuntimeSkillCache(resolveManagedSkillsRoot(companyId), skill.id, async () => {
|
||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||
await db.delete(companySkills).where(eq(companySkills.id, skill.id));
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx.delete(companySkills).where(eq(companySkills.id, skill.id));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3382,12 +3396,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
return readFullSkillRows(companyId);
|
||||
}
|
||||
|
||||
async function readFullSkillRows(companyId: string): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId)) return [];
|
||||
async function readFullSkillRows(companyId: string, keys?: Set<string>, unrestrictedOnly = false): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId) || keys?.size === 0) return [];
|
||||
const rows = await db
|
||||
.select(selectCompanySkillColumns())
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), await visibility().condition(companyId)))
|
||||
.where(and(eq(companySkills.companyId, companyId), keys ? inArray(companySkills.key, [...keys]) : undefined, unrestrictedOnly ? isNull(companySkills.accessSourceId) : await visibility().condition(companyId)))
|
||||
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
||||
return rows.map((row) => toCompanySkill(row));
|
||||
}
|
||||
@@ -3462,18 +3476,17 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
?? await getByKey(companyId, ref);
|
||||
}
|
||||
|
||||
async function readVersionMetadata(companyId: string, skillId: string, versionId: string): Promise<StoredSkillVersion | null> {
|
||||
if (db === packageDb) return packages.version(companyId, skillId, versionId);
|
||||
const [row] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId), eq(companySkillVersions.id, versionId)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function getVersion(companyId: string, skillId: string, versionId: string): Promise<CompanySkillVersion | null> {
|
||||
if (!await getById(companyId, skillId)) return null;
|
||||
const row = await db
|
||||
.select()
|
||||
.from(companySkillVersions)
|
||||
.where(and(
|
||||
eq(companySkillVersions.companyId, companyId),
|
||||
eq(companySkillVersions.companySkillId, skillId),
|
||||
eq(companySkillVersions.id, versionId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toCompanySkillVersion(row) : null;
|
||||
const row = await readVersionMetadata(companyId, skillId, versionId);
|
||||
return row ? toCompanySkillVersion({ ...row, fileInventory: await packages.versionFiles(row) }) : null;
|
||||
}
|
||||
|
||||
async function getCurrentVersion(skill: CompanySkill): Promise<CompanySkillVersion | null> {
|
||||
@@ -3543,14 +3556,15 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
if (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash && skill.currentVersionId) {
|
||||
const versionId = skill.currentVersionId;
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const version = await getVersion(companyId, skill.id, versionId);
|
||||
const version = await readVersionMetadata(companyId, skill.id, versionId);
|
||||
if (!version) throw unprocessable("Installed skill version is unavailable.");
|
||||
const sourceLocator = await materializeVersionSnapshot(companyId, skill, version);
|
||||
const audit = await auditInstalledSkillBytes({
|
||||
...skill,
|
||||
sourceType: "local_path",
|
||||
sourceLocator,
|
||||
fileInventory: await runtimeVersionInventory(companyId, version),
|
||||
fileInventory: version.repositorySnapshotId || version.packageSnapshotId
|
||||
? await packageRuntime.inventory(version) : await packages.versionFiles(version),
|
||||
});
|
||||
await persistAuditMetadata(skill, audit);
|
||||
return audit;
|
||||
@@ -3619,7 +3633,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const existingForks = await existingForkSummaries(companyId, skill.id, actor);
|
||||
const currentVersion = await getCurrentVersion(skill);
|
||||
const repositoryInventory = currentVersion?.repositorySnapshotId
|
||||
? await runtimeVersionInventory(companyId, currentVersion) : null;
|
||||
? await packageRuntime.inventory((await readVersionMetadata(companyId, skill.id, currentVersion.id))!) : null;
|
||||
return enrichSkill(
|
||||
repositoryInventory ? { ...skill, fileInventory: repositoryInventory.map(({ path, kind }) => ({ path, kind })) } : skill,
|
||||
usedByAgents.length,
|
||||
@@ -3671,11 +3685,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const skill = await getById(companyId, skillId);
|
||||
if (!skill) throw notFound("Skill not found");
|
||||
const rows = await db
|
||||
.select()
|
||||
.select(skillVersionMetadataColumns)
|
||||
.from(companySkillVersions)
|
||||
.where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId)))
|
||||
.orderBy(desc(companySkillVersions.revisionNumber));
|
||||
return rows.map((row) => toCompanySkillVersion(row));
|
||||
const result: CompanySkillVersion[] = [];
|
||||
for (const row of rows) result.push(toCompanySkillVersion({ ...row, fileInventory: await packages.versionFiles(row) }));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createVersion(
|
||||
@@ -3692,7 +3708,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
options.fileInventory ?? await collectVersionFileInventory(companyId, skill),
|
||||
);
|
||||
const previousVersion = options.fileInventory === undefined ? await getCurrentVersion(skill) : null;
|
||||
// Shipped release rows retain their existing validation contract. All user
|
||||
// snapshots use the package store; source imports prepare before their transaction.
|
||||
const packageSnapshotId = options.packageSnapshotId ?? (options.release || !skillPackageStorageEnabled() ? null : options.database
|
||||
? await withDedicatedDbConnection(packageDb, connection => skillPackageStore(connection).prepare(companyId, fileInventory))
|
||||
: await packages.prepare(companyId, fileInventory));
|
||||
const persist = async (tx: DbOrTransaction) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx.execute(sql`
|
||||
select ${companySkills.id}
|
||||
from ${companySkills}
|
||||
@@ -3713,12 +3735,15 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
companySkillId: skillId,
|
||||
repositorySnapshotId: options.repositorySnapshotId ?? previousVersion?.repositorySnapshotId ?? null,
|
||||
repositorySkillPath: options.repositorySkillPath ?? previousVersion?.repositorySkillPath ?? null,
|
||||
packageSnapshotId,
|
||||
migrationAt: packageSnapshotId || options.release ? null : new Date(),
|
||||
packageFileMap: options.packageFileMap ?? null,
|
||||
revisionNumber: Number(nextRevision ?? 1),
|
||||
label: input.label?.trim() || null,
|
||||
releaseId: options.release?.id ?? null,
|
||||
releaseName: options.release?.name ?? null,
|
||||
releasedAt: options.release?.releasedAt ?? null,
|
||||
fileInventory,
|
||||
fileInventory: packageSnapshotId ? [] : fileInventory,
|
||||
authorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
|
||||
authorUserId: actor?.type === "user" ? actor.userId ?? null : null,
|
||||
})
|
||||
@@ -3735,7 +3760,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
};
|
||||
const versionRow = options.database ? await persist(options.database) : await db.transaction(persist);
|
||||
if (!versionRow) throw notFound("Failed to persist skill version");
|
||||
return toCompanySkillVersion(versionRow);
|
||||
return toCompanySkillVersion({ ...versionRow, fileInventory });
|
||||
}
|
||||
|
||||
async function refreshStarCount(companyId: string, skillId: string) {
|
||||
@@ -4002,6 +4027,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
|
||||
async function cleanupFailedFork(companyId: string, sourceSkillId: string, forkSkillId: string, forkDir: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ currentVersionId: null, updatedAt: new Date() })
|
||||
@@ -4407,11 +4433,19 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
async function readLoadedSkillFile(skill: CompanySkill, relativePath: string): Promise<CompanySkillFileDetail> {
|
||||
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
|
||||
if (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash && skill.currentVersionId) {
|
||||
const version = await getCurrentVersion(skill);
|
||||
// Keep original package-relative reads working, and expose the repository
|
||||
// files from the same saved snapshot used by runtime delivery.
|
||||
const file = version?.fileInventory.find(entry => entry.path === normalizedPath)
|
||||
?? (version?.repositorySnapshotId ? (await runtimeVersionInventory(skill.companyId, version)).find(entry => entry.path === normalizedPath) : undefined);
|
||||
const version = await readVersionMetadata(skill.companyId, skill.id, skill.currentVersionId);
|
||||
let file: CompanySkillVersionFileInventoryEntry | null | undefined;
|
||||
if (version?.packageSnapshotId) {
|
||||
const mapping = version.packageFileMap?.find(file => file.path === normalizedPath);
|
||||
if (mapping) file = await packages.withPackage(skill.companyId, version.packageSnapshotId, async (root, manifest) => {
|
||||
const entry = manifest.files.find(file => file.path === mapping.sourcePath);
|
||||
if (!entry) return null;
|
||||
return { path: mapping.path, kind: mapping.kind, content: (await fs.readFile(path.join(root, entry.path))).toString(entry.encoding),
|
||||
encoding: entry.encoding, executable: entry.executable };
|
||||
});
|
||||
else if (!version.repositorySnapshotId) file = await packageRuntime.readFile(version, normalizedPath);
|
||||
} else if (version) file = (await packages.versionFiles(version)).find(file => file.path === normalizedPath);
|
||||
if (!file && version?.repositorySnapshotId) file = await packageRuntime.readFile(version, normalizedPath);
|
||||
if (!file) throw notFound("Installed skill snapshot is missing a file.");
|
||||
return { ...file, skillId: skill.id, language: file.encoding === "base64" ? null : inferLanguageFromPath(normalizedPath),
|
||||
markdown: file.encoding !== "base64" && isMarkdownPath(normalizedPath), editable: false };
|
||||
@@ -6018,15 +6052,13 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
}
|
||||
|
||||
async function runtimeVersionInventory(companyId: string, version: CompanySkillVersion) {
|
||||
if (!version.repositorySnapshotId) return version.fileInventory;
|
||||
const snapshotId = version.repositorySnapshotId;
|
||||
const [snapshot] = await measureRunPhase("skills.repository.read", () => db.select().from(companySkillRepositorySnapshots).where(and(
|
||||
eq(companySkillRepositorySnapshots.companyId, companyId), eq(companySkillRepositorySnapshots.id, snapshotId))));
|
||||
if (!snapshot || !version.repositorySkillPath) throw unprocessable("Installed repository snapshot is unavailable.");
|
||||
return repositorySkillProjection(snapshot.fileInventory, version.repositorySkillPath);
|
||||
if (!version.repositorySnapshotId) return version.fileInventory.length ? version.fileInventory
|
||||
: packages.versionFiles((await readVersionMetadata(companyId, version.companySkillId, version.id))!);
|
||||
if (!version.repositorySkillPath) throw unprocessable("Installed repository snapshot is unavailable.");
|
||||
return repositorySkillProjection(await packages.files(companyId, version.repositorySnapshotId), version.repositorySkillPath);
|
||||
}
|
||||
|
||||
async function materializeVersionSnapshot(companyId: string, skill: CompanySkill, version: CompanySkillVersion): Promise<string> {
|
||||
async function materializeVersionSnapshot(companyId: string, skill: CompanySkill, version: StoredSkillVersion): Promise<string> {
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const directory = await materializeVersionSnapshotLocked(companyId, skill, version);
|
||||
await touchSkillVersionCache(companyId, skill.id, version.id);
|
||||
@@ -6034,11 +6066,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeVersionSnapshotLocked(companyId: string, skill: CompanySkill, version: CompanySkillVersion): Promise<string> {
|
||||
version = { ...version, fileInventory: await runtimeVersionInventory(companyId, version) };
|
||||
async function materializeVersionSnapshotLocked(companyId: string, skill: CompanySkill, version: StoredSkillVersion): Promise<string> {
|
||||
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__versions__", skill.id);
|
||||
const skillDir = path.join(runtimeRoot, version.id);
|
||||
return runVersionSnapshotFlight(skillDir, async () => {
|
||||
if (version.repositorySnapshotId || version.packageSnapshotId) return packageRuntime.materialize(version, skillDir);
|
||||
version = { ...version, fileInventory: await packages.versionFiles(version) };
|
||||
await ensureSkillCacheDirectory(runtimeRoot, true);
|
||||
if (await measureRunPhase("skills.version.verify", () => materializedVersionSnapshotMatches(skillDir, version), { fileCount: version.fileInventory.length })) return skillDir;
|
||||
const staging = await fs.mkdtemp(path.join(runtimeRoot, `.${version.id}-`));
|
||||
@@ -6081,7 +6114,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
?? (options.snapshotLatest || (skill.metadata?.skillSourceId && skill.metadata?.snapshotHash) || skill.accessSourceId ? skill.currentVersionId : null);
|
||||
if (selectedVersionId) {
|
||||
const versionPath = path.resolve(resolveManagedSkillsRoot(companyId), "__versions__", skill.id, selectedVersionId);
|
||||
const version = await measureRunPhase("skills.version.read", () => getVersion(companyId, skill.id, selectedVersionId));
|
||||
const version = await measureRunPhase("skills.version.read", () => readVersionMetadata(companyId, skill.id, selectedVersionId));
|
||||
if (!version) {
|
||||
return {
|
||||
status: "missing",
|
||||
@@ -6185,22 +6218,43 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
): Promise<T> {
|
||||
if (options.onlyKeys?.size === 0 || (!options.onlyKeys && options.selectionConfig && !options.includeLegacy
|
||||
&& readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills.length === 0)) return consume([]);
|
||||
await measureRunPhase("skills.inventory", () => ensureSkillInventoryCurrent(companyId));
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const entries = await listRuntimeSkillEntriesLocked(companyId, options);
|
||||
// Installed repository versions are immutable and already discovered. Do
|
||||
// not reconcile the entire library (bundled releases/folders/local paths)
|
||||
// before every turn. This probe caches no authority or mutable selection.
|
||||
const requested = options.onlyKeys ?? (options.selectionConfig && !options.includeLegacy
|
||||
? new Set(readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills) : null);
|
||||
const installed = requested?.size && visibility().canReadCompany(companyId) ? await db.select({
|
||||
key: companySkills.key, sourceType: companySkills.sourceType, versionId: companySkills.currentVersionId, accessSourceId: companySkills.accessSourceId,
|
||||
}).from(companySkills).where(and(eq(companySkills.companyId, companyId), inArray(companySkills.key, [...requested]))) : [];
|
||||
const preparedRepositorySelection = requested && installed.length === requested.size
|
||||
&& installed.every(skill => skill.sourceType === "github" && skill.versionId);
|
||||
if (!preparedRepositorySelection) await measureRunPhase("skills.inventory", () => ensureSkillInventoryCurrent(companyId));
|
||||
return withSkillPackageReadScope(db, () => withSkillVersionCacheLock(db, companyId, async () => {
|
||||
// Canonical keys already resolved by the fresh probe avoid listing all
|
||||
// unrelated library references. Scope is still enforced by the row query;
|
||||
// a public-to-project change between the two reads fails closed.
|
||||
const runtimeOptions = preparedRepositorySelection ? { ...options, onlyKeys: requested } : options;
|
||||
const entries = await listRuntimeSkillEntriesLocked(companyId, runtimeOptions,
|
||||
Boolean(preparedRepositorySelection && installed.every(skill => skill.accessSourceId === null)));
|
||||
return measureRunPhase("skills.consume", () => consume(entries));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function listRuntimeSkillEntriesLocked(
|
||||
companyId: string,
|
||||
options: RuntimeSkillEntryOptions,
|
||||
unrestrictedOnly = false,
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
const skills = await measureRunPhase("skills.rows", () => readFullSkillRows(companyId));
|
||||
const references = !options.onlyKeys && options.selectionConfig ? await listReferenceTargets(companyId) : [];
|
||||
const selectedKeys = options.onlyKeys ?? (options.selectionConfig ? new Set(
|
||||
(options.includeLegacy ? resolveLegacyPaperclipDesiredSkillNames : resolvePaperclipDesiredSkillNames)(
|
||||
options.selectionConfig, skills.map(skill => ({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug) }))),
|
||||
) : null);
|
||||
options.selectionConfig, references.map(skill => ({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug) }))),
|
||||
) : undefined);
|
||||
const skills = await measureRunPhase("skills.rows", () => readFullSkillRows(companyId, selectedKeys, unrestrictedOnly));
|
||||
await packages.prefetch(companyId, skills.flatMap(skill => {
|
||||
const id = options.versionSelections?.get(skill.key) ?? skill.currentVersionId;
|
||||
return id ? [id] : [];
|
||||
}));
|
||||
|
||||
const out: PaperclipSkillEntry[] = [];
|
||||
for (let skill of skills) {
|
||||
@@ -6209,7 +6263,12 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
const unpinned = !options.versionSelections?.get(skill.key);
|
||||
const installedDirectory = options.snapshotLatest && unpinned && (skill.sourceType === "local_path" || skill.sourceType === "catalog")
|
||||
? await resolveExistingSkillDirectory(normalizeSkillDirectory(skill)) : null;
|
||||
if (options.snapshotLatest && unpinned && (!skill.currentVersionId || installedDirectory)) {
|
||||
const installedVersion = installedDirectory && skill.currentVersionId
|
||||
? await readVersionMetadata(companyId, skill.id, skill.currentVersionId) : null;
|
||||
const installedUnchanged = installedDirectory && installedVersion?.packageSnapshotId && !installedVersion.packageFileMap
|
||||
? await verifyFileManifest(installedDirectory, (await packages.manifest(companyId, installedVersion.packageSnapshotId)).files)
|
||||
: false;
|
||||
if (!installedUnchanged && options.snapshotLatest && unpinned && (!skill.currentVersionId || installedDirectory)) {
|
||||
// Capture installed bytes only. Older remote imports can have no
|
||||
// version yet; execution must not fetch their missing files upstream.
|
||||
const installed: RuntimeSkillSourceResolution | null = installedDirectory ? { status: "available", source: installedDirectory }
|
||||
@@ -6219,8 +6278,11 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
: skill.fileInventory.length === 1 && skill.fileInventory[0]?.path === "SKILL.md" && skill.markdown
|
||||
? [{ path: "SKILL.md", kind: "skill" as const, content: skill.markdown }] : null;
|
||||
if (inventory) {
|
||||
const previous = skill.currentVersionId ? await getVersion(companyId, skill.id, skill.currentVersionId) : null;
|
||||
if (!previous || skillSnapshotHash(previous.fileInventory) !== skillSnapshotHash(inventory)) {
|
||||
const previous = installedVersion ?? (skill.currentVersionId ? await readVersionMetadata(companyId, skill.id, skill.currentVersionId) : null);
|
||||
const previousHash = previous?.packageSnapshotId && !previous.packageFileMap
|
||||
? (await packages.descriptor(companyId, previous.packageSnapshotId)).contentHash
|
||||
: previous ? skillSnapshotHash(await packages.versionFiles(previous)) : null;
|
||||
if (previousHash !== skillSnapshotHash(inventory)) {
|
||||
const version = await createVersion(companyId, skill.id, { label: "Execution snapshot" }, { type: "system" },
|
||||
{ skipInventoryRefresh: true, skill, fileInventory: inventory });
|
||||
skill = { ...skill, currentVersionId: version.id };
|
||||
@@ -7366,6 +7428,7 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
}
|
||||
try {
|
||||
return await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
await beforeDelete?.(tx);
|
||||
const deleted: CompanySkill[] = [];
|
||||
for (const before of initial) {
|
||||
|
||||
@@ -74,6 +74,8 @@ export const DELIVERY_QUEUES = {
|
||||
dotEvents: "dot-events",
|
||||
grokDispatch: "grok-dispatch",
|
||||
skillSourceSync: "skill-source-sync",
|
||||
skillPackages: "skill-packages",
|
||||
runtimeAssetRetention: "runtime-asset-retention",
|
||||
fastResponse: "fast-responses",
|
||||
feedback: "feedback-exports",
|
||||
chatCompletion: "chat-completions",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type FileManifestEntry = { path: string; size: number; sha256: string; executable: boolean; mode?: number };
|
||||
|
||||
// Cache only identities/digests, not file contents. ctime prevents an ordinary
|
||||
// writer from hiding a modification by restoring mtime. Every use still checks
|
||||
// paths, file types, count and mode; changed files are hashed again.
|
||||
const verifiedFiles = new Map<string, { signature: string; digest: string }>();
|
||||
const MAX_VERIFIED_FILES = 128_000;
|
||||
let activeFileIo = 0;
|
||||
const waitingFileIo: Array<() => void> = [];
|
||||
async function withFileIo<T>(action: () => Promise<T>): Promise<T> {
|
||||
if (activeFileIo >= 24) await new Promise<void>(resolve => waitingFileIo.push(resolve));
|
||||
else activeFileIo++;
|
||||
try { return await action(); }
|
||||
finally { const next = waitingFileIo.shift(); if (next) next(); else activeFileIo--; }
|
||||
}
|
||||
/** Rename preserves file identities; carry verification forward to avoid
|
||||
* re-reading every byte on the first warm turn after a cold materialization. */
|
||||
export function relocateFileManifestVerification(from: string, to: string, files: readonly FileManifestEntry[]) {
|
||||
for (const file of files) {
|
||||
const source = path.join(from, file.path);
|
||||
const verified = verifiedFiles.get(source);
|
||||
verifiedFiles.delete(source);
|
||||
if (verified) verifiedFiles.set(path.join(to, file.path), verified);
|
||||
}
|
||||
}
|
||||
export async function verifyFileManifest(directory: string, files: readonly FileManifestEntry[]): Promise<boolean> {
|
||||
const expected = new Map(files.map(file => [file.path, file]));
|
||||
const seen = new Set<string>();
|
||||
async function walk(relative: string): Promise<boolean> {
|
||||
const root = path.join(directory, relative);
|
||||
const rootStat = await fs.lstat(root);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return false;
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (let offset = 0; offset < entries.length; offset += 24) {
|
||||
const outcomes = await Promise.all(entries.slice(offset, offset + 24).map(async entry => {
|
||||
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) return walk(name);
|
||||
const file = expected.get(name);
|
||||
if (!file || !entry.isFile()) return false;
|
||||
seen.add(name);
|
||||
return withFileIo(async () => {
|
||||
const target = path.join(directory, name);
|
||||
const stat = await fs.lstat(target, { bigint: true });
|
||||
if (!stat.isFile() || stat.size !== BigInt(file.size) || Boolean(stat.mode & 0o111n) !== file.executable
|
||||
|| (file.mode !== undefined && Number(stat.mode & 0o555n) !== file.mode)) return false;
|
||||
const signature = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.mode}`;
|
||||
const previous = verifiedFiles.get(target);
|
||||
if (previous?.signature === signature && previous.digest === file.sha256) return true;
|
||||
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (before.ino !== stat.ino || before.ctimeNs !== stat.ctimeNs) return false;
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
|
||||
if (hash.digest("hex") !== file.sha256) return false;
|
||||
const after = await handle.stat({ bigint: true });
|
||||
if (after.ctimeNs !== before.ctimeNs || after.mtimeNs !== before.mtimeNs) return false;
|
||||
if (verifiedFiles.size >= MAX_VERIFIED_FILES) verifiedFiles.delete(verifiedFiles.keys().next().value!);
|
||||
verifiedFiles.set(target, { signature, digest: file.sha256 });
|
||||
return true;
|
||||
} finally { await handle.close(); }
|
||||
});
|
||||
}));
|
||||
if (outcomes.some(result => !result)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
try { return await walk("") && seen.size === expected.size; }
|
||||
catch (error) { if (["ENOENT", "ENOTDIR", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; throw error; }
|
||||
}
|
||||
@@ -105,6 +105,12 @@ async function scanRepository(input: SkillScanInput, providerRead: GitHubRead, o
|
||||
}
|
||||
const repositoryPaths = indexSkillPackagePaths(entries.map(entry => entry.path));
|
||||
const roots = entries.filter(e => e.type === 'blob' && /(^|\/)skill\.md$/i.test(e.path) && ['100644', '100755'].includes(e.mode));
|
||||
if (!roots.length) {
|
||||
return { treeSha: snapshot.treeSha, downloadBytes: snapshot.downloadBytes, scanBytes: 0,
|
||||
packageMode: input.packageMode, connectionId: providerRead.connectionId ?? null,
|
||||
repositoryId: String(repo.id), repositoryUrl: canonical.repositoryUrl, fullName: repo.full_name,
|
||||
trackingRef, commitSha: commit.sha, defaultBranch: repo.default_branch, candidates: [], warnings: [], skills: [] };
|
||||
}
|
||||
const rootsByDirectory = new Map<string, TreeEntry[]>();
|
||||
for (const root of roots) {
|
||||
const dir = path.posix.dirname(root.path);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "../skill-package-store.js";
|
||||
import { skillAccessService } from "../skill-access.js";
|
||||
import { buildReviewPathRecoveryDisposition } from "../recovery/review-path-recovery.js";
|
||||
import type { AuthorizationActor } from "../authorization.js";
|
||||
@@ -2959,7 +2960,7 @@ export function createHeartbeatRunPreparation(db: Db) {
|
||||
inputId: companySkillTestRuns.inputId,
|
||||
skillVersionId: companySkillTestRuns.skillVersionId,
|
||||
outputDocumentKey: companySkillTestRuns.outputDocumentKey,
|
||||
fileInventory: companySkillVersions.fileInventory,
|
||||
version: skillVersionMetadataColumns,
|
||||
revisionNumber: companySkillVersions.revisionNumber,
|
||||
label: companySkillVersions.label,
|
||||
})
|
||||
@@ -2979,8 +2980,9 @@ export function createHeartbeatRunPreparation(db: Db) {
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const fileInventory = Array.isArray(row.fileInventory)
|
||||
? row.fileInventory.flatMap((entry) => {
|
||||
const savedFiles = await skillPackageStore(db).versionFiles(row.version);
|
||||
const fileInventory = Array.isArray(savedFiles)
|
||||
? savedFiles.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
||||
return [];
|
||||
const record = entry as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -213,6 +213,11 @@ const WORK_CHECKS = [
|
||||
// label requires its completion stamp and a cleared failure reason.
|
||||
`SELECT 1 FROM feedback_exports WHERE (status = 'local_only'
|
||||
OR (status = 'sent' AND exported_at IS NOT NULL AND failure_reason IS NULL)) IS NOT TRUE`,
|
||||
// Conversion owes a verified durable copy before dropping legacy payloads.
|
||||
// Cache/deletion retention times are earliest eligibility, allowed to wait for
|
||||
// the next ingress wake; they are not a promised external alarm deadline.
|
||||
`SELECT 1 FROM company_skill_repository_snapshots WHERE migration_at IS NOT NULL`,
|
||||
`SELECT 1 FROM company_skill_versions WHERE migration_at IS NOT NULL`,
|
||||
`SELECT 1 FROM project_skill_sources b JOIN companies c ON c.id = b.company_id
|
||||
WHERE c.status = 'active' AND b.active AND b.source_id IS NULL`,
|
||||
`SELECT 1 FROM company_skill_sources s JOIN companies c ON c.id = s.company_id
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 type { Db } from "@paperclipai/db";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { materializeAsset } from "./runtime-context.js";
|
||||
import { removeRuntimeAssetTree, runtimeAssetRetention, RUNTIME_ASSET_RETENTION_MS } from "./runtime-asset-retention.js";
|
||||
|
||||
let home: string;
|
||||
const originalHome = process.env.PAPERCLIP_HOME;
|
||||
beforeEach(async () => { home = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-asset-gc-")); process.env.PAPERCLIP_HOME = home; });
|
||||
afterEach(async () => {
|
||||
await removeRuntimeAssetTree(home);
|
||||
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
function database(rows: unknown[] = []) {
|
||||
const select = vi.fn(() => ({ from: () => ({ where: () => ({ limit: async () => rows }) }) }));
|
||||
return { db: { select } as unknown as Db, select };
|
||||
}
|
||||
async function age(digest: string) {
|
||||
const manifest = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${digest}.json`);
|
||||
const old = new Date(Date.now() - RUNTIME_ASSET_RETENTION_MS - 1_000);
|
||||
await fs.utimes(manifest, old, old);
|
||||
}
|
||||
describe("runtime asset retention", () => {
|
||||
it("has no database work when empty/fresh, removes expired copies and rebuilds the same digest", async () => {
|
||||
const { db, select } = database();
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
const files = [{ path: "SKILL.md", content: Buffer.from("immutable skill"), mode: 0o444 }];
|
||||
const asset = await materializeAsset(files);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(worker.nextRunAt()).toBeGreaterThan(Date.now());
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
await age(asset.digest);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await fs.stat(asset.rootPath).catch(() => null)).toBeNull();
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(await materializeAsset(files)).toEqual(asset);
|
||||
});
|
||||
it("keeps files when an unfinished run has no trustworthy input", async () => {
|
||||
const asset = await materializeAsset([{ path: "SKILL.md", content: Buffer.from("pinned skill"), mode: 0o444 }]);
|
||||
await age(asset.digest);
|
||||
const { db } = database([{ id: "run", companyId: "company", agentId: "agent", input: null }]);
|
||||
const worker = runtimeAssetRetention(db);
|
||||
await worker.tick(new AbortController().signal);
|
||||
expect((await fs.stat(asset.rootPath)).isDirectory()).toBe(true);
|
||||
expect(worker.nextRunAt()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
import { ensureSkillCacheDirectory, skillRunNeedsFilesSql } from "../skill-version-cache.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
|
||||
export const RUNTIME_ASSET_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const TOUCH_MS = 60 * 60_000;
|
||||
const touched = new Map<string, number>();
|
||||
const observers = new Set<() => void>();
|
||||
const root = () => path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
function paths(digest: string) {
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error("Invalid runtime asset digest");
|
||||
return { bundle: path.join(root(), "bundles", digest), manifest: path.join(root(), "manifests", `${digest}.json`) };
|
||||
}
|
||||
|
||||
/** Persist an hourly access lease, under the same filesystem lock as deletion.
|
||||
* Warm turns inside that lease add no I/O or database calls. The long grace also
|
||||
* protects the short interval between preparation and durable run pinning. */
|
||||
export async function withRuntimeAssetUse<T>(digest: string, consume: (locked: boolean) => Promise<T>, mutation = false): Promise<T> {
|
||||
const target = paths(digest);
|
||||
const last = touched.get(target.manifest);
|
||||
const fresh = last !== undefined && Date.now() - last < TOUCH_MS;
|
||||
if (fresh && !mutation) return consume(false);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
return withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const result = await consume(true);
|
||||
if (!fresh && (await fs.lstat(target.manifest).catch(() => null))?.isFile()) {
|
||||
const now = Date.now();
|
||||
await fs.utimes(target.manifest, new Date(now), new Date(now));
|
||||
if (touched.size >= 4096) touched.delete(touched.keys().next().value!);
|
||||
touched.set(target.manifest, now);
|
||||
for (const wake of observers) wake();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function protectedAssets(db: Db): Promise<Set<string> | null> {
|
||||
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
input: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'` }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.runtimeMode, "native"), skillRunNeedsFilesSql())).limit(1001);
|
||||
if (rows.length > 1000) return null;
|
||||
const pins = new Set<string>();
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const input = parseNativeExecutionInput(row.input);
|
||||
if (input.binding.runId !== row.id || input.binding.companyId !== row.companyId || input.binding.agentId !== row.agentId) return null;
|
||||
if (!("runtimeContext" in input)) return null;
|
||||
for (const asset of [input.runtimeContext.instructions.bundle, ...input.runtimeContext.skills.map(skill => skill.bundle)]) pins.add(asset.digest);
|
||||
} catch { return null; } // An unfinished/legacy input cannot prove absence.
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
export async function removeRuntimeAssetTree(directory: string) {
|
||||
const stat = await fs.lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!stat) return;
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe runtime asset directory");
|
||||
await fs.chmod(directory, 0o700);
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await removeRuntimeAssetTree(path.join(directory, entry.name));
|
||||
else if (!entry.isFile()) throw new Error("Unsafe runtime asset entry");
|
||||
}
|
||||
await fs.rm(directory, { recursive: true });
|
||||
}
|
||||
|
||||
export function runtimeAssetRetention(db: Db) {
|
||||
let next: number | null = null;
|
||||
async function tick(signal: AbortSignal) {
|
||||
next = null;
|
||||
const directory = path.join(root(), "manifests");
|
||||
try { await ensureSkillCacheDirectory(directory); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const candidates: Array<{ digest: string; at: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
const manifests = await fs.readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return []; throw error;
|
||||
});
|
||||
for (const entry of manifests) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(directory, entry.name)).catch(() => null);
|
||||
if (!stat?.isFile()) continue;
|
||||
seen.add(entry.name.slice(0, -5));
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name.slice(0, -5), at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
// A crash between bundle rename and manifest publication can leave an
|
||||
// unreferenced bundle. The same age, lock and run-pin checks apply.
|
||||
const bundles = path.join(root(), "bundles");
|
||||
try {
|
||||
await ensureSkillCacheDirectory(bundles);
|
||||
for (const entry of await fs.readdir(bundles, { withFileTypes: true })) {
|
||||
signal.throwIfAborted();
|
||||
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name) || seen.has(entry.name)) continue;
|
||||
const stat = await fs.lstat(path.join(bundles, entry.name)).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at <= Date.now()) candidates.push({ digest: entry.name, at });
|
||||
else next = Math.min(next ?? Infinity, at);
|
||||
}
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (!candidates.length) return; // No SQL merely to discover idle history.
|
||||
const pins = await protectedAssets(db);
|
||||
if (!pins) return; // Reconsider on the next asset use or startup, without polling.
|
||||
const eligible = candidates.filter(entry => !pins.has(entry.digest));
|
||||
for (const { digest } of eligible.slice(0, 32)) {
|
||||
signal.throwIfAborted();
|
||||
const target = paths(digest);
|
||||
await ensureSkillCacheDirectory(path.dirname(target.bundle), true);
|
||||
await withDirectoryPublicationLock(target.bundle, async () => {
|
||||
const stat = await fs.lstat(target.manifest).catch(() => null) ?? await fs.lstat(target.bundle).catch(() => null);
|
||||
if (!stat || (!stat.isFile() && !stat.isDirectory()) || stat.isSymbolicLink()) return;
|
||||
const at = stat.mtimeMs + RUNTIME_ASSET_RETENTION_MS;
|
||||
if (at > Date.now()) { next = Math.min(next ?? Infinity, at); return; }
|
||||
await removeRuntimeAssetTree(target.bundle);
|
||||
await fs.rm(target.manifest, { force: true });
|
||||
touched.delete(target.manifest);
|
||||
});
|
||||
}
|
||||
if (eligible.length > 32) next = Math.min(next ?? Infinity, Date.now() + 1_000);
|
||||
}
|
||||
function start(coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">) {
|
||||
let requested = 0, completed = -1;
|
||||
const worker = coordinator.register(DELIVERY_QUEUES.runtimeAssetRetention, {
|
||||
retryMs: 5_000, pausePassiveMaintenance: true,
|
||||
async run(signal) { const generation = requested; await tick(signal); completed = generation; },
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => requested !== completed ? Date.now() + 5_000 : next,
|
||||
});
|
||||
const wake = () => { requested++; worker.wake(); };
|
||||
observers.add(wake);
|
||||
return { ...worker, async stop() { observers.delete(wake); await worker.stop(); } };
|
||||
}
|
||||
return { tick, start, nextRunAt: () => next };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -290,7 +291,11 @@ describe("buildNativeRuntimeContext", () => {
|
||||
};
|
||||
|
||||
const context = await buildNativeRuntimeContext(input);
|
||||
const reads = vi.spyOn(fs, "readFile");
|
||||
const repeated = await buildNativeRuntimeContext(input);
|
||||
expect(reads.mock.calls.some(([file]) => String(file).startsWith(selectedRoot)
|
||||
|| String(file).startsWith(context.skills[0]!.bundle.rootPath))).toBe(false);
|
||||
reads.mockRestore();
|
||||
|
||||
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
|
||||
expect(serviceMocks.getEffectiveToolAssignmentsForAgent).toHaveBeenCalledWith("company-1", "agent-1");
|
||||
@@ -322,6 +327,19 @@ describe("buildNativeRuntimeContext", () => {
|
||||
expect(repeated.aggregateDigest).toBe(context.aggregateDigest);
|
||||
expect(repeated.instructions.bundle.rootPath).toBe(context.instructions.bundle.rootPath);
|
||||
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
|
||||
await writeFile(path.join(selectedRoot, "references/checklist.md"), "- Verify edits\n");
|
||||
const edited = await buildNativeRuntimeContext(input);
|
||||
expect(edited.skills[0]!.bundle.digest).not.toBe(context.skills[0]!.bundle.digest);
|
||||
const corrupted = path.join(edited.skills[0]!.bundle.rootPath, "SKILL.md");
|
||||
await chmod(corrupted, 0o644);
|
||||
await writeFile(corrupted, "Corrupted saved asset");
|
||||
const repaired = await buildNativeRuntimeContext(input);
|
||||
expect(repaired.skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
await chmod(path.dirname(corrupted), 0o755);
|
||||
await fs.rm(corrupted);
|
||||
expect((await buildNativeRuntimeContext(input)).skills[0]!.bundle.digest).toBe(edited.skills[0]!.bundle.digest);
|
||||
expect(await readFile(corrupted, "utf8")).toContain("Use the checklist");
|
||||
});
|
||||
|
||||
it("does not materialize assigned skills or external MCP in preconfigured mode", async () => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { withRuntimeAssetUse, removeRuntimeAssetTree } from "./runtime-asset-retention.js";
|
||||
import { cachedSkillAsset, rememberSkillAsset } from "./skill-asset-cache.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { measureRunPhase } from "../execution-preparation-timing.js";
|
||||
import { spekoToolsForSession } from "../voice/speko-agent-tools.js";
|
||||
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
|
||||
@@ -83,14 +86,9 @@ async function verifyMaterializedAsset(
|
||||
rootPath: string,
|
||||
manifestFiles: Array<{ path: string; sha256: string; mode: number; size: number }>,
|
||||
): Promise<void> {
|
||||
const actual = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(rootPath));
|
||||
const actualByPath = new Map(actual.map((file) => [file.path, file]));
|
||||
if (actualByPath.size !== manifestFiles.length) throw new Error("runtime context asset file count mismatch");
|
||||
for (const expected of manifestFiles) {
|
||||
const file = actualByPath.get(expected.path);
|
||||
if (!file || file.content.byteLength !== expected.size || sha256(file.content) !== expected.sha256 || (file.mode & 0o555) !== expected.mode) {
|
||||
throw new Error(`runtime context asset digest mismatch: ${expected.path}`);
|
||||
}
|
||||
const expected = manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) }));
|
||||
if (!await measureRunPhase("runtime_context.asset_read", () => verifyFileManifest(rootPath, expected))) {
|
||||
throw new Error("runtime context asset digest mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,55 +103,62 @@ export async function materializeAsset(files: AssetFile[]): Promise<NativeRuntim
|
||||
const manifestDigest = sha256(manifestText);
|
||||
return { sorted, manifestFiles, totalBytes, assetDigest, manifestText, manifestDigest };
|
||||
});
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
if (existing !== null) {
|
||||
if (sha256(existing) !== manifestDigest || !(await fs.stat(rootPath).catch(() => null))?.isDirectory()) throw new Error(`runtime context asset verification failed: ${assetDigest}`);
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
return withRuntimeAssetUse(assetDigest, async function publishOrReuse(locked): Promise<NativeRuntimeAssetReference> {
|
||||
const assetsRoot = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets");
|
||||
const rootPath = path.join(assetsRoot, "bundles", assetDigest);
|
||||
const manifestPath = path.join(assetsRoot, "manifests", `${assetDigest}.json`);
|
||||
const existing = await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8").catch(() => null));
|
||||
const rootExists = (await fs.stat(rootPath).catch(() => null))?.isDirectory();
|
||||
if (existing !== null && rootExists && sha256(existing) === manifestDigest
|
||||
&& await verifyFileManifest(rootPath, manifestFiles.map(file => ({ ...file, executable: Boolean(file.mode & 0o111) })))) {
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
if (!locked) return withRuntimeAssetUse(assetDigest, publishOrReuse, true);
|
||||
// The caller supplied the exact authoritative bytes for this digest. A missing
|
||||
// or damaged disposable copy can be rebuilt under the publication/GC lock.
|
||||
if (rootExists) await removeRuntimeAssetTree(rootPath);
|
||||
await fs.rm(manifestPath, { force: true });
|
||||
await measureRunPhase("runtime_context.asset_materialize", async () => {
|
||||
const stagingRoot = path.join(assetsRoot, ".staging", randomUUID());
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const file of sorted) {
|
||||
const target = path.join(stagingRoot, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, file.content, { mode: file.mode & 0o555 });
|
||||
await fs.chmod(target, file.mode & 0o555);
|
||||
}
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true, mode: 0o700 });
|
||||
let publishedRoot = false;
|
||||
await fs.rename(stagingRoot, rootPath).then(() => {
|
||||
publishedRoot = true;
|
||||
}).catch(async (error: NodeJS.ErrnoException) => {
|
||||
// Concurrent publishers can surface EACCES on macOS when the winning
|
||||
// destination has already been made read-only. Treat that as a race only
|
||||
// when the content-addressed destination now exists; a genuine permission
|
||||
// failure with no published destination must still fail loudly.
|
||||
const publishedByPeer = (await fs.stat(rootPath).catch(() => null))?.isDirectory() === true;
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && !publishedByPeer) throw error;
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true });
|
||||
});
|
||||
// Publish the writable staging directory first. Renaming a read-only
|
||||
// directory can fail with EACCES on macOS even when both parents are owned
|
||||
// and writable. Files are already immutable; directories become immutable
|
||||
// immediately after this process wins publication.
|
||||
if (publishedRoot) await makeDirectoriesReadOnly(rootPath);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(manifestPath, manifestText, { flag: "wx", mode: 0o400 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
if (sha256(await measureRunPhase("runtime_context.asset_cache", () => fs.readFile(manifestPath, "utf8"))) !== manifestDigest) throw new Error(`runtime context manifest race mismatch: ${assetDigest}`);
|
||||
});
|
||||
await measureRunPhase("runtime_context.asset_verify", () => verifyMaterializedAsset(rootPath, manifestFiles), { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
} catch (error) {
|
||||
await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}, { fileCount: manifestFiles.length, bytes: totalBytes });
|
||||
return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: assetDigest, manifestDigest, rootPath, fileCount: manifestFiles.length, totalBytes };
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFiles = false) {
|
||||
@@ -180,7 +185,7 @@ async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFi
|
||||
return { entryPath, bundle: await materializeAsset(files) };
|
||||
}
|
||||
|
||||
async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
export async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
|
||||
const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter(
|
||||
(key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
);
|
||||
@@ -189,7 +194,17 @@ async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>,
|
||||
const entry = byKey.get(key);
|
||||
if (!entry) throw new Error(`assigned runtime skill is missing from the company library: ${key}`);
|
||||
if (entry.sourceStatus === "missing") throw new Error(entry.missingDetail ?? `assigned runtime skill source is missing: ${key}`);
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle: await materializeAsset(await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source))) };
|
||||
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(entry.source));
|
||||
if (!bundle) {
|
||||
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source));
|
||||
bundle = await materializeAsset(files);
|
||||
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
|
||||
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
|
||||
// Populate the stat/digest cache against these exact source bytes before
|
||||
// remembering the reference. A concurrent source edit cannot seed it.
|
||||
if (await verifyFileManifest(entry.source, manifest)) rememberSkillAsset(entry.source, manifest, bundle);
|
||||
}
|
||||
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle };
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { withRuntimeAssetUse } from "./runtime-asset-retention.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { NativeRuntimeAssetReference } from "../../vendor/paperclip-runner/index.js";
|
||||
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
|
||||
type Cached = { manifest: FileManifestEntry[]; bundle: NativeRuntimeAssetReference };
|
||||
const entries = new Map<string, Cached>();
|
||||
let fileCount = 0;
|
||||
const MAX_FILES = 32_000;
|
||||
const MAX_ENTRIES = 128;
|
||||
const cacheKey = (source: string) => `${resolvePaperclipInstanceRoot()}:${source}`;
|
||||
|
||||
/** The caller supplies freshly authorized runtime entries on every turn. This
|
||||
* cache retains no authority or file bodies, and verifies source and destination
|
||||
* identities before reusing the content-addressed native asset reference. */
|
||||
export async function cachedSkillAsset(source: string): Promise<NativeRuntimeAssetReference | null> {
|
||||
const cached = entries.get(cacheKey(source));
|
||||
if (!cached || !await verifyFileManifest(source, cached.manifest)) return null;
|
||||
return withRuntimeAssetUse(cached.bundle.digest, async () => {
|
||||
const manifestPath = path.join(resolvePaperclipInstanceRoot(), "runtime-context-assets", "manifests", `${cached.bundle.digest}.json`);
|
||||
const manifest = await fs.readFile(manifestPath).catch(() => null);
|
||||
if (!manifest || !(await fs.lstat(cached.bundle.rootPath).catch(() => null))?.isDirectory()) return null;
|
||||
if (createHash("sha256").update(manifest).digest("hex") !== cached.bundle.manifestDigest
|
||||
|| !await verifyFileManifest(cached.bundle.rootPath, cached.manifest)) {
|
||||
return null;
|
||||
}
|
||||
return cached.bundle;
|
||||
});
|
||||
}
|
||||
|
||||
export function rememberSkillAsset(source: string, manifest: FileManifestEntry[], bundle: NativeRuntimeAssetReference) {
|
||||
if (manifest.length > MAX_FILES) return;
|
||||
const key = cacheKey(source);
|
||||
const previous = entries.get(key);
|
||||
if (previous) { fileCount -= previous.manifest.length; entries.delete(key); }
|
||||
while (entries.size && (fileCount + manifest.length > MAX_FILES || entries.size >= MAX_ENTRIES)) {
|
||||
const first = entries.keys().next().value!;
|
||||
fileCount -= entries.get(first)!.manifest.length;
|
||||
entries.delete(first);
|
||||
}
|
||||
entries.set(key, { manifest, bundle });
|
||||
fileCount += manifest.length;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, type Db } from "@paperclipai/db";
|
||||
import { ensureSkillCacheDirectory } from "./skill-version-cache.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_PACKAGES = 64;
|
||||
const used = new Map<string, number>();
|
||||
export function touchSkillPackage(directory: string) {
|
||||
if (used.size >= 4096) used.delete(used.keys().next().value!);
|
||||
used.set(directory, Date.now());
|
||||
}
|
||||
|
||||
/** Called only after a cold fill, under the company cache lock. Never on a warm
|
||||
* check. Version projections and run pins have their own existing retention. */
|
||||
export async function trimSkillPackageCache(db: Db, companyId: string, directory: string,
|
||||
verifyDurable: (id: string, signal: AbortSignal) => Promise<boolean>) {
|
||||
const parent = path.dirname(directory);
|
||||
await ensureSkillCacheDirectory(parent);
|
||||
const contents = await fs.readdir(parent, { withFileTypes: true });
|
||||
for (const entry of contents) {
|
||||
if (!/^\.(package|upload|verify)-[a-zA-Z0-9]{6}(\.tar\.gz)?$/.test(entry.name)) continue;
|
||||
const target = path.join(parent, entry.name);
|
||||
// Every I/O attempt has a three-minute deadline; leave a much longer crash
|
||||
// grace so housekeeping cannot race a live publisher in another process.
|
||||
if (Date.now() - (await fs.lstat(target)).mtimeMs > 60 * 60_000) await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
const names = contents.filter(entry => entry.isDirectory() && /^[a-f0-9-]{36}$/i.test(entry.name)).map(entry => entry.name);
|
||||
if (!names.length) return;
|
||||
const rows = await db.select({ id: snapshots.id, storage: snapshots.storage }).from(snapshots)
|
||||
.where(and(eq(snapshots.companyId, companyId), inArray(snapshots.id, names)));
|
||||
const byId = new Map(rows.map(row => [row.id, row.storage]));
|
||||
const entries = await Promise.all(names.map(async id => {
|
||||
const location = path.join(parent, id);
|
||||
const receipt = byId.get(id);
|
||||
return { id, location, receipt, lastUsed: used.get(location) ?? (await fs.lstat(location)).mtimeMs,
|
||||
// Include an upper bound on block rounding and directory/manifest overhead.
|
||||
bytes: receipt ? receipt.totalBytes + receipt.fileCount * 8192 : 64 * 1024 * 1024 };
|
||||
}));
|
||||
let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
|
||||
let count = entries.length;
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
let attempted = 0;
|
||||
for (const entry of entries.sort((a, b) => a.lastUsed - b.lastUsed)) {
|
||||
if (bytes <= MAX_BYTES && count <= MAX_PACKAGES) break;
|
||||
if (signal.aborted || attempted >= 2) break;
|
||||
if (entry.location === directory) continue;
|
||||
attempted++;
|
||||
// A storage outage must not discard the last recoverable copy. Keep the
|
||||
// excess until the next cold fill can confirm a durable copy independently.
|
||||
if (byId.has(entry.id) && !await verifyDurable(entry.id, signal)) break;
|
||||
await fs.rm(entry.location, { recursive: true, force: true });
|
||||
await fs.rm(path.join(parent, `${entry.id}.json`), { force: true });
|
||||
used.delete(entry.location);
|
||||
bytes -= entry.bytes;
|
||||
count--;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip, createGunzip } from "node:zlib";
|
||||
import { extract, pack } from "tar-stream";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { assertSkillSnapshotPath, skillFileBytes, skillSnapshotHash } from "./skill-snapshot.js";
|
||||
|
||||
export const MAX_SKILL_PACKAGE_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_SKILL_PACKAGE_FILES = 10_000;
|
||||
export const MAX_SKILL_MANIFEST_BYTES = 48 * 1024 * 1024;
|
||||
export type SkillPackageFile = {
|
||||
path: string;
|
||||
kind: CompanySkillVersionFileInventoryEntry["kind"];
|
||||
encoding: "utf8" | "base64";
|
||||
executable: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
};
|
||||
export type SkillPackageManifest = {
|
||||
format: "paperclip-skill-package/v1";
|
||||
contentHash: string;
|
||||
totalBytes: number;
|
||||
files: SkillPackageFile[];
|
||||
};
|
||||
const kinds = new Set(["skill", "markdown", "reference", "script", "asset", "other"]);
|
||||
export const packageSha256 = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
export function validateSkillPackageManifest(value: unknown): SkillPackageManifest {
|
||||
const manifest = value as SkillPackageManifest;
|
||||
if (!manifest || manifest.format !== "paperclip-skill-package/v1" || !/^[a-f0-9]{64}$/.test(manifest.contentHash)
|
||||
|| !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_SKILL_PACKAGE_FILES) {
|
||||
throw new Error("Invalid skill package manifest");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
let total = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSkillSnapshotPath(file.path);
|
||||
if (file.path.length > 4096 || file.path.split("/").length > 64 || paths.has(file.path)
|
||||
|| !kinds.has(file.kind) || !["utf8", "base64"].includes(file.encoding) || typeof file.executable !== "boolean"
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error("Invalid skill package file");
|
||||
paths.add(file.path);
|
||||
total += file.size;
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
let parent = path.posix.dirname(file.path);
|
||||
while (parent !== ".") {
|
||||
if (paths.has(parent)) throw new Error("Conflicting skill package paths");
|
||||
parent = path.posix.dirname(parent);
|
||||
}
|
||||
}
|
||||
if (total !== manifest.totalBytes || total > MAX_SKILL_PACKAGE_BYTES) throw new Error("Skill package exceeds its byte bound");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function skillPackageManifest(files: CompanySkillVersionFileInventoryEntry[]): SkillPackageManifest {
|
||||
return validateSkillPackageManifest({
|
||||
format: "paperclip-skill-package/v1", contentHash: skillSnapshotHash(files),
|
||||
totalBytes: files.reduce((sum, file) => sum + skillFileBytes(file).length, 0),
|
||||
files: files.map(file => {
|
||||
const bytes = skillFileBytes(file);
|
||||
return { path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable), size: bytes.length, sha256: packageSha256(bytes) };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** JSONB reorders object keys. Preserve the archive manifest's v1 wire encoding
|
||||
* when a recovery recipe is read back from PostgreSQL. Array order is retained. */
|
||||
export function skillPackageManifestBytes(manifest: SkillPackageManifest): Buffer {
|
||||
return Buffer.from(JSON.stringify({ format: manifest.format, contentHash: manifest.contentHash, totalBytes: manifest.totalBytes,
|
||||
files: manifest.files.map(file => ({ path: file.path, kind: file.kind, encoding: file.encoding,
|
||||
executable: file.executable, size: file.size, sha256: file.sha256 })) }));
|
||||
}
|
||||
|
||||
export function boundedHashStream(maxBytes: number) {
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
const stream = new Transform({ transform(chunk: Buffer, _encoding, done) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > maxBytes) return done(new Error("Skill package stream exceeds its byte bound"));
|
||||
hash.update(chunk);
|
||||
done(null, chunk);
|
||||
} });
|
||||
return { stream, result: () => ({ bytes, sha256: hash.digest("hex") }) };
|
||||
}
|
||||
|
||||
export async function writeSkillPackageArchive(filename: string, files: CompanySkillVersionFileInventoryEntry[], signal?: AbortSignal) {
|
||||
const archive = pack();
|
||||
const digest = boundedHashStream(MAX_SKILL_PACKAGE_BYTES + MAX_SKILL_MANIFEST_BYTES);
|
||||
const writing = pipeline(archive, createGzip({ level: 3 }), digest.stream, createWriteStream(filename, { flags: "wx", mode: 0o600 }), { signal });
|
||||
// Attach a handler before producing entries so an early filesystem failure is observed.
|
||||
void writing.catch(() => {});
|
||||
try {
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
signal?.throwIfAborted();
|
||||
await new Promise<void>((resolve, reject) => archive.entry({
|
||||
name: `files/${assertSkillSnapshotPath(file.path)}`, type: "file", mode: file.executable ? 0o555 : 0o444,
|
||||
mtime: new Date(0), uid: 0, gid: 0,
|
||||
}, skillFileBytes(file), error => error ? reject(error) : resolve()));
|
||||
}
|
||||
archive.finalize();
|
||||
await writing;
|
||||
return digest.result();
|
||||
} catch (error) {
|
||||
archive.destroy(error as Error);
|
||||
await writing.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractSkillPackageArchive(filename: string, directory: string, manifest: SkillPackageManifest, signal?: AbortSignal) {
|
||||
const archive = extract();
|
||||
const expected = new Map(manifest.files.map(file => [`files/${file.path}`, file]));
|
||||
const expanded = boundedHashStream(MAX_SKILL_PACKAGE_BYTES + MAX_SKILL_PACKAGE_FILES * 8192);
|
||||
const reading = pipeline(createReadStream(filename), createGunzip(), expanded.stream, archive, { signal });
|
||||
void reading.catch(() => {});
|
||||
try {
|
||||
for await (const entry of archive) {
|
||||
signal?.throwIfAborted();
|
||||
const file = expected.get(entry.header.name);
|
||||
if (!file || entry.header.type !== "file" || entry.header.size !== file.size
|
||||
|| entry.header.mode !== (file.executable ? 0o555 : 0o444)) throw new Error("Unexpected skill package archive entry");
|
||||
expected.delete(entry.header.name);
|
||||
const target = path.join(directory, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const digest = boundedHashStream(file.size);
|
||||
const output = await fs.open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, file.executable ? 0o555 : 0o444);
|
||||
await pipeline(entry, digest.stream, output.createWriteStream(), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== file.size || actual.sha256 !== file.sha256) throw new Error("Skill package file checksum mismatch");
|
||||
}
|
||||
await reading;
|
||||
if (expected.size) throw new Error("Skill package archive is incomplete");
|
||||
} catch (error) {
|
||||
archive.destroy(error as Error);
|
||||
await reading.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { verifyFileManifest as verifySkillPackageDirectory, relocateFileManifestVerification as relocateSkillPackageVerification } from "./file-manifest-cache.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
import path from "node:path";
|
||||
import { and, asc, eq, gt, isNull, lte, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions as versions,
|
||||
skillPackageDeletions, type Db } from "@paperclipai/db";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
|
||||
import { packageSha256 } from "./skill-package-files.js";
|
||||
import { skillFileBytes, skillSnapshotHash } from "./skill-snapshot.js";
|
||||
|
||||
type PageOptions = { id?: string; signal?: AbortSignal; companyId?: string; after?: string; limit?: number; apply?: boolean; clearLegacy?: boolean };
|
||||
const pageSize = (options: PageOptions) => Math.min(50, Math.max(1, options.limit ?? 10));
|
||||
|
||||
/** Bounded maintenance shared by automatic delivery and optional operator diagnostics. */
|
||||
export function skillPackageMaintenance(db: Db, storage?: StorageService) {
|
||||
const store = skillPackageStore(db, storage);
|
||||
async function migrateSnapshots(options: PageOptions = {}) {
|
||||
const rows = await db.select({ id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash })
|
||||
.from(snapshots).where(and(options.companyId ? eq(snapshots.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(snapshots.id, options.id) : undefined,
|
||||
options.after ? gt(snapshots.id, options.after) : undefined, options.clearLegacy ? undefined : isNull(snapshots.storage)))
|
||||
.orderBy(asc(snapshots.id)).limit(pageSize(options));
|
||||
let migrated = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
// Transfer only one legacy body at a time. Never trust a mutable upstream.
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(eq(snapshots.id, row.id));
|
||||
options.signal?.throwIfAborted();
|
||||
if (!legacy?.files.length) {
|
||||
await db.update(snapshots).set({ migrationAt: null }).where(eq(snapshots.id, row.id));
|
||||
continue;
|
||||
}
|
||||
if (skillSnapshotHash(legacy.files) !== row.contentHash) throw new Error(`Legacy package hash mismatch: ${row.id}`);
|
||||
const id = await store.prepare(row.companyId, legacy.files, true, options.signal);
|
||||
if (id !== row.id || !await store.verifyDurable(row.companyId, id, options.signal)) throw new Error(`Package verification failed: ${row.id}`);
|
||||
// An ambiguous result is safe to retry: storage is already published, IDs
|
||||
// are unchanged, and the old bytes survive until this final update.
|
||||
if (options.clearLegacy) await db.update(snapshots).set({ fileInventory: [], migrationAt: null }).where(and(eq(snapshots.id, id), eq(snapshots.contentHash, row.contentHash), sql`${snapshots.storage} is not null`));
|
||||
migrated++;
|
||||
}
|
||||
return { inspected: rows.length, migrated, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function migrateVersions(options: PageOptions = {}) {
|
||||
const rows = await db.select(skillVersionMetadataColumns).from(versions).where(and(
|
||||
options.companyId ? eq(versions.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(versions.id, options.id) : undefined,
|
||||
options.after ? gt(versions.id, options.after) : undefined, options.clearLegacy ? undefined : isNull(versions.packageSnapshotId), isNull(versions.releaseId)))
|
||||
.orderBy(asc(versions.id)).limit(pageSize(options));
|
||||
let migrated = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
const [legacy] = await db.select({ files: versions.fileInventory }).from(versions).where(eq(versions.id, row.id));
|
||||
const files = legacy?.files;
|
||||
options.signal?.throwIfAborted();
|
||||
if (!files?.length) {
|
||||
await db.update(versions).set({ migrationAt: null }).where(eq(versions.id, row.id));
|
||||
continue;
|
||||
}
|
||||
if (row.repositorySnapshotId) await migrateSnapshots({ id: row.repositorySnapshotId, apply: true, clearLegacy: true, signal: options.signal });
|
||||
let packageId = row.packageSnapshotId;
|
||||
let fileMap = row.packageFileMap;
|
||||
if (packageId && skillSnapshotHash(await store.versionFiles(row)) !== skillSnapshotHash(files)) throw new Error(`Legacy version differs from its package: ${row.id}`);
|
||||
if (!packageId && row.repositorySnapshotId && row.repositorySkillPath) {
|
||||
const manifest = await store.manifest(row.companyId, row.repositorySnapshotId);
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
const mappings = files.map(file => ({ path: file.path, kind: file.kind,
|
||||
sourcePath: file.path === "SKILL.md" ? row.repositorySkillPath! : path.posix.join(path.posix.dirname(row.repositorySkillPath!), file.path) }));
|
||||
if (files.every((file, index) => {
|
||||
const source = byPath.get(mappings[index]!.sourcePath);
|
||||
return source?.sha256 === packageSha256(skillFileBytes(file)) && source.executable === Boolean(file.executable);
|
||||
})) { packageId = row.repositorySnapshotId; fileMap = mappings; }
|
||||
}
|
||||
packageId ??= await store.prepare(row.companyId, files, true, options.signal);
|
||||
const receipt = await store.descriptor(row.companyId, packageId);
|
||||
if (!receipt.storage || !await store.verifyDurable(row.companyId, packageId, options.signal)) throw new Error(`Package must be migrated and verified first: ${packageId}`);
|
||||
await db.update(versions).set({ packageSnapshotId: packageId, packageFileMap: fileMap, ...(options.clearLegacy ? { fileInventory: [], migrationAt: null } : {}) })
|
||||
.where(and(eq(versions.companyId, row.companyId), eq(versions.id, row.id), row.packageSnapshotId ? eq(versions.packageSnapshotId, row.packageSnapshotId) : isNull(versions.packageSnapshotId)));
|
||||
migrated++;
|
||||
}
|
||||
return { inspected: rows.length, migrated, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function collectOrphans(options: PageOptions = {}) {
|
||||
const unused = sql`not exists (select 1 from ${versions} where ${versions.repositorySnapshotId} = ${snapshots.id} or ${versions.packageSnapshotId} = ${snapshots.id})`;
|
||||
const rows = await db.select({ id: snapshots.id }).from(snapshots).where(and(
|
||||
options.companyId ? eq(snapshots.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(snapshots.id, options.id) : undefined,
|
||||
options.after ? gt(snapshots.id, options.after) : undefined,
|
||||
sql`${snapshots.createdAt} < now() - interval '1 day'`,
|
||||
sql`(${snapshots.upload} is null or (${snapshots.upload}->>'expiresAt')::timestamptz < now())`, unused,
|
||||
)).orderBy(asc(snapshots.id)).limit(pageSize(options));
|
||||
let removed = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
// FK locking wins against a concurrent publication. The trigger persists
|
||||
// deletion receipts in the same transaction, including company cascades.
|
||||
const deleted = await db.delete(snapshots).where(and(eq(snapshots.id, row.id), unused)).returning({ id: snapshots.id });
|
||||
removed += deleted.length;
|
||||
}
|
||||
return { inspected: rows.length, removed, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
|
||||
async function deleteObjects(options: PageOptions = {}) {
|
||||
const backend = storage ?? getStorageService();
|
||||
const rows = await db.select().from(skillPackageDeletions).where(and(
|
||||
options.companyId ? eq(skillPackageDeletions.companyId, options.companyId) : undefined,
|
||||
options.id ? eq(skillPackageDeletions.id, options.id) : undefined,
|
||||
options.after ? gt(skillPackageDeletions.id, options.after) : undefined,
|
||||
lte(skillPackageDeletions.deleteAfter, new Date()), eq(skillPackageDeletions.provider, backend.provider),
|
||||
)).orderBy(asc(skillPackageDeletions.id)).limit(pageSize(options));
|
||||
let removed = 0;
|
||||
if (options.apply) for (const row of rows) {
|
||||
options.signal?.throwIfAborted();
|
||||
const [referenced] = await db.select({ id: snapshots.id }).from(snapshots).where(and(eq(snapshots.companyId, row.companyId),
|
||||
sql`(${snapshots.storage}->>'archiveKey' = ${row.archiveKey} or ${snapshots.upload}->>'archiveKey' = ${row.archiveKey})`)).limit(1);
|
||||
if (referenced) {
|
||||
// Live ownership supersedes this receipt. A future replacement/deletion
|
||||
// enqueues fresh work in the same transaction as the ownership change.
|
||||
await db.delete(skillPackageDeletions).where(eq(skillPackageDeletions.id, row.id));
|
||||
continue;
|
||||
}
|
||||
for (const key of [row.archiveKey, row.manifestKey]) {
|
||||
await backend.deleteObject(row.companyId, key);
|
||||
if ((await backend.headObject(row.companyId, key)).exists) throw new Error(`Storage did not confirm package deletion: ${row.id}`);
|
||||
}
|
||||
await db.delete(skillPackageDeletions).where(eq(skillPackageDeletions.id, row.id));
|
||||
removed++;
|
||||
}
|
||||
return { inspected: rows.length, removed, after: rows.at(-1)?.id ?? null };
|
||||
}
|
||||
return { migrateSnapshots, migrateVersions, collectOrphans, deleteObjects };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { recoverSkillPackage } from "./skill-package-recovery.js";
|
||||
import { skillPackageManifest } from "./skill-package-files.js";
|
||||
import type { GitHubRead } from "./github-skill-source.js";
|
||||
import type { SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
|
||||
function fixture() {
|
||||
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "---\nname: original\n---\nold commit" },
|
||||
{ path: "run.sh", kind: "script" as const, content: "echo original", executable: true }];
|
||||
const recipe: SkillPackageRestoreRecipe = { version: 1, sourceId: randomUUID(), repositoryId: "123", repositoryUrl: "https://github.com/acme/skills",
|
||||
commitSha: "a".repeat(40), skillPath: "skills/one/skill.md", manifest: skillPackageManifest(files) };
|
||||
const entries = recipe.manifest.files.map((file, i) => ({ path: `skills/one/${i ? "run.sh" : "skill.md"}`, type: "blob", mode: file.executable ? "100755" : "100644", sha: String(i).repeat(40), size: file.size }));
|
||||
const release = vi.fn(async () => {});
|
||||
const readBlob = vi.fn(async (entry: typeof entries[number]) => Buffer.from(files[entries.indexOf(entry)]!.content));
|
||||
const read = Object.assign(vi.fn(async () => ({ id: 123 })), {
|
||||
openSnapshot: vi.fn(async () => ({ commitSha: recipe.commitSha, entries, readBlob, release })),
|
||||
}) as unknown as GitHubRead;
|
||||
return { files, recipe, read, entries, readBlob, release };
|
||||
}
|
||||
describe("immutable skill package recovery", () => {
|
||||
it("reads the original manifest at the exact commit, including normalized entrypoint and modes", async () => {
|
||||
const f = fixture();
|
||||
expect(await recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).toEqual(f.files);
|
||||
expect(f.read.openSnapshot).toHaveBeenCalledWith({ repositoryUrl: f.recipe.repositoryUrl, ref: f.recipe.commitSha, commitSha: f.recipe.commitSha }, expect.anything());
|
||||
expect(f.read).toHaveBeenCalledExactlyOnceWith("/repos/acme/skills", expect.any(AbortSignal));
|
||||
expect(f.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects reused repository names, wrong commits, changed contents and missing files", async () => {
|
||||
const f = fixture();
|
||||
vi.mocked(f.read).mockResolvedValueOnce({ id: 999 });
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("not the original");
|
||||
expect(f.read.openSnapshot).not.toHaveBeenCalled();
|
||||
f.readBlob.mockResolvedValueOnce(Buffer.from("wrong"));
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("checksum mismatch");
|
||||
f.entries.pop();
|
||||
await expect(recoverSkillPackage(f.recipe, f.read, new AbortController().signal)).rejects.toThrow("unavailable");
|
||||
expect(f.release).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { companySkillSources, type Db, type SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertSkillSnapshotPath } from "./skill-snapshot.js";
|
||||
import { packageSha256, skillPackageManifest, validateSkillPackageManifest } from "./skill-package-files.js";
|
||||
import type { GitHubRead } from "./github-skill-source.js";
|
||||
|
||||
/** Recheck the source's saved identity and current grant. Never search another
|
||||
* company's credentials or turn a mutable branch name into a historical commit. */
|
||||
export async function skillPackageRecoveryReader(db: Db, companyId: string, recipe: SkillPackageRestoreRecipe) {
|
||||
const [source] = await db.select().from(companySkillSources).where(and(
|
||||
eq(companySkillSources.companyId, companyId), eq(companySkillSources.id, recipe.sourceId)));
|
||||
if (!source || source.removedAt || !source.enabled || source.repositoryId !== recipe.repositoryId
|
||||
|| source.repositoryUrl.toLowerCase() !== recipe.repositoryUrl.toLowerCase()) {
|
||||
throw forbidden("This skill package is no longer cached. Reconnect its original repository to restore it.");
|
||||
}
|
||||
// Deferred import avoids making the package store own source synchronization.
|
||||
const { skillSourceSetupContext } = await import("./skill-source-sync.js");
|
||||
const context = await skillSourceSetupContext(db, source);
|
||||
await context.authorize("skills.update", { sourceType: "git", sourceLocator: recipe.repositoryUrl });
|
||||
return context.read(source.connectionId);
|
||||
}
|
||||
|
||||
export async function recoverSkillPackage(recipe: SkillPackageRestoreRecipe, read: GitHubRead, signal: AbortSignal): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
if (recipe.version !== 1 || !/^[a-f0-9]{40}$/i.test(recipe.commitSha)) throw new Error("Invalid immutable skill package recipe");
|
||||
const manifest = validateSkillPackageManifest(recipe.manifest);
|
||||
const url = new URL(recipe.repositoryUrl);
|
||||
if (url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password
|
||||
|| !/^\/[^/]+\/[^/]+$/.test(url.pathname) || url.search || url.hash) throw new Error("Invalid skill repository identity");
|
||||
const repository = await read(`/repos${url.pathname}`, signal) as { id?: number | string };
|
||||
if (String(repository.id) !== recipe.repositoryId) throw new Error("The repository at this URL is not the original skill repository");
|
||||
const snapshot = await read.openSnapshot({ repositoryUrl: recipe.repositoryUrl, ref: recipe.commitSha, commitSha: recipe.commitSha }, { signal });
|
||||
try {
|
||||
if (snapshot.commitSha !== recipe.commitSha) throw new Error("Git returned a different skill commit");
|
||||
const byPath = new Map(snapshot.entries.map(entry => [entry.path, entry]));
|
||||
if (recipe.skillPath) assertSkillSnapshotPath(recipe.skillPath);
|
||||
const entries = manifest.files.map(file => {
|
||||
const sourcePath = recipe.skillPath
|
||||
? file.path === "SKILL.md" ? recipe.skillPath : path.posix.join(path.posix.dirname(recipe.skillPath), file.path)
|
||||
: file.path;
|
||||
const entry = byPath.get(assertSkillSnapshotPath(sourcePath));
|
||||
if (!entry || entry.type !== "blob" || entry.mode !== (file.executable ? "100755" : "100644") || entry.size !== file.size) {
|
||||
throw new Error(`The original skill file is unavailable: ${sourcePath}`);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
const blobs = snapshot.readBlobs ? await snapshot.readBlobs(entries, signal) : null;
|
||||
const files: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
signal.throwIfAborted();
|
||||
const expected = manifest.files[index]!;
|
||||
const bytes = blobs?.get(entry.sha) ?? await snapshot.readBlob(entry, signal);
|
||||
if (bytes.length !== expected.size || packageSha256(bytes) !== expected.sha256) throw new Error(`Restored skill file checksum mismatch: ${expected.path}`);
|
||||
files.push({ path: expected.path, kind: expected.kind, content: bytes.toString(expected.encoding),
|
||||
...(expected.encoding === "base64" ? { encoding: expected.encoding } : {}), ...(expected.executable ? { executable: true } : {}) });
|
||||
}
|
||||
if (skillPackageManifest(files).contentHash !== manifest.contentHash) throw new Error("Restored skill package checksum mismatch");
|
||||
return files;
|
||||
} finally { await snapshot.release(); }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { skillPackageStore, type StoredSkillVersion } from "./skill-package-store.js";
|
||||
import { packageSha256, verifySkillPackageDirectory, relocateSkillPackageVerification, type SkillPackageFile, type SkillPackageManifest } from "./skill-package-files.js";
|
||||
import { REPOSITORY_CONTENT_DIR, repositorySkillProjection } from "./skill-repository-package.js";
|
||||
import { assertSkillSnapshotPath, skillFileBytes } from "./skill-snapshot.js";
|
||||
import { ensureSkillCacheDirectory } from "./skill-version-cache.js";
|
||||
|
||||
type ProjectedFile = SkillPackageFile & { sourcePath?: string; generated?: Buffer };
|
||||
// Only the wrapper/policy bytes and file metadata are retained, never a decoded repository.
|
||||
const projections = new Map<string, { files: ProjectedFile[]; bytes: number }>();
|
||||
let projectionBytes = 0;
|
||||
const MAX_PROJECTION_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_PROJECTIONS = 256;
|
||||
const repositoryFiles = new WeakMap<SkillPackageManifest, ProjectedFile[]>();
|
||||
function originalFiles(version: StoredSkillVersion, manifest: SkillPackageManifest): ProjectedFile[] {
|
||||
if (version.repositorySnapshotId) {
|
||||
let shared = repositoryFiles.get(manifest);
|
||||
if (!shared) {
|
||||
shared = manifest.files.map(file => ({ ...file, sourcePath: file.path, path: `${REPOSITORY_CONTENT_DIR}/${file.path}` }));
|
||||
repositoryFiles.set(manifest, shared);
|
||||
}
|
||||
return [...shared];
|
||||
}
|
||||
if (!version.packageFileMap) return manifest.files.map(file => ({ ...file, sourcePath: file.path }));
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
return version.packageFileMap.map(mapping => {
|
||||
assertSkillSnapshotPath(mapping.path);
|
||||
assertSkillSnapshotPath(mapping.sourcePath);
|
||||
const file = byPath.get(mapping.sourcePath);
|
||||
if (!file) throw new Error("Skill version references a missing package file");
|
||||
return { ...file, ...mapping };
|
||||
});
|
||||
}
|
||||
export function skillPackageRuntime(db: Db) {
|
||||
const store = skillPackageStore(db);
|
||||
async function inventory(version: StoredSkillVersion) {
|
||||
const id = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
if (!id) throw new Error("Skill version has no package");
|
||||
const manifest = await store.manifest(version.companyId, id);
|
||||
const files = originalFiles(version, manifest).map(({ path, kind }) => ({ path, kind }));
|
||||
if (version.repositorySnapshotId) {
|
||||
files.unshift({ path: "SKILL.md", kind: "skill" });
|
||||
const policy = path.posix.join(path.posix.dirname(version.repositorySkillPath!), "agents/openai.yaml");
|
||||
const file = manifest.files.find(file => file.path === policy);
|
||||
if (file) files.push({ path: "agents/openai.yaml", kind: file.kind });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
async function projection(version: StoredSkillVersion): Promise<ProjectedFile[]> {
|
||||
const id = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
if (!id) throw new Error("Skill version has no package");
|
||||
const manifest = await store.manifest(version.companyId, id);
|
||||
const key = `${version.companyId}:${version.id}:${manifest.contentHash}`;
|
||||
const files = originalFiles(version, manifest);
|
||||
const cached = projections.get(key);
|
||||
if (cached) return [...files, ...cached.files];
|
||||
const generatedFiles: ProjectedFile[] = [];
|
||||
if (version.repositorySnapshotId) {
|
||||
if (!version.repositorySkillPath) throw new Error("Repository skill entrypoint is missing");
|
||||
const generated = await store.withPackage(version.companyId, id, async (root, manifest) => {
|
||||
const wanted = new Set([version.repositorySkillPath!, path.posix.join(path.posix.dirname(version.repositorySkillPath!), "agents/openai.yaml")]);
|
||||
const selected: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const file of manifest.files.filter(file => wanted.has(file.path))) selected.push({
|
||||
...file, content: (await fs.readFile(path.join(root, file.path))).toString(file.encoding),
|
||||
});
|
||||
return repositorySkillProjection(selected, version.repositorySkillPath!).filter(file => !file.path.startsWith(`${REPOSITORY_CONTENT_DIR}/`));
|
||||
});
|
||||
for (const file of generated) {
|
||||
const bytes = skillFileBytes(file);
|
||||
generatedFiles.push({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8", executable: Boolean(file.executable),
|
||||
size: bytes.length, sha256: packageSha256(bytes), generated: bytes });
|
||||
}
|
||||
}
|
||||
// Retain only tiny generated wrappers/policies per version. Shared package
|
||||
// file metadata follows the bounded manifest cache through the WeakMap.
|
||||
const bytes = generatedFiles.reduce((sum, file) => sum + (file.generated?.length ?? 0) + 512, 0);
|
||||
while (projections.size && (projections.size >= MAX_PROJECTIONS || projectionBytes + bytes > MAX_PROJECTION_BYTES)) {
|
||||
const first = projections.keys().next().value!;
|
||||
projectionBytes -= projections.get(first)!.bytes;
|
||||
projections.delete(first);
|
||||
}
|
||||
if (generatedFiles.length && bytes <= MAX_PROJECTION_BYTES) { projections.set(key, { files: generatedFiles, bytes }); projectionBytes += bytes; }
|
||||
return [...files, ...generatedFiles];
|
||||
}
|
||||
async function materialize(version: StoredSkillVersion, directory: string) {
|
||||
const files = await projection(version);
|
||||
const parent = path.dirname(directory);
|
||||
await ensureSkillCacheDirectory(parent, true);
|
||||
if (await measureRunPhase("skills.version.verify", () => verifySkillPackageDirectory(directory, files), { fileCount: files.length })) return directory;
|
||||
return store.withPackage(version.companyId, (version.repositorySnapshotId ?? version.packageSnapshotId)!, async root => {
|
||||
const staging = await fs.mkdtemp(path.join(parent, `.${version.id}-`));
|
||||
try {
|
||||
for (const file of files) {
|
||||
const target = path.join(staging, file.path);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
if (file.generated) await fs.writeFile(target, file.generated);
|
||||
else await fs.copyFile(path.join(root, file.sourcePath!), target);
|
||||
await fs.chmod(target, file.executable ? 0o555 : 0o444);
|
||||
}
|
||||
if (!await verifySkillPackageDirectory(staging, files)) throw new Error("Prepared skill version failed integrity verification");
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
await fs.rename(staging, directory);
|
||||
relocateSkillPackageVerification(staging, directory, files);
|
||||
return directory;
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
async function readFile(version: StoredSkillVersion, relative: string) {
|
||||
const file = (await projection(version)).find(file => file.path === relative);
|
||||
if (!file) return null;
|
||||
const bytes = file.generated ?? await store.withPackage(version.companyId,
|
||||
(version.repositorySnapshotId ?? version.packageSnapshotId)!, root => fs.readFile(path.join(root, file.sourcePath!)));
|
||||
return { path: file.path, kind: file.kind, content: bytes.toString(file.encoding), encoding: file.encoding, executable: file.executable };
|
||||
}
|
||||
return { inventory, materialize, readFile };
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { recoverSkillPackage, skillPackageRecoveryReader } from "./skill-package-recovery.js";
|
||||
import { touchSkillPackage, trimSkillPackageCache } from "./skill-package-cache.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions, type Db, type SkillPackageStorageReceipt, type SkillPackageRestoreRecipe } from "@paperclipai/db";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { assertSkillSnapshotPath, skillFileBytes } from "./skill-snapshot.js";
|
||||
import { ensureSkillCacheDirectory, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
import { boundedHashStream, extractSkillPackageArchive, MAX_SKILL_MANIFEST_BYTES, packageSha256,
|
||||
skillPackageManifest, skillPackageManifestBytes, validateSkillPackageManifest, verifySkillPackageDirectory, relocateSkillPackageVerification, writeSkillPackageArchive,
|
||||
type SkillPackageManifest } from "./skill-package-files.js";
|
||||
|
||||
/** External storage is the default; false is an explicit rolling-upgrade escape hatch. */
|
||||
export function skillPackageStorageEnabled() {
|
||||
return process.env.PAPERCLIP_SKILL_PACKAGE_STORAGE_ENABLED !== "false";
|
||||
}
|
||||
|
||||
const descriptorColumns = { id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash,
|
||||
storage: snapshots.storage, upload: snapshots.upload, createdAt: snapshots.createdAt,
|
||||
restorable: sql<boolean>`${snapshots.restore} is not null`, restoreRetryAt: snapshots.restoreRetryAt };
|
||||
type Descriptor = Pick<typeof snapshots.$inferSelect, "id" | "companyId" | "contentHash" | "storage" | "upload" | "createdAt" | "restoreRetryAt"> & { restorable: boolean };
|
||||
export const skillVersionMetadataColumns = { ...getTableColumns(companySkillVersions),
|
||||
fileInventory: sql<CompanySkillVersionFileInventoryEntry[]>`'[]'::jsonb` };
|
||||
export type StoredSkillVersion = typeof companySkillVersions.$inferSelect;
|
||||
type ReadScope = { db: Db; descriptors: Map<string, Promise<Descriptor>>; durable: Map<string, Promise<boolean>>; versions: Map<string, StoredSkillVersion> };
|
||||
const readScope = new AsyncLocalStorage<ReadScope>();
|
||||
export function withSkillPackageReadScope<T>(db: Db, action: () => Promise<T>): Promise<T> {
|
||||
return readScope.getStore()?.db === db ? action() : readScope.run({ db, descriptors: new Map(), durable: new Map(), versions: new Map() }, action);
|
||||
}
|
||||
const flights = new WeakMap<Db, Map<string, Promise<unknown>>>();
|
||||
function flight<T>(db: Db, key: string, action: () => Promise<T>): Promise<T> {
|
||||
let pending = flights.get(db);
|
||||
if (!pending) { pending = new Map(); flights.set(db, pending); }
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const work = action().finally(() => pending!.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
let activeIo = 0;
|
||||
const waitingIo: Array<() => void> = [];
|
||||
async function withPackageIo<T>(action: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
if (activeIo >= 2) {
|
||||
if (waitingIo.length >= 32) throw conflict("Skill package storage is busy; retry shortly.");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ready = () => { signal.removeEventListener("abort", aborted); resolve(); };
|
||||
const aborted = () => {
|
||||
const index = waitingIo.indexOf(ready);
|
||||
if (index >= 0) waitingIo.splice(index, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
waitingIo.push(ready);
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
} else activeIo++;
|
||||
try { signal.throwIfAborted(); return await action(); }
|
||||
finally { const next = waitingIo.shift(); if (next) next(); else activeIo--; }
|
||||
}
|
||||
const manifests = new Map<string, { manifest: SkillPackageManifest; bytes: number }>();
|
||||
let manifestBytes = 0;
|
||||
const MAX_MANIFEST_CACHE = 16 * 1024 * 1024;
|
||||
function rememberManifest(key: string, manifest: SkillPackageManifest, bytes: number) {
|
||||
if (bytes > MAX_MANIFEST_CACHE) return;
|
||||
const previous = manifests.get(key);
|
||||
if (previous) manifestBytes -= previous.bytes;
|
||||
manifests.delete(key);
|
||||
while (manifests.size && manifestBytes + bytes > MAX_MANIFEST_CACHE) {
|
||||
const first = manifests.keys().next().value!;
|
||||
manifestBytes -= manifests.get(first)!.bytes;
|
||||
manifests.delete(first);
|
||||
}
|
||||
manifests.set(key, { manifest, bytes });
|
||||
manifestBytes += bytes;
|
||||
}
|
||||
export function skillPackagePaths(companyId: string, snapshotId: string) {
|
||||
if (![companyId, snapshotId].every(id => /^[a-f0-9-]{36}$/i.test(id))) throw new Error("Invalid skill package identity");
|
||||
const parent = path.join(resolvePaperclipInstanceRoot(), "skills", companyId, "__packages__");
|
||||
return { parent, directory: path.join(parent, snapshotId), manifest: path.join(parent, `${snapshotId}.json`) };
|
||||
}
|
||||
async function readBoundedObject(storage: StorageService, companyId: string, key: string, length: number, hash: string, signal: AbortSignal) {
|
||||
if (!Number.isSafeInteger(length) || length < 0 || length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Invalid skill manifest size");
|
||||
const object = await storage.getObject(companyId, key, { signal });
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of object.stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.length;
|
||||
if (bytes > length) throw new Error("Skill manifest exceeds its recorded size");
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} finally { object.stream.destroy(); }
|
||||
const result = Buffer.concat(chunks);
|
||||
if (bytes !== length || packageSha256(result) !== hash) throw new Error("Skill manifest checksum mismatch");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Owns immutable package storage. Authorization belongs to the referencing
|
||||
* skill/source, and must be checked even when package bytes are already cached. */
|
||||
export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
||||
const storage = () => suppliedStorage ?? getStorageService();
|
||||
const scope = (companyId: string, id: string) => and(eq(snapshots.companyId, companyId), eq(snapshots.id, id));
|
||||
async function descriptor(companyId: string, id: string): Promise<Descriptor> {
|
||||
const load = async () => {
|
||||
const [row] = await db.select(descriptorColumns).from(snapshots).where(scope(companyId, id));
|
||||
if (!row || (!row.storage && !row.restorable && row.upload && !row.upload.legacy)) throw notFound("Installed skill package is unavailable");
|
||||
return row;
|
||||
};
|
||||
const context = readScope.getStore();
|
||||
if (context?.db !== db) return load();
|
||||
const key = `${companyId}:${id}`;
|
||||
if (!context.descriptors.has(key)) context.descriptors.set(key, load());
|
||||
return context.descriptors.get(key)!;
|
||||
}
|
||||
async function version(companyId: string, skillId: string, id: string): Promise<StoredSkillVersion | null> {
|
||||
const context = readScope.getStore();
|
||||
const cached = context?.db === db ? context.versions.get(`${companyId}:${id}`) : null;
|
||||
if (cached) return cached.companySkillId === skillId ? cached : null;
|
||||
const [row] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId), eq(companySkillVersions.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
async function prefetch(companyId: string, versionIds: string[]) {
|
||||
const context = readScope.getStore();
|
||||
if (context?.db !== db || versionIds.length < 2) return;
|
||||
const rows = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), inArray(companySkillVersions.id, [...new Set(versionIds)])));
|
||||
for (const row of rows) context.versions.set(`${companyId}:${row.id}`, row);
|
||||
const ids = [...new Set(rows.flatMap(row => [row.repositorySnapshotId, row.packageSnapshotId]).filter((id): id is string => Boolean(id)))];
|
||||
if (!ids.length) return;
|
||||
const packages = await db.select(descriptorColumns).from(snapshots).where(and(eq(snapshots.companyId, companyId), inArray(snapshots.id, ids)));
|
||||
for (const row of packages) if (row.storage || row.restorable || !row.upload || row.upload.legacy) {
|
||||
context.descriptors.set(`${companyId}:${row.id}`, Promise.resolve(row));
|
||||
}
|
||||
}
|
||||
async function restoreRecipe(row: Descriptor) {
|
||||
const [saved] = await db.select({ restore: snapshots.restore }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
const recipe = saved?.restore;
|
||||
if (!recipe || recipe.version !== 1) throw notFound("This skill package has no exact-commit restore recipe");
|
||||
const manifest = validateSkillPackageManifest(recipe.manifest);
|
||||
if (manifest.contentHash !== row.contentHash || (row.storage && packageSha256(skillPackageManifestBytes(manifest)) !== row.storage.manifestSha256)) {
|
||||
throw new Error("Skill package restore recipe does not match its receipt");
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
async function acquireFiles(row: Descriptor, signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
const [current] = await db.select({ retryAt: snapshots.restoreRetryAt }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (current?.retryAt && current.retryAt.getTime() > Date.now()) throw conflict("The original skill package could not be acquired. Reconnect its repository and retry after the recovery backoff.");
|
||||
try {
|
||||
const recipe = await restoreRecipe(row);
|
||||
const read = await skillPackageRecoveryReader(db, row.companyId, recipe);
|
||||
const files = await recoverSkillPackage(recipe, read, signal);
|
||||
await db.update(snapshots).set({ restoreRetryAt: null }).where(scope(row.companyId, row.id));
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) await db.update(snapshots).set({ restoreRetryAt: new Date(Date.now() + 5 * 60_000) }).where(scope(row.companyId, row.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function legacyFiles(row: Descriptor) {
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory, storage: snapshots.storage, upload: snapshots.upload })
|
||||
.from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (!legacy || (!legacy.files.length && !legacy.storage)) throw notFound("Installed skill package has no saved contents");
|
||||
// A reader may have loaded its descriptor just before a migration clears
|
||||
// the legacy payload. Upgrade this request's descriptor to the published
|
||||
// receipt instead of reporting a missing historical version.
|
||||
if (legacy.storage) { row.storage = legacy.storage; row.upload = legacy.upload; }
|
||||
return legacy.files;
|
||||
}
|
||||
async function manifestFor(row: Descriptor): Promise<SkillPackageManifest> {
|
||||
const paths = skillPackagePaths(row.companyId, row.id);
|
||||
const key = `${paths.manifest}:${row.storage?.manifestSha256 ?? row.contentHash}`;
|
||||
const cached = manifests.get(key);
|
||||
if (cached) return cached.manifest;
|
||||
return flight(db, `manifest:${key}`, async () => {
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
// Legacy manifests have no independently stored checksum. Derive them once
|
||||
// per process from the authoritative row until the operator migrates it.
|
||||
let bytes: Buffer | null = null;
|
||||
if (row.storage) {
|
||||
try {
|
||||
const file = await fs.open(paths.manifest, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (stat.isFile() && stat.size <= MAX_SKILL_MANIFEST_BYTES) bytes = await file.readFile();
|
||||
} finally { await file.close(); }
|
||||
} catch (error) {
|
||||
if (!["ENOENT", "ELOOP", "EISDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
||||
}
|
||||
}
|
||||
if (row.storage) {
|
||||
if (!bytes || packageSha256(bytes) !== row.storage.manifestSha256) {
|
||||
const signal = AbortSignal.timeout(180_000);
|
||||
try {
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
|
||||
bytes = await withPackageIo(() => readBoundedObject(storage(), row.companyId, row.storage!.manifestKey,
|
||||
row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal);
|
||||
} catch (error) {
|
||||
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
const saved = legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null;
|
||||
if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved;
|
||||
else if (row.restorable) bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
} else if (row.restorable) {
|
||||
bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||
} else if (!bytes) {
|
||||
const files = await legacyFiles(row);
|
||||
if (row.storage) return manifestFor(row);
|
||||
bytes = skillPackageManifestBytes(skillPackageManifest(files));
|
||||
}
|
||||
if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
||||
const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8")));
|
||||
if (row.storage && (manifest.contentHash !== row.contentHash || manifest.files.length !== row.storage.fileCount
|
||||
|| manifest.totalBytes !== row.storage.totalBytes)) throw new Error("Skill package receipt does not match its manifest");
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
const temp = `${paths.manifest}.${randomUUID()}`;
|
||||
try { await fs.writeFile(temp, bytes, { flag: "wx", mode: 0o600 }); await fs.rename(temp, paths.manifest); }
|
||||
finally { await fs.rm(temp, { force: true }); }
|
||||
rememberManifest(key, manifest, bytes.length);
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
async function materialize(row: Descriptor, manifest: SkillPackageManifest) {
|
||||
const paths = skillPackagePaths(row.companyId, row.id);
|
||||
return flight(db, `files:${paths.directory}`, async () => {
|
||||
await ensureSkillCacheDirectory(paths.parent, true);
|
||||
if (await verifySkillPackageDirectory(paths.directory, manifest.files)) return { directory: paths.directory, created: false };
|
||||
const signal = AbortSignal.timeout(180_000);
|
||||
return withPackageIo(async () => {
|
||||
const staging = await fs.mkdtemp(path.join(paths.parent, ".package-"));
|
||||
const archivePath = `${staging}.tar.gz`;
|
||||
try {
|
||||
let legacy: CompanySkillVersionFileInventoryEntry[] | null = null;
|
||||
if (!row.storage && !row.restorable) legacy = await legacyFiles(row);
|
||||
try {
|
||||
if (row.storage) {
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
|
||||
const object = await storage().getObject(row.companyId, row.storage.archiveKey, { signal });
|
||||
const digest = boundedHashStream(row.storage.archiveBytes);
|
||||
await pipeline(object.stream, digest.stream, createWriteStream(archivePath, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== row.storage.archiveBytes || actual.sha256 !== row.storage.archiveSha256) throw new Error("Skill package archive checksum mismatch");
|
||||
await extractSkillPackageArchive(archivePath, staging, manifest, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
// A missing/corrupt archive must not hide a still-authoritative legacy
|
||||
// body. Only then use the saved, authorized immutable Git recipe.
|
||||
const [saved] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
||||
if (saved?.files.length && skillPackageManifest(saved.files).contentHash === row.contentHash) legacy = saved.files;
|
||||
else if (row.restorable) legacy = await acquireFiles(row, signal);
|
||||
else throw error;
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
await fs.mkdir(staging);
|
||||
}
|
||||
if (!row.storage && row.restorable && !legacy) legacy = await acquireFiles(row, signal);
|
||||
if (legacy) {
|
||||
for (const file of legacy) {
|
||||
signal.throwIfAborted();
|
||||
const target = path.join(staging, assertSkillSnapshotPath(file.path));
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(target, skillFileBytes(file), { flag: "wx", mode: file.executable ? 0o555 : 0o444 });
|
||||
}
|
||||
}
|
||||
if (!await verifySkillPackageDirectory(staging, manifest.files)) throw new Error("Restored skill package does not match its manifest");
|
||||
// All consumers hold the company cache lock, including processes sharing a volume.
|
||||
await fs.rm(paths.directory, { recursive: true, force: true });
|
||||
await fs.rename(staging, paths.directory);
|
||||
relocateSkillPackageVerification(staging, paths.directory, manifest.files);
|
||||
return { directory: paths.directory, created: true };
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); await fs.rm(archivePath, { force: true }); }
|
||||
}, signal);
|
||||
});
|
||||
}
|
||||
async function withPackage<T>(companyId: string, id: string, consume: (root: string, manifest: SkillPackageManifest) => Promise<T>) {
|
||||
const row = await descriptor(companyId, id);
|
||||
const manifest = await manifestFor(row);
|
||||
return withSkillVersionCacheLock(db, companyId, async () => {
|
||||
const restored = await materialize(row, manifest);
|
||||
touchSkillPackage(restored.directory);
|
||||
if (restored.created) await trimSkillPackageCache(db, companyId, restored.directory,
|
||||
(id, signal) => verifyDurable(companyId, id, signal).catch(() => false));
|
||||
return consume(restored.directory, manifest);
|
||||
});
|
||||
}
|
||||
async function files(companyId: string, id: string): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
return withPackage(companyId, id, async (root, manifest) => {
|
||||
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const entry of manifest.files) result.push({ path: entry.path, kind: entry.kind, content: (await fs.readFile(path.join(root, entry.path))).toString(entry.encoding),
|
||||
...(entry.encoding === "base64" ? { encoding: entry.encoding } : {}), ...(entry.executable ? { executable: true } : {}) });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async function prepare(companyId: string, files: CompanySkillVersionFileInventoryEntry[], migrateLegacy = false, cancellation?: AbortSignal, restore?: SkillPackageRestoreRecipe): Promise<string> {
|
||||
cancellation?.throwIfAborted();
|
||||
const manifest = skillPackageManifest(files);
|
||||
if (restore && !skillPackageManifestBytes(restore.manifest).equals(skillPackageManifestBytes(manifest))) throw new Error("Restore recipe does not describe these package bytes");
|
||||
return flight(db, `prepare:${companyId}:${manifest.contentHash}`, async () => {
|
||||
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||
const token = randomUUID();
|
||||
const prefix = `${companyId}/skill-packages/${token}`;
|
||||
const upload = { token, legacy: false, provider: storage().provider, archiveKey: `${prefix}/files.tar.gz`, manifestKey: `${prefix}/manifest.json`, expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||
const [created] = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
return tx.insert(snapshots).values({ companyId, contentHash: manifest.contentHash, upload, restore,
|
||||
migrationAt: null, maintenanceAt: new Date(Date.now() + 24 * 60 * 60_000) }).onConflictDoNothing().returning(descriptorColumns);
|
||||
});
|
||||
let existing = created ?? (await db.select(descriptorColumns).from(snapshots).where(and(eq(snapshots.companyId, companyId), eq(snapshots.contentHash, manifest.contentHash))))[0]!;
|
||||
if (!created) {
|
||||
if (existing.storage || (!existing.upload && !migrateLegacy && !existing.restorable)) {
|
||||
// Content deduplication must not attach a recipe with different kind or
|
||||
// encoding metadata to an already published manifest.
|
||||
if (restore && !existing.restorable && existing.storage?.manifestSha256 === packageSha256(skillPackageManifestBytes(manifest))) {
|
||||
await db.update(snapshots).set({ restore }).where(and(scope(companyId, existing.id), sql`${snapshots.restore} is null`));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
// A stale claim is replaced atomically; the DB trigger retains its exact
|
||||
// keys in the deletion outbox. Never reuse keys from another attempt.
|
||||
upload.legacy = !existing.upload || Boolean(existing.upload.legacy);
|
||||
const [claimed] = await db.update(snapshots).set({ upload }).where(and(scope(companyId, existing.id),
|
||||
sql`${snapshots.storage} is null`,
|
||||
sql`(${snapshots.upload} is null or (${snapshots.upload}->>'expiresAt')::timestamptz <= now())`)).returning(descriptorColumns);
|
||||
if (!claimed) throw conflict("This skill package is being saved. Retry after its upload settles.");
|
||||
existing = claimed;
|
||||
}
|
||||
return withPackageIo(async () => {
|
||||
const root = skillPackagePaths(companyId, existing.id).parent;
|
||||
await ensureSkillCacheDirectory(root, true);
|
||||
const staging = await fs.mkdtemp(path.join(root, ".upload-"));
|
||||
const archivePath = path.join(staging, "files.tar.gz");
|
||||
const manifestBuffer = skillPackageManifestBytes(manifest);
|
||||
if (manifestBuffer.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
||||
const backend = storage();
|
||||
try {
|
||||
const archive = await writeSkillPackageArchive(archivePath, files, signal);
|
||||
await backend.putFile({ companyId, namespace: "skill-packages", objectKey: upload.archiveKey, originalFilename: "files.tar.gz",
|
||||
contentType: "application/gzip", body: createReadStream(archivePath), byteSize: archive.bytes, sha256: archive.sha256, signal });
|
||||
await backend.putFile({ companyId, namespace: "skill-packages", objectKey: upload.manifestKey, originalFilename: "manifest.json",
|
||||
contentType: "application/json", body: manifestBuffer, signal });
|
||||
const receipt: SkillPackageStorageReceipt = { format: "tar-gzip-v1", provider: backend.provider,
|
||||
archiveKey: upload.archiveKey, archiveSha256: archive.sha256, archiveBytes: archive.bytes,
|
||||
manifestKey: upload.manifestKey, manifestSha256: packageSha256(manifestBuffer), manifestBytes: manifestBuffer.length,
|
||||
fileCount: manifest.files.length, totalBytes: manifest.totalBytes };
|
||||
// A complete read verifies storage independently before publishing any reference.
|
||||
await readBoundedObject(backend, companyId, receipt.manifestKey, receipt.manifestBytes, receipt.manifestSha256, signal);
|
||||
const verify = await backend.getObject(companyId, receipt.archiveKey, { signal });
|
||||
const digest = boundedHashStream(receipt.archiveBytes);
|
||||
const downloaded = path.join(staging, "verified.tar.gz");
|
||||
await pipeline(verify.stream, digest.stream, createWriteStream(downloaded, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== receipt.archiveBytes || actual.sha256 !== receipt.archiveSha256) throw new Error("Uploaded skill package checksum mismatch");
|
||||
const restored = path.join(staging, "restored");
|
||||
await fs.mkdir(restored);
|
||||
await extractSkillPackageArchive(downloaded, restored, manifest, signal);
|
||||
signal.throwIfAborted();
|
||||
const [saved] = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
return tx.update(snapshots).set({ storage: receipt, upload: null, ...(restore ? { restore } : {}) })
|
||||
.where(and(scope(companyId, existing.id), sql`${snapshots.upload}->>'token' = ${token}`)).returning({ id: snapshots.id });
|
||||
});
|
||||
if (!saved) throw conflict("Skill package upload ownership changed");
|
||||
return saved.id;
|
||||
} finally {
|
||||
// Failed/ambiguous uploads retain their durable claim. Maintenance reconciles
|
||||
// exact owned keys; never delete a successful object on an uncertain SQL result.
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}, signal);
|
||||
});
|
||||
}
|
||||
async function verifyDurable(companyId: string, id: string, cancellation?: AbortSignal): Promise<boolean> {
|
||||
const action = async () => {
|
||||
const row = await descriptor(companyId, id);
|
||||
if (!row.storage) { await manifestFor(row); if (!row.storage) return true; }
|
||||
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed; migrate its objects before switching storage");
|
||||
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||
return withPackageIo(async () => {
|
||||
const receipt = row.storage!;
|
||||
const bytes = await readBoundedObject(storage(), companyId, receipt.manifestKey, receipt.manifestBytes, receipt.manifestSha256, signal);
|
||||
const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8")));
|
||||
if (manifest.contentHash !== row.contentHash) return false;
|
||||
const parent = skillPackagePaths(companyId, id).parent;
|
||||
await ensureSkillCacheDirectory(parent, true);
|
||||
const staging = await fs.mkdtemp(path.join(parent, ".verify-"));
|
||||
try {
|
||||
const object = await storage().getObject(companyId, receipt.archiveKey, { signal });
|
||||
const digest = boundedHashStream(receipt.archiveBytes);
|
||||
const archive = path.join(staging, "files.tar.gz");
|
||||
await pipeline(object.stream, digest.stream, createWriteStream(archive, { flags: "wx", mode: 0o600 }), { signal });
|
||||
const actual = digest.result();
|
||||
if (actual.bytes !== receipt.archiveBytes || actual.sha256 !== receipt.archiveSha256) return false;
|
||||
const restored = path.join(staging, "files");
|
||||
await fs.mkdir(restored);
|
||||
await extractSkillPackageArchive(archive, restored, manifest, signal);
|
||||
return true;
|
||||
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
||||
}, signal);
|
||||
};
|
||||
const context = readScope.getStore();
|
||||
const key = `${companyId}:${id}`;
|
||||
if (context?.db !== db) return action();
|
||||
if (!context.durable.has(key)) context.durable.set(key, action());
|
||||
return context.durable.get(key)!;
|
||||
}
|
||||
async function versionFiles(version: StoredSkillVersion): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||
if (!version.packageSnapshotId) {
|
||||
const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id)));
|
||||
if (row?.packageSnapshotId) return versionFiles(row);
|
||||
return row?.files ?? [];
|
||||
}
|
||||
if (!version.packageFileMap) return files(version.companyId, version.packageSnapshotId);
|
||||
return withPackage(version.companyId, version.packageSnapshotId, async (root, manifest) => {
|
||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
||||
for (const mapping of version.packageFileMap!) {
|
||||
assertSkillSnapshotPath(mapping.path);
|
||||
assertSkillSnapshotPath(mapping.sourcePath);
|
||||
const file = byPath.get(mapping.sourcePath);
|
||||
if (!file) throw new Error("Skill version references a missing package file");
|
||||
result.push({ path: mapping.path, kind: mapping.kind,
|
||||
content: (await fs.readFile(path.join(root, file.path))).toString(file.encoding),
|
||||
...(file.encoding === "base64" ? { encoding: file.encoding } : {}), ...(file.executable ? { executable: true } : {}) });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return { descriptor, prepare, files, version, prefetch, versionFiles, withPackage, verifyDurable,
|
||||
manifest: async (companyId: string, id: string) => manifestFor(await descriptor(companyId, id)) };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { companies, companySkillRepositorySnapshots as snapshots, createDb } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { skillPackageWork } from "./skill-package-work.js";
|
||||
import { skillPackageStore } from "./skill-package-store.js";
|
||||
import { skillSnapshotHash } from "./skill-snapshot.js";
|
||||
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { idleWorkSnapshot } from "./task-admission.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("automatic skill package work", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let storage: ReturnType<typeof createStorageService>;
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
let home: string;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const files = [{ path: "SKILL.md", kind: "skill" as const, content: "---\nname: legacy\n---\noriginal" }];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("skill-package-work-");
|
||||
db = createDb(database.connectionString, { maxConnections: 2 });
|
||||
home = await fs.mkdtemp(path.join(os.tmpdir(), "skill-package-work-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
storage = createStorageService(createLocalDiskStorageProvider(path.join(home, "objects")));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await db.execute(sql`truncate companies cascade`);
|
||||
await db.execute(sql`truncate skill_package_deletions`);
|
||||
});
|
||||
afterEach(async () => { await coordinator?.stop(); coordinator = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
afterAll(async () => {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
await db?.$client.end({ timeout: 1 }); await database?.cleanup();
|
||||
if (home) await fs.rm(home, { recursive: true, force: true });
|
||||
});
|
||||
async function company() {
|
||||
const id = randomUUID();
|
||||
await db.insert(companies).values({ id, name: "Migration", issuePrefix: "MIG" });
|
||||
return id;
|
||||
}
|
||||
it("stops querying after empty startup and retains a first committed wake through drain", async () => {
|
||||
const companyId = await company();
|
||||
let draining = false;
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError: error => { throw error; } });
|
||||
const work = skillPackageWork(db, storage).start(coordinator);
|
||||
await work.ready;
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
let queries = 0;
|
||||
const debug = db.$client.options.debug;
|
||||
db.$client.options.debug = () => { queries++; };
|
||||
try {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(queries).toBe(0);
|
||||
draining = true;
|
||||
let id: string;
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
[id] = (await tx.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning({ id: snapshots.id })).map(row => row.id);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
|
||||
const baseline = queries;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(queries).toBe(baseline);
|
||||
draining = false;
|
||||
work.wake();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.waitFor(async () => {
|
||||
const [row] = await db.select().from(snapshots).where(eq(snapshots.id, id!));
|
||||
expect(row!.fileInventory).toEqual([]);
|
||||
expect(row!.storage).not.toBeNull();
|
||||
});
|
||||
} finally { db.$client.options.debug = debug; }
|
||||
});
|
||||
it("keeps legacy contents on failure, persists a retry and resumes after restart", async () => {
|
||||
const companyId = await company();
|
||||
const [row] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: files }).returning();
|
||||
const upload = vi.spyOn(storage, "putFile").mockRejectedValue(new Error("offline"));
|
||||
const service = skillPackageWork(db, storage);
|
||||
await service.tick(new AbortController().signal);
|
||||
const [failed] = await db.select().from(snapshots).where(eq(snapshots.id, row!.id));
|
||||
expect(failed!.fileInventory).toEqual(files);
|
||||
expect(failed!.migrationAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(await service.deadline()).toBe(failed!.migrationAt!.getTime());
|
||||
upload.mockRestore();
|
||||
await db.update(snapshots).set({ migrationAt: new Date(0), upload: { ...failed!.upload!, expiresAt: new Date(0).toISOString() } }).where(eq(snapshots.id, row!.id));
|
||||
await skillPackageWork(db, storage).tick(new AbortController().signal);
|
||||
const [saved] = await db.select().from(snapshots).where(eq(snapshots.id, row!.id));
|
||||
expect(saved!.fileInventory).toEqual([]);
|
||||
expect(saved!.migrationAt).toBeNull();
|
||||
expect(await skillPackageStore(db, storage).files(companyId, row!.id)).toEqual(files);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { and, eq, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
||||
import { companySkillRepositorySnapshots as snapshots, companySkillVersions as versions,
|
||||
skillPackageDeletions, agents, type Db } from "@paperclipai/db";
|
||||
import { getStorageService, type StorageService } from "../storage/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { validateSkillPackageManifest, skillPackageManifestBytes, packageSha256 } from "./skill-package-files.js";
|
||||
import { skillPackageMaintenance } from "./skill-package-maintenance.js";
|
||||
import { skillPackagePaths, skillPackageStorageEnabled } from "./skill-package-store.js";
|
||||
import { ensureSkillCacheDirectory, protectedVersions, SkillCacheBusy, withSkillVersionCacheLock } from "./skill-version-cache.js";
|
||||
|
||||
export const SKILL_PACKAGE_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
const RETRY_MS = 60 * 60_000;
|
||||
const PAGE_SIZE = 4;
|
||||
|
||||
/** A finite upgrade plus persisted cleanup deadlines. No work is added to turn
|
||||
* preparation; completed history has no recurring SQL/network obligation. */
|
||||
export function skillPackageWork(db: Db, storage?: StorageService) {
|
||||
const maintenance = skillPackageMaintenance(db, storage);
|
||||
const backend = () => storage ?? getStorageService();
|
||||
async function retire(id: string, signal: AbortSignal) {
|
||||
const [row] = await db.select({ id: snapshots.id, companyId: snapshots.companyId, contentHash: snapshots.contentHash, createdAt: snapshots.createdAt,
|
||||
storage: snapshots.storage, upload: snapshots.upload, restore: snapshots.restore, migrationAt: snapshots.migrationAt })
|
||||
.from(snapshots).where(eq(snapshots.id, id));
|
||||
if (!row) return;
|
||||
signal.throwIfAborted();
|
||||
// Orphan deletion is delayed so an upload can publish its first version.
|
||||
const removeLocal = async () => {
|
||||
const paths = skillPackagePaths(row.companyId, id);
|
||||
try {
|
||||
await ensureSkillCacheDirectory(paths.parent);
|
||||
await fs.rm(paths.directory, { recursive: true, force: true });
|
||||
await fs.rm(paths.manifest, { force: true });
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
};
|
||||
if (await withSkillVersionCacheLock(db, row.companyId, async () => {
|
||||
if (!(await maintenance.collectOrphans({ id, apply: true, signal })).removed) return false;
|
||||
await removeLocal();
|
||||
return true;
|
||||
}, true)) return;
|
||||
const orphanAt = row.createdAt.getTime() + 24 * 60 * 60_000;
|
||||
let next: Date | null = orphanAt > Date.now() ? new Date(orphanAt) : null;
|
||||
if (row.upload) next = new Date(Math.max(Date.now() + RETRY_MS, new Date(row.upload.expiresAt).getTime(), row.createdAt.getTime() + 24 * 60 * 60_000));
|
||||
else if (row.restore && row.storage) {
|
||||
const expires = row.createdAt.getTime() + SKILL_PACKAGE_RETENTION_MS;
|
||||
if (expires > Date.now()) next = new Date(expires);
|
||||
else if (!row.migrationAt) {
|
||||
const manifest = validateSkillPackageManifest(row.restore.manifest);
|
||||
if (row.restore.version !== 1 || manifest.contentHash !== row.contentHash
|
||||
|| packageSha256(skillPackageManifestBytes(manifest)) !== row.storage.manifestSha256) throw new Error("Invalid skill restore recipe; keep durable bytes");
|
||||
await withSkillVersionCacheLock(db, row.companyId, async () => {
|
||||
const referenced = await db.select({ id: versions.id }).from(versions).where(and(eq(versions.companyId, row.companyId),
|
||||
sql`(${versions.repositorySnapshotId} = ${id} or ${versions.packageSnapshotId} = ${id})`));
|
||||
const [current] = await db.execute(sql`select 1 from company_skills s where s.company_id = ${row.companyId}
|
||||
and s.current_version_id in (select id from company_skill_versions where repository_snapshot_id = ${id} or package_snapshot_id = ${id})
|
||||
union all select 1 from company_skill_test_runs t where t.company_id = ${row.companyId}
|
||||
and t.status in ('queued', 'running') and t.skill_version_id in
|
||||
(select id from company_skill_versions where repository_snapshot_id = ${id} or package_snapshot_id = ${id}) limit 1`);
|
||||
if (current) return;
|
||||
const pinned = new Set<string>();
|
||||
for (const agent of await db.select({ config: agents.adapterConfig }).from(agents).where(eq(agents.companyId, row.companyId))) {
|
||||
for (const entry of readPaperclipSkillSyncPreference(agent.config ?? {}).desiredSkillEntries) if (entry.versionId) pinned.add(entry.versionId);
|
||||
}
|
||||
const active = await protectedVersions(db, row.companyId);
|
||||
if (!active || referenced.some(version => active.has(version.id) || pinned.has(version.id))) return;
|
||||
signal.throwIfAborted();
|
||||
// Metadata/recipe remain. Triggered deletion receipts retain the old
|
||||
// object keys through the rollback grace; future reads reacquire the SHA.
|
||||
await db.update(snapshots).set({ storage: null }).where(and(eq(snapshots.id, id), isNull(snapshots.upload)));
|
||||
await removeLocal();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
await db.update(snapshots).set({ maintenanceAt: next }).where(and(eq(snapshots.id, id), lte(snapshots.maintenanceAt, new Date())));
|
||||
}
|
||||
async function tick(signal: AbortSignal) {
|
||||
if (!skillPackageStorageEnabled()) return;
|
||||
for (const table of [snapshots, versions]) {
|
||||
const rows = await db.select({ id: table.id }).from(table).where(lte(table.migrationAt, new Date())).orderBy(table.migrationAt).limit(PAGE_SIZE);
|
||||
for (const row of rows) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
if (table === snapshots) await maintenance.migrateSnapshots({ id: row.id, apply: true, clearLegacy: true, signal });
|
||||
else {
|
||||
// Bundled releases intentionally retain their small inline contract.
|
||||
const result = await maintenance.migrateVersions({ id: row.id, apply: true, clearLegacy: true, signal });
|
||||
if (!result.inspected) await db.update(versions).set({ migrationAt: null }).where(eq(versions.id, row.id));
|
||||
}
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(table).set({ migrationAt: new Date(Date.now() + RETRY_MS) }).where(and(eq(table.id, row.id), isNotNull(table.migrationAt)));
|
||||
logger.warn({ packageId: row.id }, "Skill package conversion deferred; original contents retained");
|
||||
}
|
||||
}
|
||||
}
|
||||
const due = await db.select({ id: snapshots.id }).from(snapshots).where(lte(snapshots.maintenanceAt, new Date())).orderBy(snapshots.maintenanceAt).limit(PAGE_SIZE);
|
||||
for (const { id } of due) {
|
||||
try { await retire(id, signal); }
|
||||
catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date(Date.now() + (error instanceof SkillCacheBusy ? 60_000 : RETRY_MS)) }).where(and(eq(snapshots.id, id), lte(snapshots.maintenanceAt, new Date())));
|
||||
if (!(error instanceof SkillCacheBusy)) logger.warn({ packageId: id }, "Skill package cleanup deferred");
|
||||
}
|
||||
}
|
||||
const deletions = await db.select({ id: skillPackageDeletions.id }).from(skillPackageDeletions).where(and(
|
||||
eq(skillPackageDeletions.provider, backend().provider), lte(skillPackageDeletions.deleteAfter, new Date()))).limit(PAGE_SIZE);
|
||||
for (const { id } of deletions) {
|
||||
try { await maintenance.deleteObjects({ id, apply: true, signal }); }
|
||||
catch (error) {
|
||||
signal.throwIfAborted();
|
||||
await db.update(skillPackageDeletions).set({ deleteAfter: new Date(Date.now() + RETRY_MS) }).where(eq(skillPackageDeletions.id, id));
|
||||
logger.warn({ deletionId: id }, "Skill package object deletion deferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
async function deadline() {
|
||||
if (!skillPackageStorageEnabled()) return null;
|
||||
const [row] = await db.execute<{ at: string | Date | null }>(sql`select min(at) as at from (
|
||||
select min(migration_at) as at from company_skill_repository_snapshots
|
||||
union all select min(migration_at) from company_skill_versions
|
||||
union all select min(maintenance_at) from company_skill_repository_snapshots
|
||||
union all select min(delete_after) from skill_package_deletions where provider = ${backend().provider}
|
||||
) deadlines`);
|
||||
return row?.at ? new Date(row.at).getTime() : null;
|
||||
}
|
||||
function start(coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">) {
|
||||
let requested = 0, completed = -1, next: number | null = null;
|
||||
let recovered = false;
|
||||
return coordinator.register(DELIVERY_QUEUES.skillPackages, {
|
||||
retryMs: 5_000, pausePassiveMaintenance: true,
|
||||
onSettled: () => { requested++; },
|
||||
async run(signal) {
|
||||
const generation = requested;
|
||||
if (!recovered && skillPackageStorageEnabled()) {
|
||||
// A package previously protected by a run/pin may now be unused.
|
||||
// Reconsider it once on restart, not on an idle polling interval.
|
||||
await db.update(snapshots).set({ maintenanceAt: new Date() }).where(and(isNull(snapshots.maintenanceAt),
|
||||
sql`${snapshots.storage} is not null and ${snapshots.restore} is not null`,
|
||||
lte(snapshots.createdAt, new Date(Date.now() - SKILL_PACKAGE_RETENTION_MS))));
|
||||
recovered = true;
|
||||
}
|
||||
await tick(signal);
|
||||
next = await deadline();
|
||||
if (next !== null) next = Math.max(Date.now() + 1_000, next);
|
||||
completed = generation;
|
||||
},
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => requested !== completed ? Date.now() + 5_000 : next,
|
||||
});
|
||||
}
|
||||
return { tick, deadline, start };
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function setupActor(setup: Setup): AuthorizationActor {
|
||||
onBehalfOfUserId: setup.refreshUserId === 'local-board' ? null : setup.refreshUserId, source: 'agent_key' }
|
||||
: { type: 'board', userId: setup.refreshUserId, source: setup.refreshLocalTrusted ? 'local_implicit' : 'session', companyIds: [setup.companyId] };
|
||||
}
|
||||
async function setupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
export async function skillSourceSetupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
if (!setup.refreshUserId && !setup.refreshLocalTrusted) throw forbidden('Choose a GitHub connection to enable automatic updates.');
|
||||
if (!setup.refreshLocalTrusted) {
|
||||
const [member] = await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
@@ -152,7 +152,7 @@ async function setupContext(db: Db, setup: Setup): Promise<SkillSourceContext> {
|
||||
}
|
||||
|
||||
export function skillSourceSyncService(db: Db, options: { context?: (setup: Setup) => Promise<SkillSourceContext> } = {}) {
|
||||
const contextFor = options.context ?? (setup => setupContext(db, setup));
|
||||
const contextFor = options.context ?? (setup => skillSourceSetupContext(db, setup));
|
||||
const sources = skillSourceService(db);
|
||||
let running: Promise<{ imported: number; checked: number }> | null = null;
|
||||
const shutdown = new AbortController();
|
||||
@@ -211,7 +211,7 @@ export function skillSourceSyncService(db: Db, options: { context?: (setup: Setu
|
||||
try {
|
||||
const result = await sources.create(binding.companyId, { repositoryUrl: scan.repositoryUrl, trackingRef: binding.trackingRef,
|
||||
commitSha: scan.commitSha, packageMode: scan.packageMode, selectedPaths: scan.candidates.map(candidate => candidate.path),
|
||||
connectionId: scan.connectionId ?? binding.connectionId }, { ...context, projectBindingId: binding.id, projectBindingLeaseToken: token }, scan);
|
||||
connectionId: scan.connectionId ?? binding.connectionId }, { ...context, signal: shutdown.signal, projectBindingId: binding.id, projectBindingLeaseToken: token }, scan);
|
||||
sourceId = result.source.id;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'status' in error && error.status === 409)) throw error;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { skillPackageManifest } from "./skill-package-files.js";
|
||||
import { skillPackageStorageEnabled, skillPackageStore } from "./skill-package-store.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from './delivery-work-notifications.js';
|
||||
import { reserveAutomaticSkillWork, settleAutomaticSkillWork } from './skill-source-refresh-budget.js';
|
||||
import { skillSourceRepairAction } from './skill-source-problems.js';
|
||||
@@ -20,6 +22,7 @@ import { skillSnapshotHash } from './skill-snapshot.js';
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
type SourceRow = typeof sources.$inferSelect;
|
||||
export type SkillSourceContext = {
|
||||
signal?: AbortSignal;
|
||||
actor: { type: 'user' | 'agent' | 'system'; userId?: string | null; agentId?: string | null };
|
||||
setup?: { userId: string | null; agentId?: string | null; localTrusted: boolean; grantId?: string | null };
|
||||
projectBindingId?: string;
|
||||
@@ -33,6 +36,27 @@ const scope = (companyId: string, id: string) => and(eq(sources.companyId, compa
|
||||
|
||||
export function skillSourceService(db: Db) {
|
||||
const skills = companySkillService(db);
|
||||
const packages = skillPackageStore(db);
|
||||
async function preparePackages(companyId: string, scan: ScannedSkillSource, selected: string[], sourceId: string, signal?: AbortSignal) {
|
||||
if (!skillPackageStorageEnabled()) return new Map<string, string>();
|
||||
const candidates = scan.skills.filter(candidate => selected.includes(candidate.path) && !candidate.error);
|
||||
const prepared = new Map<string, string>();
|
||||
if (!candidates.length) return prepared;
|
||||
if (scan.packageMode === 'repository') {
|
||||
if (scan.repositoryPackage?.error || !scan.repositoryFiles?.length) throw unprocessable(scan.repositoryPackage?.error ?? 'Repository package has no files.');
|
||||
const id = await packages.prepare(companyId, scan.repositoryFiles, true, signal, {
|
||||
version: 1, sourceId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl,
|
||||
commitSha: scan.commitSha, skillPath: null, manifest: skillPackageManifest(scan.repositoryFiles),
|
||||
});
|
||||
for (const candidate of candidates) prepared.set(candidate.path, id);
|
||||
} else {
|
||||
for (const candidate of candidates) prepared.set(candidate.path, await packages.prepare(companyId, candidate.files, true, signal, {
|
||||
version: 1, sourceId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl,
|
||||
commitSha: scan.commitSha, skillPath: candidate.path, manifest: skillPackageManifest(candidate.files),
|
||||
}));
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
async function row(companyId: string, id: string, database: Db | Tx = db) {
|
||||
const [source] = await database.select().from(sources).where(scope(companyId, id));
|
||||
if (!source || source.removedAt) throw notFound('Skill source not found.');
|
||||
@@ -98,20 +122,23 @@ export function skillSourceService(db: Db) {
|
||||
const identity = createHash('sha256').update(`${scan.repositoryId}:${sourceId}${revision ? `:${revision}` : ''}:${skillPath}`).digest('hex').slice(0, 16);
|
||||
return `github/${scan.repositoryId}/${identity}/${normalizeAgentUrlKey(name) || 'skill'}`;
|
||||
}
|
||||
async function publish(tx: Tx, source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], excludedFolders: string[], context: SkillSourceContext, selectionReviewed: boolean): Promise<Omit<SkillSourceRefreshResult, 'source'>> {
|
||||
async function publish(tx: Tx, source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], excludedFolders: string[], context: SkillSourceContext, selectionReviewed: boolean, prepared: Map<string, string>): Promise<Omit<SkillSourceRefreshResult, 'source'>> {
|
||||
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
|
||||
const mode = scan.packageMode ?? 'skills';
|
||||
if (mode === 'repository' && scan.skills.length > 0 && (scan.repositoryPackage?.error || !scan.repositoryFiles?.length)) {
|
||||
throw unprocessable(scan.repositoryPackage?.error ?? 'Repository package has no files. Scan it again before importing.');
|
||||
}
|
||||
let repositorySnapshotId: string | undefined;
|
||||
const repositoryHash = mode === 'repository' && scan.repositoryFiles?.length && !scan.repositoryPackage?.error ? skillSnapshotHash(scan.repositoryFiles) : null;
|
||||
if (repositoryHash) {
|
||||
await tx.insert(companySkillRepositorySnapshots).values({ companyId: source.companyId, contentHash: repositoryHash, fileInventory: scan.repositoryFiles! })
|
||||
.onConflictDoNothing();
|
||||
const [snapshot] = await tx.select({ id: companySkillRepositorySnapshots.id }).from(companySkillRepositorySnapshots).where(and(
|
||||
eq(companySkillRepositorySnapshots.companyId, source.companyId), eq(companySkillRepositorySnapshots.contentHash, repositoryHash)));
|
||||
repositorySnapshotId = snapshot!.id;
|
||||
let repositorySnapshotId = mode === 'repository' ? prepared.values().next().value : undefined;
|
||||
const hasSelected = scan.skills.some(candidate => selectedPaths.includes(candidate.path) && !candidate.error);
|
||||
const repositoryHash = mode === 'repository' && hasSelected ? skillSnapshotHash(scan.repositoryFiles!) : null;
|
||||
if (repositoryHash && !repositorySnapshotId) {
|
||||
// Compatibility writer for a rolling deployment. Never save an otherwise
|
||||
// unused repository when no valid skill is selected.
|
||||
const [saved] = await tx.insert(companySkillRepositorySnapshots).values({ companyId: source.companyId,
|
||||
contentHash: repositoryHash, fileInventory: scan.repositoryFiles! }).onConflictDoNothing().returning({ id: companySkillRepositorySnapshots.id });
|
||||
repositorySnapshotId = saved?.id ?? (await tx.select({ id: companySkillRepositorySnapshots.id })
|
||||
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, source.companyId),
|
||||
eq(companySkillRepositorySnapshots.contentHash, repositoryHash))))[0]!.id;
|
||||
}
|
||||
const previous = await tx.select().from(entries).where(and(eq(entries.companyId, source.companyId), eq(entries.sourceId, source.id)));
|
||||
const imported: CompanySkill[] = [], updated: CompanySkill[] = [];
|
||||
@@ -157,6 +184,9 @@ export function skillSourceService(db: Db) {
|
||||
if (changed) {
|
||||
const version = await skills.createVersion(source.companyId, skillId!, { label: `GitHub ${scan.commitSha.slice(0, 8)}` }, context.actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill: installed, fileInventory: candidate.files,
|
||||
packageSnapshotId: prepared.get(candidate.path),
|
||||
packageFileMap: repositorySnapshotId && prepared.has(candidate.path) ? candidate.files.map(file => ({ path: file.path, kind: file.kind,
|
||||
sourcePath: file.path === 'SKILL.md' ? candidate.path : path.posix.join(path.posix.dirname(candidate.path), file.path) })) : null,
|
||||
...(repositorySnapshotId ? { repositorySnapshotId, repositorySkillPath: candidate.path } : {}),
|
||||
});
|
||||
installed.currentVersionId = version.id;
|
||||
@@ -203,8 +233,11 @@ export function skillSourceService(db: Db) {
|
||||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: scan.repositoryUrl,
|
||||
skillKey: newSkillKey(scan, candidate.path, candidate.name, id) });
|
||||
}
|
||||
const prepared = await preparePackages(companyId, scan, input.selectedPaths, id, context.signal);
|
||||
context.signal?.throwIfAborted();
|
||||
const published = await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
const [source] = await tx.insert(sources).values({ id, companyId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl, fullName: scan.fullName,
|
||||
packageMode: scan.packageMode ?? 'skills', trackingRef: context.projectBindingId && input.trackingRef === 'HEAD' ? 'HEAD' : scan.trackingRef, connectionId: scan.connectionId ?? input.connectionId ?? null, lastAttemptAt: new Date(),
|
||||
accessScope: context.projectBindingId ? 'projects' : 'company', importNewSkills: Boolean(context.projectBindingId),
|
||||
@@ -215,7 +248,7 @@ export function skillSourceService(db: Db) {
|
||||
if (!binding?.active || (context.projectBindingLeaseToken && binding.leaseToken !== context.projectBindingLeaseToken)) throw conflict('Project repository changed during import.');
|
||||
await tx.update(projectSkillSources).set({ sourceId: source.id, status: 'ready', lastError: null }).where(eq(projectSkillSources.id, binding.id));
|
||||
}
|
||||
const result = await publish(tx, source, scan, input.selectedPaths, input.excludedFolders ?? [], context, true);
|
||||
const result = await publish(tx, source, scan, input.selectedPaths, input.excludedFolders ?? [], context, true, prepared);
|
||||
return { ...result, source: await detail(companyId, id, tx) };
|
||||
});
|
||||
context.publishCommittedActivity?.();
|
||||
@@ -289,11 +322,14 @@ export function skillSourceService(db: Db) {
|
||||
}
|
||||
}
|
||||
await authorizeScan(source, scan, selectedPaths, context);
|
||||
const prepared = await preparePackages(companyId, scan, selectedPaths, id, options.signal ?? context.signal);
|
||||
(options.signal ?? context.signal)?.throwIfAborted();
|
||||
const published = await db.transaction(async tx => {
|
||||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update');
|
||||
if (!locked || locked.leaseToken !== token || locked.revision !== source.revision || locked.leaseExpiresAt! < new Date()) throw conflict('Source refresh expired or changed. Try again.');
|
||||
const result = await publish(tx, locked, scan, selectedPaths, selection?.excludedFolders ?? source.excludedFolders, context, Boolean(selection));
|
||||
const result = await publish(tx, locked, scan, selectedPaths, selection?.excludedFolders ?? source.excludedFolders, context, Boolean(selection), prepared);
|
||||
if (selection || scan.connectionId !== undefined) await tx.update(sources).set({ ...(selection ? { enabled: true } : {}), connectionId: scan.connectionId ?? connectionId,
|
||||
...(source.refreshGrantId === null && reader.grantId ? { refreshGrantId: reader.grantId } : {}),
|
||||
...(selection?.connectionId !== undefined || (!source.refreshUserId && !source.refreshLocalTrusted && context.setup) ? { refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId ?? null, refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader.grantId ?? null } : {}) }).where(scope(companyId, id));
|
||||
@@ -383,6 +419,7 @@ export function skillSourceService(db: Db) {
|
||||
}
|
||||
async function remove(tx: Tx, copiedCount: number, deletedCount: number) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
|
||||
// Stop both pending attachments and an in-flight refresh before removing tracking.
|
||||
await tx.update(projectSkillSources).set({ active: false, leaseToken: null, leaseExpiresAt: null })
|
||||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id)));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { companySkillVersions, companySkillRepositorySnapshots, heartbeatRuns,
|
||||
import { companySkillVersions, heartbeatRuns,
|
||||
nativeRunFinalizations, environmentLeases, withDedicatedDbConnection, type Db } from "@paperclipai/db";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { assertSkillSnapshotPath } from "./skill-snapshot.js";
|
||||
@@ -16,11 +17,6 @@ export const SKILL_VERSION_CACHE_IDLE_MS = 60 * 60 * 1000;
|
||||
const locks = new AsyncLocalStorage<Array<{ db: Db; companyId: string }>>();
|
||||
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
export class SkillCacheBusy extends Error {}
|
||||
// Repository snapshots are immutable and referenced with restrictive foreign
|
||||
// keys. Cache only validated entrypoint names, never large file contents, so an
|
||||
// hourly sweep does not transfer the same repository for each skill/version.
|
||||
const verifiedSnapshots = new WeakMap<Db, Map<string, ReadonlySet<string>>>();
|
||||
|
||||
/** Hold through publication AND durable run pinning, not just file creation. */
|
||||
export async function withSkillVersionCacheLock<T>(db: Db, companyId: string, action: () => Promise<T>, tryOnly = false): Promise<T> {
|
||||
const held = locks.getStore() ?? [];
|
||||
@@ -95,18 +91,8 @@ export async function lastUsedAt(filename: string): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal run can still own provider execution or recovery. Only fully settled
|
||||
// runs release their pins; resume preparation reacquires the same cache lock.
|
||||
export async function protectedVersions(db: Db, companyId: string): Promise<Set<string> | null> {
|
||||
const rows = await db.select({
|
||||
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
pins: sql<unknown>`${heartbeatRuns.contextSnapshot}->'skillVersions'`,
|
||||
// Avoid loading large execution inputs for runs with the modern pin map.
|
||||
legacyInput: sql<unknown>`case when ${heartbeatRuns.runtimeMode} = 'native'
|
||||
and not coalesce(${heartbeatRuns.contextSnapshot} ? 'skillVersions', false)
|
||||
then ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput' end`,
|
||||
})
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), sql`(
|
||||
export function skillRunNeedsFilesSql() {
|
||||
return sql`(
|
||||
${heartbeatRuns.status} not in ('succeeded', 'failed', 'timed_out', 'cancelled', 'interrupted')
|
||||
or ${heartbeatRuns.finishedAt} is null
|
||||
or ${heartbeatRuns.controllerLeaseExpiresAt} > now()
|
||||
@@ -119,7 +105,21 @@ export async function protectedVersions(db: Db, companyId: string): Promise<Set<
|
||||
or l.metadata ? 'nativeWorkspaceExportResume'
|
||||
or (l.released_at is null and not coalesce(l.status = 'retained'
|
||||
and l.lease_policy = 'reuse_by_environment' and l.cleanup_status = 'success', false))))
|
||||
)`));
|
||||
)`;
|
||||
}
|
||||
|
||||
// A terminal run can still own provider execution or recovery. Only fully settled
|
||||
// runs release their pins; resume preparation reacquires the same cache lock.
|
||||
export async function protectedVersions(db: Db, companyId: string): Promise<Set<string> | null> {
|
||||
const rows = await db.select({
|
||||
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
pins: sql<unknown>`${heartbeatRuns.contextSnapshot}->'skillVersions'`,
|
||||
// Avoid loading large execution inputs for runs with the modern pin map.
|
||||
legacyInput: sql<unknown>`case when ${heartbeatRuns.runtimeMode} = 'native'
|
||||
and not coalesce(${heartbeatRuns.contextSnapshot} ? 'skillVersions', false)
|
||||
then ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput' end`,
|
||||
})
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), skillRunNeedsFilesSql()));
|
||||
const versions = new Set<string>();
|
||||
for (const { pins, legacyInput, id, agentId, nativeIssueId } of rows) {
|
||||
if (legacyInput != null) {
|
||||
@@ -163,27 +163,27 @@ function validateInventory(inventory: CompanySkillVersionFileInventoryEntry[]) {
|
||||
}
|
||||
|
||||
export async function hasRestorableSnapshot(db: Db, companyId: string, versionId: string) {
|
||||
const [version] = await db.select().from(companySkillVersions).where(and(
|
||||
const [version] = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
|
||||
eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.id, versionId)));
|
||||
if (!version) return false;
|
||||
if (version.repositorySnapshotId) {
|
||||
if (!version.repositorySkillPath) return false;
|
||||
let cache = verifiedSnapshots.get(db);
|
||||
if (!cache) { cache = new Map(); verifiedSnapshots.set(db, cache); }
|
||||
const key = `${companyId}:${version.repositorySnapshotId}`;
|
||||
const verified = cache.get(key);
|
||||
if (verified) return verified.has(version.repositorySkillPath);
|
||||
const [snapshot] = await db.select({ inventory: companySkillRepositorySnapshots.fileInventory })
|
||||
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, companyId),
|
||||
eq(companySkillRepositorySnapshots.id, version.repositorySnapshotId)));
|
||||
if (!snapshot || !validateInventory(snapshot.inventory)) return false;
|
||||
const entrypoints = new Set(snapshot.inventory.filter(file => file.encoding !== "base64"
|
||||
&& repositorySkillFrontmatter(file.content)).map(file => file.path));
|
||||
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, entrypoints);
|
||||
return entrypoints.has(version.repositorySkillPath);
|
||||
const packageId = version.repositorySnapshotId ?? version.packageSnapshotId;
|
||||
const store = skillPackageStore(db);
|
||||
if (packageId) {
|
||||
if (version.repositorySnapshotId && !version.repositorySkillPath) return false;
|
||||
const manifest = await store.manifest(companyId, packageId);
|
||||
const entrypoint = version.repositorySkillPath ?? version.packageFileMap?.find(file => file.path === "SKILL.md")?.sourcePath ?? "SKILL.md";
|
||||
const entry = manifest.files.find(file => file.path === entrypoint);
|
||||
if (!entry) return false;
|
||||
// Git packages intentionally permit byte eviction. Their immutable recipe,
|
||||
// not a network probe during cleanup, is the restoration contract.
|
||||
if ((await store.descriptor(companyId, packageId)).restorable) return true;
|
||||
if (version.repositorySnapshotId && (entry.encoding === "base64" || !await store.withPackage(companyId, packageId,
|
||||
async root => repositorySkillFrontmatter(await fs.readFile(path.join(root, entrypoint), "utf8"))))) return false;
|
||||
// Never evict the only known-good bytes based on a permanent process cache.
|
||||
return store.verifyDurable(companyId, packageId);
|
||||
}
|
||||
return validateInventory(version.fileInventory) && version.fileInventory.some(file => file.path === "SKILL.md");
|
||||
const files = await store.versionFiles(version);
|
||||
return validateInventory(files) && files.some(file => file.path === "SKILL.md");
|
||||
}
|
||||
|
||||
// Counts allocated bytes without following links, matching what df can reclaim.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withSkillPackageReadScope } from "./skill-package-store.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -417,7 +418,7 @@ export function skillVersionRetentionService(db: Db, defaults: SkillVersionPrune
|
||||
async function pruneCompany(companyId: string, options: SkillVersionPruneOptions & { cursor?: CompanyCursor | null } = {}) {
|
||||
if (scanning.has(companyId)) return { ...emptyResult(), checked: 1, skipped: { scan_busy: 1 } };
|
||||
scanning.add(companyId);
|
||||
try { return await pruneCompanyPage(companyId, options); }
|
||||
try { return await withSkillPackageReadScope(db, () => pruneCompanyPage(companyId, options)); }
|
||||
finally { scanning.delete(companyId); }
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider
|
||||
|
||||
const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, input.body);
|
||||
await fs.writeFile(tempPath, input.body, { signal: input.signal });
|
||||
await fs.rename(tempPath, targetPath);
|
||||
} finally { await fs.rm(tempPath, { force: true }); }
|
||||
},
|
||||
@@ -66,7 +66,7 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider
|
||||
? input.range.end - input.range.start + 1
|
||||
: stat.size;
|
||||
return {
|
||||
stream: createReadStream(filePath, streamOptions),
|
||||
stream: createReadStream(filePath, { ...streamOptions, signal: input.signal }),
|
||||
contentLength,
|
||||
lastModified: stat.mtime,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function putS3Multipart(client: S3Client, bucket: string, key: stri
|
||||
// Scale part sizes to the S3 part-count limit while retaining stream backpressure.
|
||||
const partSize = Math.max(8 * 1024 * 1024, Math.ceil(input.contentLength / 10_000));
|
||||
try {
|
||||
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType })))).UploadId;
|
||||
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }), { abortSignal: input.signal }))).UploadId;
|
||||
if (!uploadId) throw new Error("S3 did not return a multipart upload id");
|
||||
const parts: { ETag: string; PartNumber: number }[] = [];
|
||||
for (let offset = 0; offset < input.contentLength;) {
|
||||
@@ -35,14 +35,14 @@ export async function putS3Multipart(client: S3Client, bucket: string, key: stri
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: parts.length + 1, Body: body, ContentLength: length })));
|
||||
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: parts.length + 1, Body: body, ContentLength: length }), { abortSignal: input.signal }));
|
||||
if (remaining || !result.ETag) throw new Error("S3 upload part is incomplete");
|
||||
parts.push({ ETag: result.ETag, PartNumber: parts.length + 1 });
|
||||
} finally { body.destroy(); }
|
||||
offset += length;
|
||||
}
|
||||
if (pending.length || !(await iterator.next()).done) throw new Error("S3 upload source exceeds its declared size");
|
||||
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } })));
|
||||
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } }), { abortSignal: input.signal }));
|
||||
} catch (error) {
|
||||
if (uploadId) await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId })).catch(() => {});
|
||||
throw error;
|
||||
|
||||
@@ -95,6 +95,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
|
||||
ContentType: input.contentType,
|
||||
ContentLength: input.contentLength,
|
||||
}),
|
||||
{ abortSignal: input.signal },
|
||||
));
|
||||
},
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createStorageService(provider: StorageProvider): StorageService
|
||||
body: input.body,
|
||||
contentType,
|
||||
contentLength: byteSize,
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
await provider.deleteObject({ objectKey }).catch(() => {});
|
||||
@@ -127,7 +128,7 @@ export function createStorageService(provider: StorageProvider): StorageService
|
||||
|
||||
async getObject(companyId: string, objectKey: string, options) {
|
||||
ensureCompanyPrefix(companyId, objectKey);
|
||||
return provider.getObject({ objectKey, range: options?.range });
|
||||
return provider.getObject({ objectKey, range: options?.range, signal: options?.signal });
|
||||
},
|
||||
|
||||
async headObject(companyId: string, objectKey: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Readable } from "node:stream";
|
||||
|
||||
export interface PutObjectInput {
|
||||
objectKey: string;
|
||||
signal?: AbortSignal;
|
||||
// Readable bodies stream straight to the backend (contentLength must be the
|
||||
// exact byte size); Buffer stays supported for small payloads.
|
||||
body: Buffer | Readable;
|
||||
@@ -45,6 +46,7 @@ export interface StorageProvider {
|
||||
}
|
||||
|
||||
export type PutFileInput = {
|
||||
signal?: AbortSignal;
|
||||
/** Server-allocated, company-prefixed key for durable idempotent uploads. Never accept from client input. */
|
||||
objectKey?: string;
|
||||
companyId: string;
|
||||
@@ -65,7 +67,7 @@ export interface PutFileResult {
|
||||
export interface StorageService {
|
||||
provider: StorageProviderId;
|
||||
putFile(input: PutFileInput): Promise<PutFileResult>;
|
||||
getObject(companyId: string, objectKey: string, options?: Pick<GetObjectInput, "range">): Promise<GetObjectResult>;
|
||||
getObject(companyId: string, objectKey: string, options?: Pick<GetObjectInput, "range" | "signal">): Promise<GetObjectResult>;
|
||||
headObject(companyId: string, objectKey: string): Promise<HeadObjectResult>;
|
||||
deleteObject(companyId: string, objectKey: string): Promise<void>;
|
||||
}
|
||||
Reference in new issue
Block a user