@wenathlan/saddle 1.8.1 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -198,6 +198,6 @@
198
198
 
199
199
  You should have received a copy of the Proprietary Source-Available
200
200
  License along with this program. If not, see
201
- <https://github.com/iakadion/saddle/LICENSE>
201
+ <https://github.com/wenathlan/saddle/LICENSE>
202
202
 
203
203
  Also add information on how to contact you by electronic and paper mail.
package/README.md CHANGED
@@ -7,16 +7,16 @@
7
7
  <p align="center">
8
8
  <strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
9
9
  <strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
10
- <a href="https://github.com/iakadion/saddle/actions/workflows/ci.yml"><img src="https://github.com/iakadion/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
11
- <a href="https://github.com/iakadion/saddle/releases/tag/v1.8.0"><img src="https://img.shields.io/badge/release-v1.8.0-d35d3d" alt="Release 1.8.0" /></a>
12
- <a href="https://github.com/iakadion/saddle/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-202a2f" alt="GPL 3.0 license" /></a>
10
+ <a href="https://github.com/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
11
+ <a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.4"><img src="https://img.shields.io/badge/release-v1.8.4-d35d3d" alt="Release 1.8.4" /></a>
12
+ <a href="https://github.com/wenathlan/saddle/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-202a2f" alt="GPL 3.0 license" /></a>
13
13
  </p>
14
14
 
15
15
  > **Core idea:** storage is the durable side of the working set; the runner is replaceable; the artifact is the boundary. **Storage == Compute** — RAM and disk are the same construct, differing only by usage flag.
16
16
 
17
17
  Saddle is a **JavaScript ESM engine** for jobs that move data between storage, a working set, an injected runner and durable artifacts. It is also a **virtual machine you publish as a package** that runs on other people's computers (GitHub Actions, Forgejo, Gitea, GitLab, Codeberg, free Docker containers) and turns unlimited third-party storage buckets into virtual RAM/GPU/CPU. Nothing runs on the operator's local machine.
18
18
 
19
- Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. Package `@wenathlan/saddle` published to npm, GitHub Packages, Maven, NuGet, RubyGems and GHCR (auto-mirrored to jsDelivr).
19
+ Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. The canonical JavaScript package is `@wenathlan/saddle`; GitHub Packages npm, Maven and GHCR use the transferred `wenathlan` owner namespace, while NuGet and RubyGems retain their unscoped ecosystem package names.
20
20
 
21
21
  ## Start here
22
22
 
@@ -109,14 +109,17 @@ The caller still chooses how to provide `fetcher`, browser transport, persistenc
109
109
 
110
110
  ## Browser extension
111
111
 
112
- Version 1.1 includes a pure JavaScript Manifest V3 reference surface in [`extension/`](extension/). It is deliberately narrow: the user invokes the action, the popup sends a versioned command, the service worker routes it, and an isolated content bridge returns bounded page metadata, visible text or a user initiated action result.
112
+ Version 1.8.4 includes a pure JavaScript Manifest V3 reference surface in [`extension/`](extension/). It is deliberately narrow: the user invokes the action, the popup sends a versioned command, the service worker routes it, and an isolated content bridge returns bounded page metadata, visible text or a user initiated action result. The exported `permissionpolicy` keeps base permissions minimal and makes optional escalation caller-owned.
113
113
 
114
114
  ```bash
115
115
  # load the unpacked extension from chrome://extensions
116
116
  ls extension/manifest.json extension/worker.js extension/content.js extension/popup.html
117
+
118
+ # build an isolated unpacked artifact using the package version
119
+ npm run extension:build -- --output build/extension
117
120
  ```
118
121
 
119
- The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. See [`extension/README.md`](extension/README.md) for the unpacked development flow.
122
+ The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. Published releases attach `saddle-extension-<version>.zip`; the source manifest remains a stable unpacked reference. See [`extension/README.md`](extension/README.md) for the unpacked and release artifact flows.
120
123
 
121
124
  ## CLI
122
125
 
@@ -142,12 +145,12 @@ saddle mcp
142
145
 
143
146
  | Registry | Artifact | Workflow | Status |
144
147
  | --- | --- | --- | --- |
145
- | GitHub npm | `@iakadion/saddle@1.8.0` | publishgithubnpm.yml | published |
146
- | GHCR | `ghcr.io/iakadion/saddle:1.8.0` and `latest` | publishghcr.yml | published |
147
- | Maven | `io.devthink:saddle:1.8.0` | publishmaven.yml | published after JDK 26 retry |
148
- | NuGet | `Saddle.1.8.0.nupkg` | publishnuget.yml | published |
149
- | RubyGems | `saddle 1.8.0` | publishrubygems.yml | published |
150
- | npmjs | `@wenathlan/saddle@1.8.1` | publishnpmjs.yml | pending follow-up release workflow |
148
+ | GitHub npm | `@wenathlan/saddle@1.8.4` | publishgithubnpm.yml | pending release |
149
+ | GHCR | `ghcr.io/wenathlan/saddle:1.8.4` and `latest` | publishghcr.yml | pending release |
150
+ | Maven | `io.wenathlan:saddle:1.8.4` | publishmaven.yml | pending release |
151
+ | NuGet | `Saddle.1.8.4.nupkg` | publishnuget.yml | pending release |
152
+ | RubyGems | `saddle 1.8.4` | publishrubygems.yml | pending release |
153
+ | npmjs | `@wenathlan/saddle@1.8.4` | publishnpmjs.yml | pending release |
151
154
 
152
155
  ## Development
153
156
 
@@ -185,7 +188,7 @@ Root-based JavaScript ESM layout, no src/ directory, no TypeScript build require
185
188
 
186
189
  ## Current scope
187
190
 
188
- Version 1.8 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance, API/MCP security contracts, bot integration lifecycle, the first tested extension bridge, desktop/mobile/n8n surface contracts, a framework-neutral operator control boundary and the first cross-runtime import boundary. Native runtimes, n8n host registration, provider credentials, persistent databases and production deployment remain caller-selected adapters. The next improvements should extend these contracts without coupling the core to one forge, registry, browser or storage vendor.
191
+ Version 1.8.4 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance and normalization, API/MCP security contracts, bot integration lifecycle, the tested extension bridge and permission policy, deterministic extension packaging, context-aware replay, Node.js 26.7.0 cross-forge gates, desktop/mobile/n8n surface contracts, a framework-neutral operator control boundary and the first cross-runtime import boundary. Native runtimes, n8n host registration, provider credentials, persistent databases and production deployment remain caller-selected adapters. The next improvements should extend these contracts without coupling the core to one forge, registry, browser or storage vendor.
189
192
 
190
193
  ## License
191
194
 
@@ -7,9 +7,15 @@ export function actionrecorder(options = {}) {
7
7
  const startedat = Number(options.startedat ?? Date.now());
8
8
  const events = [];
9
9
  let lastsnapshotid;
10
- function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid }); return snapshot; }
11
- function action(input = {}) { const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
10
+ function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; const context = recordercontext(snapshot); events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid, context }); return snapshot; }
11
+ function action(input = {}) { const context = recordercontext(input.context ?? input); const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, windowid: input.windowid, context, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
12
12
  function list() { return events.map((event) => ({ ...event, payload: { ...event.payload } })); }
13
13
  function manifest() { return { version: 1, startedat, eventcount: events.length, lastsnapshotid, events: list() }; }
14
14
  return { snapshot, action, list, manifest };
15
15
  }
16
+
17
+ function recordercontext(value = {}) {
18
+ const context = {};
19
+ for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) context[name] = String(value[name]);
20
+ return Object.keys(context).length ? context : undefined;
21
+ }
@@ -25,16 +25,16 @@ The engine owns **contracts, validation, orchestration, recovery and auditabilit
25
25
  | Block | Scope | Current state | Exit evidence |
26
26
  | --- | --- | --- | --- |
27
27
  | 1 | audit and governance | active | gap matrix, sources, claims reconciled |
28
- | 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and replay provenance tests |
28
+ | 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and context-aware replay provenance tests |
29
29
  | 3 | extension runtime | first slice complete | MV3 unpacked surface, protocol, worker, content bridge and tests |
30
30
  | 4 | working set and storage | complete | range chunks, content dedupe, tiered cache, conflict sync and memory capabilities |
31
31
  | 5 | runners and execution | complete | provider health, triggers, cancellation, heartbeat and resumable runs |
32
- | 6 | scraping and context | complete | semantic extraction, crawl budgets, RAG lineage and low-cardinality metrics |
32
+ | 6 | scraping and context | complete | semantic extraction, content-type normalization, crawl budgets, RAG lineage and low-cardinality metrics |
33
33
  | 7 | API, MCP and security | complete | request identity, optional auth, secure headers, browser MCP tools and redirect/DNS checks |
34
34
  | 8 | bots and integrations | complete | app lifecycle, command scopes, idempotency, delivery retries and dead letters |
35
- | 9 | packaging and distribution | partial; registry slice verified for 1.7.0 | reproducible extension, desktop, mobile, n8n and binary artifacts |
35
+ | 9 | packaging and distribution | extension zip and registry slice prepared for 1.8.2 | desktop, mobile, n8n and binary artifacts remain caller-owned |
36
36
  | 10 | product surfaces and operations | first slice complete | desktop, mobile, n8n and operator control contracts; observability, retention and threat model remain |
37
- | 11 | cross-runtime compatibility | first slice complete | Node, Bun and Deno root probe; browser worker, extension and package tree-shaking checks remain |
37
+ | 11 | cross-runtime compatibility | transport-neutral graph audit complete | Node, Bun and Deno root probe, browser worker bridge, extension permission/build checks and package graph audit |
38
38
  | 12 | release gates | active | deterministic checks, docs, clean diffs and claim/code parity |
39
39
 
40
40
  ## execution method
@@ -52,7 +52,7 @@ Each block follows the same loop:
52
52
 
53
53
  ## current implementation
54
54
 
55
- Version 1.7 contains the first bot and integration lifecycle slice after browser, storage, runners, scraping and API hardening. It adds app installation and scope state, command authorization, idempotent bot execution, retryable webhook delivery and dead-letter records. The registry publication slice for 1.7.0 is verified for GitHub npm, GHCR, Maven, NuGet and RubyGems; public npmjs remains blocked because the owner account or scope still rejects the configured publication with HTTP 404. The first product surface slice adds desktop, mobile, n8n, operator control, operational policy and framework-neutral HTTP contracts. The first cross-runtime slice now validates the root on Node, Bun and Deno, while browser worker and extension import checks remain.
55
+ Version 1.8.2 carries the public npm identity migration through the transferred `wenathlan` repository owner, aligns GitHub Packages npm, Maven and GHCR owner metadata, and includes the minimal extension permission policy, deterministic extension zip workflow, context-aware replay, transport-neutral graph audit and bounded content normalization. Registry publication is triggered only after the release tag and independent target checks. The first product surface slice adds desktop, mobile, n8n, operator control, operational policy and framework-neutral HTTP contracts. The first cross-runtime slice validates the root on Node, Bun and Deno, while extension-context and browser bundler checks remain caller-owned.
56
56
 
57
57
  ## evidence sources
58
58
 
package/docs/gapmatrix.md CHANGED
@@ -18,20 +18,20 @@ This matrix turns the supplied README and conclusions into implementation decisi
18
18
  | Root library | Implemented ESM entry point with broad exports | No extension subpath or extension files are shipped | P0 | Add a small `extension/` package surface and export only serializable contracts |
19
19
  | Browser agent | Implemented injected action adapter for navigate, click, type, screenshot, DOM, title, scroll and command batches | Vendor-neutral action results and bounded action batches are now public | P1 | Keep the adapter boundary; add vendor adapters without moving browser ownership into the core |
20
20
  | Browser snapshots | Implemented public contract | Snapshot ids, bounded elements, stable refs, stale checks and diffs are covered by deterministic tests | P0 | Reuse the contract from MCP and extension transport |
21
- | Session replay | Partial | Replay and action recording exist with snapshot provenance, but tab/window restoration and frame-aware replay are still absent | P1 | Add context-aware replay after adapter conformance tests |
21
+ | Session replay | Implemented | Replay restores caller-owned window, tab and frame context before actions; context identifiers are validated and counted | P1 | Keep browser selection and restoration in injected adapters |
22
22
  | Extension runtime | Surface is only declared in `surfaces/manifest.js` and `surfaces/targets.js` | No Manifest V3 manifest, service worker, content bridge, popup or build artifact | P0 | Implement a pure JavaScript MV3 reference surface with minimal permissions |
23
23
  | Extension messaging | Not implemented | No versioned envelope, correlation id, timeout, sender metadata or error response contract | P0 | Add transport-neutral message contracts and Chrome runtime adapter |
24
- | Service worker resilience | Not implemented | No rehydration or durable state strategy for worker termination | P0 | Persist pending command metadata and session summaries through injected storage |
25
- | Permissions | Not implemented | No permission policy or optional escalation path | P0 | Start with `storage` and no broad host permissions; make host access caller-configured |
24
+ | Service worker resilience | Implemented contract slice | Pending command envelopes, attempt metadata and snapshot summaries persist through injected storage; resume remains explicit and user-owned | P0 | Rehydrate metadata on startup and never replay a command without an explicit caller action |
25
+ | Permissions | Contract slice | `permissionpolicy` keeps the base permissions minimal and optional escalation caller-owned | P0 | Start with `storage` and no broad host permissions; make host access caller-configured |
26
26
  | Content isolation | Not implemented | No isolated-world DOM bridge or page-to-extension boundary | P0 | Add a narrow content script that reports page facts through the message contract |
27
27
  | Task agent | Partial | Jobs, workflows and bot commands exist, but no browser task planner or tool registry | P1 | Reuse workflow, trigger and bot contracts; add browser task commands only after snapshots |
28
28
  | MCP | Implemented scrape, crawl, batch, extract and serialize tools with JSON-RPC handling | No browser snapshot or browser action MCP tools | P1 | Add browser tools as an optional adapter over the same snapshot/action contracts |
29
29
  | API security | Implemented URL protocol and private hostname/IP checks | Request envelopes, optional authorization, security headers, redirect bounds and injected DNS resolution checks are now available | P0 | Keep credentials caller-owned and reject private or rebinding targets before transport |
30
30
  | Apps and bots | Implemented platform adapter, commands, bot and webhook signature contracts | App install/suspend/revoke, command scope checks, idempotency and delivery retry/dead-letter records are now available | P1 | Keep platform tokens and OAuth lifecycle caller-owned |
31
31
  | Storage | Implemented local, chunked, S3-compatible, GitHub Contents and file hosting adapters | Range reads, content dedupe, tiered cache, capabilities and conflict-aware sync were missing | P1 | Use the new neutral storage helpers and keep extension storage injected |
32
- | Queue | Implemented in-memory and persistent queue contracts | No worker-aware resume protocol for extension commands | P1 | Add resumable command records and idempotency keys to extension transport |
32
+ | Queue | Implemented in-memory, persistent and extension-resumable command contracts | Browser host still owns long-lived queue workers and cancellation policy | P1 | Keep command records serializable and resume explicit through the extension adapter |
33
33
  | Remote execution | Implemented provider, scheduler, health, triggers, heartbeat and resumable run contracts | No permissioned extension-to-runner bridge or forge-specific status adapters for every provider | P1 | Require explicit caller-provided endpoint and auth; no default remote host |
34
- | Scraping | Implemented robots, cache, HTML extraction, schema extraction and scraper | Semantic headings, landmarks, controls and links are now available; content-type normalization remains partial | P1 | Keep extraction safe and bounded; add adapters for richer document types |
34
+ | Scraping | Implemented robots, cache, HTML extraction, schema extraction, scraper and content normalization | Rich document parsers and binary format adapters remain caller-owned | P1 | Keep extraction safe and bounded; add adapters for richer document types |
35
35
  | Crawl | Implemented normalized BFS and persistent frontier | Priority and per-domain budget frontier now exist; sitemap refresh remains absent | P1 | Keep frontier state serializable and caller-persistable |
36
36
  | RAG context | Implemented chunk hashes and vector record metadata | Retrieval provenance and merge records now exist; embeddings and indexes remain injected | P1 | Preserve source, document, chunk and score lineage |
37
37
  | Observability | Contract slice | Low-cardinality counters and durations are bound to the standard operational metric vocabulary; export and tracing remain caller-owned | P1 | Keep metrics vendor-neutral and bounded |
@@ -40,10 +40,10 @@ This matrix turns the supplied README and conclusions into implementation decisi
40
40
  | Auth profiles | Session file and replay contracts exist | No extension profile or consent model | P1 | Defer cookie/profile export; support explicit user-owned session references only |
41
41
  | CAPTCHA | Contract, guard and evidence exist | No automatic solver integration | deferred | Keep external/manual solver boundary; do not promise bypass in the extension |
42
42
  | Stealth | Fingerprint contract exists | No automatic stealth patching | deferred | Keep opt-in fingerprint metadata; no hidden anti-detection behavior |
43
- | Packaging | npm, GHCR, Maven, NuGet and RubyGems workflows are live | No extension zip build or release artifact | P1 | Add a deterministic zip/check workflow after the reference surface is tested |
43
+ | Packaging | npm, GHCR, Maven, NuGet, RubyGems and extension zip workflows are live | Desktop, mobile, n8n and binary release artifacts remain caller-owned | P1 | Keep non-JavaScript artifacts in explicit adapters and release jobs |
44
44
  | Mobile and desktop apps | Contract slice | Desktop/mobile manifests and caller-owned adapter contracts exist; no native project is bundled | P1 | Keep native projects caller-owned and add runtime conformance tests incrementally |
45
45
  | n8n surface | Contract slice | Node metadata, trigger matching and declared action execution exist; no n8n host package is bundled | P1 | Keep node registration and credentials caller-owned |
46
- | Cross-browser | Target profile declares browser and extension | No Firefox, Edge or Safari manifests/build validation | P2 | Keep WebExtension-compatible contracts and add adapters incrementally |
46
+ | Cross-browser | Target profile declares browser and extension | Firefox, Edge or Safari manifests remain unbundled; transport-neutral export graph is statically audited for Node-only imports | P2 | Keep WebExtension-compatible contracts and add browser adapters incrementally |
47
47
  | Storage equals compute | Memory bridge and engine implement storage-to-working-set-to-artifact; sync and capability negotiation now exist | Remote storage is not physical VRAM and has latency | deferred | Document as a working-set model, never as literal remote VRAM |
48
48
  | Site/database deployment | Persistence schemas and adapters exist | No hosted site or database is part of the package | deferred | Keep deploy targets caller-owned and outside the library core |
49
49
 
@@ -6,6 +6,7 @@ The public API is designed around injected transports. Consumers can use the sam
6
6
  |---|---|
7
7
  | `saddleurl` | choose fetch or an injected browser agent |
8
8
  | `scrapeurl` | fetch a URL and return extracted content |
9
+ | `detectcontenttype` / `normalizeresult` / `normalizeresponse` | classify and bound JSON, XML, Markdown, text, HTML and binary response content |
9
10
  | `scrapehtml` | extract content from an HTML string |
10
11
  | `extractcontent` | expose structured extraction directly |
11
12
  | `serializeresult` | serialize as JSON, Markdown, text, XML, or Redis payload |
@@ -40,7 +41,7 @@ The public API is designed around injected transports. Consumers can use the sam
40
41
  | `nodeserver` | expose a Web Request/Response handler through Node HTTP |
41
42
 
42
43
  ```js
43
- import { scrapeurl, formatforagent } from "@devthink/saddle";
44
+ import { scrapeurl, formatforagent } from "@wenathlan/saddle";
44
45
 
45
46
  const result = await scrapeurl("https://example.com", {
46
47
  format: "markdown",
package/docs/modes.md CHANGED
@@ -13,7 +13,7 @@ The mode resolver keeps execution open. It returns a profile and capability map
13
13
  | pair | without, with |
14
14
 
15
15
  ```js
16
- import { resolvemode } from "@devthink/saddle/modes";
16
+ import { resolvemode } from "@wenathlan/saddle/modes";
17
17
 
18
18
  const profile = resolvemode({
19
19
  execution: "binary",
@@ -0,0 +1,18 @@
1
+ # Platform pipeline audit
2
+
3
+ This document records the platform facts used by the 1.8.4 pipeline work. The workflows remain caller-configured: repository owners provide runner availability, deployment secrets and target repository names.
4
+
5
+ ## verified platform facts
6
+
7
+ | Platform | Verified contract | Source |
8
+ | --- | --- | --- |
9
+ | GitHub Pages | A custom workflow uses `actions/configure-pages@v5`, uploads a static artifact with `actions/upload-pages-artifact@v4`, then deploys with `actions/deploy-pages@v4`. The deployment job needs `pages: write` and `id-token: write`, depends on the build job and uses the `github-pages` environment. | [GitHub Pages custom workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) |
10
+ | Codeberg Pages | Forgejo Actions can deploy with `https://codeberg.org/git-pages/action@v2`, using `site`, `source` and the injected `${{ forge.token }}`. Publishing the repository subdomain should be restricted to the default branch. Codeberg is migrating its legacy Pages v2 flow; custom domains still have separate constraints. | [Codeberg Pages via Forgejo Actions](https://docs.codeberg.org/codeberg-pages/forgejo-actions/), [Codeberg Pages output](https://docs.codeberg.org/codeberg-pages/pushing-output/) |
11
+ | GitLab Pages | A job with `pages: true` publishes the default `public` directory; the job can also use a `pages` hash for a `path_prefix`. Static HTML, CSS and JavaScript are supported through GitLab CI/CD. | [GitLab Pages](https://docs.gitlab.com/user/project/pages/) |
12
+ | Woodpecker CI | A workflow is a serial list of container steps with `image` and `commands`; branch/event filters belong in `when`, and secrets are injected through the pipeline environment rather than committed YAML. | [Woodpecker workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) |
13
+ | Forgejo Actions | Fully qualified actions are recommended; the official checkout reference is `https://data.forgejo.org/actions/checkout@v6`. Short `actions/checkout@v6` resolves through the instance `DEFAULT_ACTIONS_URL`, which can vary. | [Forgejo Actions: Using Actions](https://forgejo.org/docs/v15.0/user/actions/actions/) |
14
+ | Gitea Actions | Gitea Actions is mostly compatible with GitHub Actions and requires a separately installed Gitea Runner. The official documentation lists `actions/checkout@v4` as a supported action reference and warns that runner trust matters for public instances. | [Gitea Actions overview](https://docs.gitea.com/usage/actions/overview) |
15
+
16
+ ## boundary
17
+
18
+ GitHub Pages has a first-party artifact/deploy contract. Codeberg Pages has a git-pages Forgejo Action contract. Forgejo, Gitea and GitLab workflow files can build the same static output, but their hosting destination and token names are instance-specific. Woodpecker can build and publish artifacts, but a Pages deploy step requires the target host's configured service or a caller-owned token. No workflow in this release hardcodes credentials, hostnames or a private deployment target.
@@ -0,0 +1,13 @@
1
+ # Cross-forge pipelines
2
+
3
+ The library keeps GitHub Actions as the release-authoritative workflow because its six registry publishers already share the release-tag action and validation action. Forgejo, Gitea, GitLab and Woodpecker now execute the same deterministic gates with Node.js 26.7.0, but they do not assume a shared package registry, release API or secret name.
4
+
5
+ | Forge | File | Current role | Required caller configuration |
6
+ | --- | --- | --- | --- |
7
+ | GitLab | `.gitlab-ci.yml` | verify and package dry-run | runner, optional package registry credentials and artifact retention |
8
+ | Forgejo | `.forgejo/workflows/saddle.yml` | verify and package dry-run | trusted Forgejo runner and action source policy |
9
+ | Gitea | `.gitea/workflows/saddle.yml` | verify and package dry-run | Gitea Runner and instance action policy |
10
+ | Codeberg | Forgejo workflow files | verify plus optional Codeberg Pages action | Codeberg repository, `forge.token` and default-branch policy |
11
+ | Woodpecker | `.woodpecker/deploy.yml` | verify and package dry-run | trusted agent, image pull policy and optional artifact plugin |
12
+
13
+ These files intentionally do not invent a cross-forge release token or hardcode a host. A caller can add a publish step for the target forge after configuring its own secret and package endpoint. The public site has its own platform workflow set in the `saddle-pages` repository.
@@ -14,7 +14,7 @@ Saddle is the contract layer for a family of caller-owned surfaces. The library
14
14
  | cross runtime | `runtimecontract`, `memorystorage` and root ESM import | runtime-specific APIs and package loader behavior |
15
15
  | browser worker | `workerbridge` and root-safe contracts | worker lifecycle, message transport and extension permissions |
16
16
  | browser | browser agent and snapshot contracts | browser vendor adapter, profile and session ownership |
17
- | extension | Manifest V3 reference files and serializable protocol | browser permission grant, signing and store submission |
17
+ | extension | Manifest V3 reference files, serializable protocol and `permissionpolicy` | browser permission grant, signing and store submission |
18
18
  | web control | API, service, `controlsurface` and `controlservice` contracts | operator UI, authentication, database and hosting |
19
19
 
20
20
  ## operating boundary
@@ -20,13 +20,13 @@ The RubyGems workflow must pass the host without a trailing slash, matching the
20
20
 
21
21
  ## workflow decisions
22
22
 
23
- The repository now uses one release-triggered workflow per destination. `publishgithubnpm.yml` publishes a GitHub Packages npm variant under the repository owner scope, currently `@iakadion/saddle`, with `GITHUB_TOKEN`; the public npm workflow keeps the canonical name `@devthink/saddle`. `publishghcr.yml` publishes `ghcr.io/iakadion/saddle`; `publishmaven.yml`, `publishnuget.yml`, and `publishrubygems.yml` publish minimal ecosystem metadata to the corresponding GitHub Packages registries. Every GitHub Packages job grants only `contents: read` and `packages: write`.
23
+ The repository now uses one release-triggered workflow per destination. After the repository transfer, `publishgithubnpm.yml` derives the GitHub Packages npm scope from `github.repository_owner`, so the v1.8.2 run targets `@wenathlan/saddle` with `GITHUB_TOKEN`; the public npm workflow publishes the same canonical package with `NPM_TOKEN`. `publishghcr.yml` derives `ghcr.io/wenathlan/saddle`; `publishmaven.yml` publishes `io.wenathlan:saddle`; NuGet and RubyGems retain their ecosystem-compatible unscoped package identities. Every GitHub Packages job grants only `contents: read` and `packages: write`.
24
24
 
25
25
  `publishnpmjs.yml` is intentionally separate from GitHub Packages. It uses Node 26.7.0, disables package-manager caching for the release job, and runs `npm publish --access public` against `https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`. The secret name is public by design, but its value must remain owner-managed and absent from logs and source files.
26
26
 
27
27
  The public npm package must be created under the owner account before the first successful publication. The current path uses the owner-managed `NPM_TOKEN` secret for that bootstrap and subsequent releases. The exposed credential from the prior conversation must never be used.
28
28
 
29
- The GitHub Packages npm workflow must use a package scope authorized for the workflow token. `@devthink` is a GitHub organization namespace, while the selected repository currently belongs to `iakadion`; the first run confirmed that publishing `@devthink/saddle` was rejected with HTTP 403. The workflow now changes only the package metadata in the CI workspace to `@iakadion/saddle` before publishing, while the committed `package.json` and public npm package remain `@devthink/saddle`. The two registries therefore have independent package names and access rules.
29
+ The GitHub Packages npm workflow uses the repository owner scope at runtime. The repository is now owned by `wenathlan`, while the authenticated `iakadion` account retains administrator permission, so the committed package metadata and the GitHub Packages workspace both resolve to `@wenathlan/saddle`. Public npm and GitHub Packages therefore share the canonical JavaScript identity for v1.8.2.
30
30
 
31
31
  The GHCR package is linked automatically by publishing from this repository and includes the OCI source label in `dockerfile.saddle`. GitHub creates a first container package as private by default, so the owner must change its package visibility to public if public pulls are required. The same visibility review applies to the Maven, NuGet, RubyGems, and GitHub npm packages after their first publication.
32
32
 
@@ -44,7 +44,11 @@ The cross-runtime workflow `31552266171` and its manual rerun `31552272176` comp
44
44
 
45
45
  Release `v1.8.0` was created from commit `9ddfd6c`. GitHub npm run `31556461901`, GHCR run `31556461887`, NuGet run `31556461883` and RubyGems run `31556461991` completed successfully for `1.8.0`. Maven run `31556461885` initially failed because setup-java rejects `latest`; after changing the workflow to JDK 26, manual run `31556549154` completed successfully. Public npmjs run `31556461909` produced the `@devthink/saddle@1.8.0` tarball but the registry rejected the PUT with HTTP 404 `Scope not found`; no public npmjs publication is claimed.
46
46
 
47
- The active package identity on main is now `@wenathlan/saddle`. The existing `v1.8.0` tag intentionally remains historical and still contains the prior package identity; a follow-up tag is required before publishing the renamed package through the normal release workflow.
47
+ The active package identity on main is now `@wenathlan/saddle`. The repository has since transferred to `wenathlan`; the authenticated `iakadion` account retains administrator permission. Release `v1.8.2` is the first release prepared against the transferred repository owner for GitHub Packages npm, Maven and GHCR.
48
+
49
+ Release `v1.8.1` was created from the identity migration commit `efcbb02`. The npmjs workflow `31557618590` authenticated as `wenathlan`, generated `@wenathlan/saddle@1.8.1`, and ended with npm's `+ @wenathlan/saddle@1.8.1` success line. A later independent `npm view` returned `1.8.1`, and the direct registry metadata endpoint returned HTTP 200 with the same version record, confirming public visibility. The other v1.8.1 registry workflows also completed successfully: GitHub npm `31557618600`, GHCR `31557618571`, Maven `31557618597`, NuGet `31557618573` and RubyGems `31557618575`.
50
+
51
+ Release `v1.8.2` was created from commit `ac7c355` after the repository transfer to `wenathlan`. All release jobs succeeded: GitHub npm `31559770092` logged the owner-derived `@wenathlan/saddle` package, npmjs `31559770096` published the same public package, GHCR `31559770016` pushed `ghcr.io/wenathlan/saddle:1.8.2` and `latest`, Maven `31559770216` deployed `io.wenathlan:saddle:1.8.2`, NuGet `31559770142` created and pushed `Saddle.1.8.2.nupkg`, RubyGems `31559770023` registered `saddle (1.8.2)`, and extension build `31559769740` attached `saddle-extension-1.8.2.zip`. Release validation `31559770059` also succeeded. Independent checks confirmed npmjs `@wenathlan/saddle@1.8.2` and the GitHub release asset. The GitHub organization package-list API returned 403 for the available integration, while unauthenticated GHCR, Maven and NuGet endpoints returned 401; these protected visibility boundaries do not contradict the successful authenticated workflow logs.
48
52
 
49
53
  ## sources
50
54
 
package/docs/release.md CHANGED
@@ -1,4 +1,4 @@
1
- # saddle release 1.8.1
1
+ # saddle release 1.8.4
2
2
 
3
3
  ## release path
4
4
 
@@ -6,12 +6,13 @@ The release path is intentionally split into source validation, package validati
6
6
 
7
7
  | step | owner | condition |
8
8
  |---|---|---|
9
- | package version | repository | `package.json` is `1.8.1` |
9
+ | package version | repository | `package.json` is `1.8.4` |
10
10
  | quality gate | GitHub Actions | `npm run pack:check` passes |
11
- | tag | repository owner | tag `v1.8.1` points to the validated release commit |
12
- | GitHub release | repository owner | release `v1.8.1` is created from the validated tag |
11
+ | tag | repository owner | tag `v1.8.4` points to the validated release commit |
12
+ | GitHub release | repository owner | release `v1.8.4` is created from the validated tag |
13
13
  | GitHub Packages | GitHub Actions | `publishgithubnpm.yml`, `publishghcr.yml`, `publishmaven.yml`, `publishnuget.yml`, and `publishrubygems.yml` use `GITHUB_TOKEN` |
14
14
  | public npmjs | owner-managed GitHub Actions secret | `publishnpmjs.yml` uses `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` and derives the version from the release tag or latest release in manual maintenance runs |
15
+ | extension zip | GitHub Actions | `buildextension.yml` derives the version from the release tag, validates the unpacked artifact and attaches `saddle-extension-<version>.zip` |
15
16
 
16
17
  ## credential rule
17
18
 
@@ -21,8 +22,8 @@ The npm token previously sent in chat is compromised and must not be used. GitHu
21
22
 
22
23
  ```text
23
24
  npm run pack:check
24
- git tag v1.8.1
25
- git push origin v1.8.1
25
+ git tag v1.8.2
26
+ git push origin v1.8.2
26
27
  ```
27
28
 
28
- The release-created event is the publication trigger for the registry workflows. A dry-run verifies package shape and local tests, but cannot verify registry ownership, Trusted Publisher configuration, package scope authorization, or package visibility; those remain settings controlled by the owner.
29
+ The release-created event is the publication and extension-asset trigger for the release workflows. A dry-run verifies package shape and local tests, but cannot verify registry ownership, Trusted Publisher configuration, package scope authorization, package visibility or browser-store submission; those remain settings controlled by the owner.
@@ -12,4 +12,4 @@ The candidate must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm
12
12
 
13
13
  ## publication boundary
14
14
 
15
- The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry.
15
+ The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry. The npmjs job authenticated as `wenathlan` and reported `+ @wenathlan/saddle@1.8.1`; a later independent `npm view` and direct registry metadata lookup both returned `1.8.1`, confirming public visibility.
@@ -0,0 +1,27 @@
1
+ # Saddle 1.8.2
2
+
3
+ Saddle 1.8.2 is the first release prepared after the repository transfer to `wenathlan`. The authenticated `iakadion` account still has administrator permission on the repository, while `wenathlan` is the repository owner and the canonical public npm owner.
4
+
5
+ ## canonical identities
6
+
7
+ The JavaScript package is `@wenathlan/saddle` on public npm and GitHub Packages npm. GHCR resolves to `ghcr.io/wenathlan/saddle`, and Maven uses the owner-aligned coordinate `io.wenathlan:saddle`. NuGet keeps the unscoped package id `Saddle`, and RubyGems keeps the unscoped gem name `saddle`, because those registries do not use npm-style owner scopes; their authorship and repository metadata now point to `wenathlan`.
8
+
9
+ | Destination | 1.8.2 identity | Owner resolution |
10
+ | --- | --- | --- |
11
+ | public npm | `@wenathlan/saddle@1.8.2` | owner-managed `NPM_TOKEN` |
12
+ | GitHub Packages npm | `@wenathlan/saddle@1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
13
+ | GHCR | `ghcr.io/wenathlan/saddle:1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
14
+ | Maven | `io.wenathlan:saddle:1.8.2` | transferred GitHub Packages owner path |
15
+ | NuGet | `Saddle 1.8.2` | unscoped NuGet package id with `wenathlan` repository metadata |
16
+ | RubyGems | `saddle 1.8.2` | unscoped gem name with `wenathlan` metadata |
17
+ | extension | `saddle-extension-1.8.2.zip` | release-tag-derived asset |
18
+
19
+ ## included engine changes
20
+
21
+ This release includes the minimal extension permission policy, deterministic extension packaging, context-aware window/tab/frame replay, the transport-neutral export graph audit, and bounded content-type normalization for JSON, XML, Markdown, text, HTML and binary results. The package remains root-based JavaScript ESM and keeps Node-only adapters in explicit subpaths.
22
+
23
+ ## validation boundary
24
+
25
+ The release must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm run pack:check` and `git diff --check` before the tag is created. Each registry workflow remains isolated because its package protocol and credential boundary differ. Publication evidence is reported only after the workflow succeeds and the target registry can be queried independently.
26
+
27
+ The `v1.8.2` release and all publication workflows completed successfully. Public npm and the extension asset were independently visible. GitHub Package registries returned their expected protected-access responses to unauthenticated probes, so their workflow logs remain the authoritative evidence available to the authenticated publisher context.
@@ -0,0 +1,7 @@
1
+ # Saddle 1.8.4
2
+
3
+ Saddle 1.8.4 updates every active library and forge pipeline to Node.js 26.7.0 and adds deterministic package gates to GitLab, Forgejo, Gitea and Woodpecker workflows. GitHub Actions remains the authoritative multi-registry release path; the other forge files are caller-configured validation and artifact paths because their runners, secrets and package registries differ.
4
+
5
+ The legacy public Maven package `io.devthink.saddle` was identified under the `wenathlan` organization. Its deletion request was attempted through the GitHub Packages API, but the authenticated GitHub App integration returned HTTP 403 because it lacks package-management deletion permission. The new owner-aligned Maven coordinate is `io.wenathlan:saddle` and must not be removed.
6
+
7
+ The companion Saddle Pages repository now contains a GitHub Pages workflow using `actions/configure-pages@v5`, `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`, plus caller-configured GitLab Pages, Forgejo, Codeberg Pages, Gitea and Woodpecker pipelines. The site builds only `dist/public` with `VITE_BASE_PATH=/saddle-pages/` and uses Node.js 26.7.0.
package/docs/usage.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## install
4
4
 
5
- The canonical public package is `@wenathlan/saddle`. The GitHub Packages npm variant is `@iakadion/saddle`; consumers of that registry must configure npm for the repository owner scope before installing.
5
+ The canonical package is `@wenathlan/saddle` on both public npm and GitHub Packages npm after the repository transfer. Consumers of GitHub Packages must configure the `wenathlan` registry scope and authenticate with a token authorized for that package.
6
6
 
7
7
  ```bash
8
8
  npm install @wenathlan/saddle
@@ -30,5 +30,17 @@ function validateevent(value, index) {
30
30
  if (value.key !== undefined && typeof value.key !== "string") throw validationerror(`session event ${index} key is invalid`);
31
31
  if (value.target !== undefined && typeof value.target !== "string") throw validationerror(`session event ${index} target is invalid`);
32
32
  if (value.button !== undefined && !["left", "right"].includes(value.button)) throw validationerror(`session event ${index} button is invalid`);
33
- return { ...value };
33
+ const context = validatecontext(value.context ?? value, index);
34
+ return context ? { ...value, context } : { ...value };
35
+ }
36
+
37
+ function validatecontext(value, index) {
38
+ if (value.context !== undefined && (value.context === null || typeof value.context !== "object" || Array.isArray(value.context))) throw validationerror(`session event ${index} context is invalid`);
39
+ const source = value.context ?? value;
40
+ const context = {};
41
+ for (const name of ["windowid", "tabid", "frameid"]) if (source[name] !== undefined) {
42
+ if (typeof source[name] !== "string" || !source[name]) throw validationerror(`session event ${index} ${name} is invalid`);
43
+ context[name] = source[name];
44
+ }
45
+ return Object.keys(context).length ? context : undefined;
34
46
  }
@@ -14,10 +14,14 @@ The manifest requests only `activeTab`, `scripting` and `storage`. It does not r
14
14
 
15
15
  ## boundaries
16
16
 
17
- The content bridge runs in Chrome's isolated world. It exposes bounded page metadata, visible text, stable references and user initiated click or fill commands. The service worker forwards versioned messages and stores only the latest snapshot metadata in session storage. No endpoint, credential, remote script or browser profile is embedded.
17
+ The content bridge runs in Chrome's isolated world. It exposes bounded page metadata, visible text, stable references and user initiated click or fill commands. The service worker forwards versioned messages, persists bounded pending command records and stores the latest snapshot metadata in session storage. Rehydration is explicit; startup never replays a command without a caller action. No endpoint, credential, remote script or browser profile is embedded.
18
18
 
19
19
  `protocol.js` and `serviceworker.js` are reusable ESM contracts. `content.js` is intentionally a classic injected file because programmatic Chrome content scripts are loaded as files; it exposes a small global bridge and avoids arbitrary page JavaScript evaluation.
20
20
 
21
+ ## deterministic release artifact
22
+
23
+ The Node-only build adapter creates an isolated unpacked artifact with the release version in its manifest. A caller can run `npm run extension:build -- --version 1.8.2 --output build/extension` and package that directory with the archive tool available in the host environment. The release workflow derives the version from the published tag and attaches `saddle-extension-<version>.zip` without changing the source manifest.
24
+
21
25
  ## next slices
22
26
 
23
- The next extension slices should add snapshot diffing, tab and frame identity, resumable command records, optional host permission escalation, browser action results and a deterministic zip workflow. Browser providers, login profiles, captcha solvers and remote runners remain caller owned adapters.
27
+ The next extension slices should add snapshot diffing, richer tab and frame metadata, optional host permission escalation and browser action results. Browser providers, login profiles, captcha solvers and remote runners remain caller owned adapters.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * extension build adapter creates a versioned, unpacked Manifest V3 artifact for release packaging.
3
+ */
4
+
5
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const rootpath = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const entries = ["manifest.json", "worker.js", "serviceworker.js", "content.js", "popup.js", "popup.html", "popup.css", "protocol.js", "permissions.js"];
11
+
12
+ function parsearguments(argumentslist) {
13
+ const options = {};
14
+ for (let index = 0; index < argumentslist.length; index += 1) {
15
+ const argument = argumentslist[index];
16
+ if (argument === "--version") options.version = argumentslist[++index];
17
+ else if (argument === "--output") options.output = argumentslist[++index];
18
+ else throw new TypeError(`unsupported extension build argument: ${argument}`);
19
+ }
20
+ return options;
21
+ }
22
+
23
+ function validversion(version) {
24
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version ?? ""));
25
+ }
26
+
27
+ /** Builds the extension into an isolated directory and returns its manifest. */
28
+ export async function buildextension(options = {}) {
29
+ const packagefile = JSON.parse(await readFile(resolve(rootpath, "package.json"), "utf8"));
30
+ const version = String(options.version ?? packagefile.version);
31
+ if (!validversion(version)) throw new TypeError(`invalid extension version: ${version}`);
32
+ const output = resolve(process.cwd(), options.output ?? "build/extension");
33
+ await rm(output, { force: true, recursive: true });
34
+ await mkdir(output, { recursive: true });
35
+ const manifest = JSON.parse(await readFile(resolve(rootpath, "extension/manifest.json"), "utf8"));
36
+ manifest.version = version;
37
+ for (const entry of entries) {
38
+ const source = resolve(rootpath, "extension", entry);
39
+ const destination = resolve(output, entry);
40
+ if (entry === "manifest.json") await writeFile(destination, `${JSON.stringify(manifest, null, 2)}\n`);
41
+ else await cp(source, destination);
42
+ }
43
+ return { output, manifest };
44
+ }
45
+
46
+ if (process.argv[1] === fileURLToPath(import.meta.url)) buildextension(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`extension artifact: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
@@ -3,3 +3,4 @@
3
3
  */
4
4
  export * from "./protocol.js";
5
5
  export * from "./serviceworker.js";
6
+ export * from "./permissions.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * extension permission policies keep the browser capability boundary explicit and caller-owned.
3
+ */
4
+
5
+ export const extensionpermissions = Object.freeze(["activeTab", "scripting", "storage"]);
6
+
7
+ /** Creates a minimal permission policy without requesting broad host access. */
8
+ export function permissionpolicy(options = {}) {
9
+ const requested = [...new Set((options.requested ?? extensionpermissions).map(String))];
10
+ const optional = [...new Set((options.optional ?? []).map(String))];
11
+ const unknown = requested.concat(optional).filter((permission) => !extensionpermissions.includes(permission));
12
+ if (unknown.length) throw new TypeError(`unsupported extension permission: ${unknown[0]}`);
13
+ return { version: 1, requested, optional, hostpermissions: [], allows(permission) { return requested.includes(String(permission)); }, missing(permissions = []) { return [...new Set(permissions.map(String))].filter((permission) => !requested.includes(permission)); } };
14
+ }
15
+
16
+ /** Requests an optional capability through an injected browser permission function. */
17
+ export async function requestpermission(policy, permission, request) {
18
+ const name = String(permission ?? "");
19
+ if (!policy?.optional?.includes(name)) throw new TypeError(`permission is not optional: ${name}`);
20
+ if (typeof request !== "function") throw new TypeError("permission request function is required");
21
+ try { return { permission: name, granted: Boolean(await request(name)) }; } catch (error) { return { permission: name, granted: false, code: String(error?.code ?? "PERMISSION_REQUEST_FAILED"), message: String(error?.message ?? error) }; }
22
+ }
@@ -11,7 +11,9 @@ export function createworkerrouter(options = {}) {
11
11
  const storage = options.storage;
12
12
  const contentfile = options.contentfile ?? "content.js";
13
13
  const statekey = options.statekey ?? "saddleextensionstate";
14
+ const maxpending = options.maxpending ?? 32;
14
15
  if (typeof tabs?.sendMessage !== "function") throw new TypeError("extension router requires tabs.sendMessage");
16
+ if (!Number.isSafeInteger(maxpending) || maxpending < 1) throw new TypeError("extension router maxpending must be a positive safe integer");
15
17
 
16
18
  async function ensurecontent(tabid) {
17
19
  if (!Number.isInteger(tabid)) throw new TypeError("extension command requires a tab id");
@@ -29,15 +31,72 @@ export function createworkerrouter(options = {}) {
29
31
  if (typeof storage?.set === "function") await storage.set({ [statekey]: value });
30
32
  }
31
33
 
34
+ async function pendingstate() {
35
+ const value = await readstate();
36
+ return { ...value, pending: Array.isArray(value.pending) ? value.pending.filter(validpending) : [] };
37
+ }
38
+
39
+ async function enqueue(request, sender) {
40
+ const state = await pendingstate();
41
+ const record = pendingrecord(request, sender);
42
+ const pending = state.pending.filter((item) => item.requestid !== record.requestid);
43
+ if (pending.length >= maxpending && !state.pending.some((item) => item.requestid === record.requestid)) throw extensionerror("PENDING_LIMIT", "extension pending command limit reached");
44
+ pending.push(record);
45
+ await savestate({ ...state, pending });
46
+ return record;
47
+ }
48
+
49
+ async function complete(requestid, patch = {}) {
50
+ const state = await pendingstate();
51
+ await savestate({ ...state, ...patch, pending: state.pending.filter((item) => item.requestid !== requestid) });
52
+ }
53
+
54
+ async function markfailure(requestid, error) {
55
+ const state = await pendingstate();
56
+ const pending = state.pending.map((item) => item.requestid === requestid ? { ...item, attempts: item.attempts + 1, lasterror: { code: String(error?.code ?? "extension_error"), message: String(error?.message ?? error) }, updatedat: Date.now() } : item);
57
+ await savestate({ ...state, pending });
58
+ }
59
+
60
+ async function dispatch(request, sender = {}) {
61
+ const tabid = request.payload?.tabid ?? sender.tab?.id;
62
+ await ensurecontent(tabid);
63
+ return tabs.sendMessage(tabid, request);
64
+ }
65
+
32
66
  async function handle(message, sender = {}) {
33
67
  const request = assertmessage(message);
34
68
  if (request.type !== "command") throw new TypeError("extension router accepts commands only");
35
69
  const tabid = request.payload?.tabid ?? sender.tab?.id;
36
- await ensurecontent(tabid);
37
- const response = await tabs.sendMessage(tabid, request);
38
- if (response?.type === "response" && response.payload?.snapshotid) await savestate({ tabid, snapshotid: response.payload.snapshotid, updatedat: Date.now() });
70
+ await enqueue(request, { ...sender, tab: { ...sender.tab, id: tabid } });
71
+ try {
72
+ const response = await dispatch(request, { ...sender, tab: { ...sender.tab, id: tabid } });
73
+ await complete(request.id, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, updatedat: Date.now() } : { updatedat: Date.now() });
74
+ return response;
75
+ } catch (error) {
76
+ await markfailure(request.id, error);
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ async function rehydrate() { return pendingstate(); }
82
+
83
+ async function resume(requestid, sender = {}) {
84
+ const state = await pendingstate();
85
+ const pending = state.pending.find((item) => item.requestid === requestid);
86
+ if (!pending) throw extensionerror("PENDING_NOT_FOUND", `pending command not found: ${requestid}`);
87
+ const tabid = pending.tabid ?? sender.tab?.id;
88
+ const response = await dispatch(pending.message, { ...sender, tab: { ...sender.tab, id: tabid } });
89
+ await complete(requestid, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, updatedat: Date.now() } : { updatedat: Date.now() });
39
90
  return response;
40
91
  }
41
92
 
42
- return { ensurecontent, readstate, savestate, handle };
93
+ async function cancel(requestid) { const state = await pendingstate(); await savestate({ ...state, pending: state.pending.filter((item) => item.requestid !== requestid) }); }
94
+
95
+ return { ensurecontent, readstate, savestate, rehydrate, enqueue, resume, cancel, handle };
43
96
  }
97
+
98
+ function pendingrecord(request, sender = {}) { return { requestid: request.id, command: request.command, message: request, tabid: request.payload?.tabid ?? sender.tab?.id, attempts: 0, createdat: Date.now(), updatedat: Date.now() }; }
99
+
100
+ function validpending(value) { return Boolean(value && typeof value === "object" && typeof value.requestid === "string" && value.message && value.message.type === "command" && Number.isSafeInteger(value.attempts) && value.attempts >= 0); }
101
+
102
+ function extensionerror(code, message) { const error = new Error(message); error.code = code; return error; }
@@ -13,8 +13,10 @@ export function startworker(chromeapi = globalThis.chrome) {
13
13
  router.handle(message, sender).then(sendresponse).catch((error) => sendresponse(createerror(message, error)));
14
14
  return true;
15
15
  };
16
+ const startup = () => router.rehydrate().catch(() => undefined);
16
17
  chromeapi.runtime.onMessage.addListener(listener);
17
- return { router, dispose() { chromeapi.runtime.onMessage.removeListener?.(listener); } };
18
+ chromeapi.runtime.onStartup?.addListener(startup);
19
+ return { router, dispose() { chromeapi.runtime.onMessage.removeListener?.(listener); chromeapi.runtime.onStartup?.removeListener?.(startup); } };
18
20
  }
19
21
 
20
22
  if (globalThis.chrome?.runtime?.onMessage) startworker();
package/index.js CHANGED
@@ -77,6 +77,7 @@ export * from "./crawl/normalize.js";
77
77
  export * from "./crawl/crawler.js";
78
78
  export * from "./crawl/persistent.js";
79
79
  export * from "./scrape/schema.js";
80
+ export * from "./scrape/normalize.js";
80
81
  export * from "./mcp/server.js";
81
82
  export * from "./runtime/detect.js";
82
83
  export * from "./runtime/abort.js";
package/library/public.js CHANGED
@@ -5,6 +5,7 @@ import { crawl } from "../crawl/crawler.js";
5
5
  import { chunkmarkdown } from "../ai/chunk.js";
6
6
  import { estimatetokens } from "../ai/tokens.js";
7
7
  import { extracthtml } from "../scrape/extract.js";
8
+ import { normalizeresponse } from "../scrape/normalize.js";
8
9
  import { browseragent } from "../browser/agent.js";
9
10
 
10
11
  /** Selects the fetch or browser execution path. */
@@ -19,8 +20,9 @@ export async function scrapeurl(url, options = {}) {
19
20
  const target = safeurl(url);
20
21
  const response = await (options.fetcher ?? fetch)(target, { signal: options.signal, headers: options.headers });
21
22
  if (!response.ok) throw new Error(`scrape request failed with ${response.status}`);
22
- const html = await response.text();
23
- return formatresult(scrapehtml(html, target, options), options);
23
+ const normalized = await normalizeresponse(response, { url: target, defaultcontenttype: "text/html", maxbytes: options.maxbytes });
24
+ const result = normalized.kind === "html" ? scrapehtml(normalized.content, target, options) : { content: normalized.content, data: normalized.data, metadata: { url: target, contenttype: normalized.contenttype, size: normalized.size }, bytes: normalized.bytes };
25
+ return formatresult(result, options);
24
26
  }
25
27
 
26
28
  /** Extracts a serializable result from HTML without network access. */
package/license.md CHANGED
@@ -198,6 +198,6 @@
198
198
 
199
199
  You should have received a copy of the Proprietary Source-Available
200
200
  License along with this program. If not, see
201
- <https://github.com/iakadion/saddle/blob/main/license.txt>
201
+ <https://github.com/wenathlan/saddle/blob/main/license.txt>
202
202
 
203
203
  Also add information on how to contact you by electronic and paper mail.
package/license.txt CHANGED
@@ -198,6 +198,6 @@
198
198
 
199
199
  You should have received a copy of the Proprietary Source-Available
200
200
  License along with this program. If not, see
201
- <https://github.com/iakadion/saddle/blob/main/license.txt>
201
+ <https://github.com/wenathlan/saddle/blob/main/license.txt>
202
202
 
203
203
  Also add information on how to contact you by electronic and paper mail.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@wenathlan/saddle",
3
- "version": "1.8.1",
3
+ "version": "1.8.4",
4
4
  "description": "binary computing engine that turns distributed storage into a publishable working set",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
7
7
  "publishConfig": { "access": "public" },
8
- "repository": { "type": "git", "url": "https://github.com/iakadion/saddle.git" },
9
- "bugs": { "url": "https://github.com/iakadion/saddle/issues" },
10
- "homepage": "https://github.com/iakadion/saddle#readme",
8
+ "repository": { "type": "git", "url": "https://github.com/wenathlan/saddle.git" },
9
+ "bugs": { "url": "https://github.com/wenathlan/saddle/issues" },
10
+ "homepage": "https://github.com/wenathlan/saddle#readme",
11
11
  "keywords": ["distributed computing", "virtual memory", "runner", "storage", "automation"],
12
12
  "engines": { "node": ">=22" },
13
13
  "bin": { "saddle": "./cli/main.js" },
@@ -37,10 +37,11 @@
37
37
  },
38
38
  "files": ["core", "domain", "memory", "runners", "runtime", "storage", "sessions", "modes", "adapters", "persistence", "queue", "dispatch", "scrape", "crawl", "api", "mcp", "browser", "proxy", "captcha", "ai", "webhook", "surfaces", "library", "errors", "retry", "server", "binary", "format", "packager", "bot", "protocol", "workflow", "cli", "extension", "index.js", "docs", "examples", "README.md", "LICENSE"],
39
39
  "scripts": {
40
- "check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/popup.js",
40
+ "check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js",
41
41
  "formatcheck": "node format/check.js",
42
42
  "test": "node --test tests/*.test.js",
43
43
  "run": "node cli/main.js",
44
+ "extension:build": "node extension/build.js",
44
45
  "pack:check": "npm run check && npm run formatcheck && npm test && npm pack --dry-run",
45
46
  "prepublishOnly": "npm run pack:check"
46
47
  }
@@ -19,7 +19,7 @@ export function binaryplan(manifest, options = {}) {
19
19
  }
20
20
 
21
21
  export function containerplan(manifest, options = {}) {
22
- const base = options.base ?? "node:22-alpine";
22
+ const base = options.base ?? "node:26.7.0-alpine";
23
23
  const workdir = options.workdir ?? "/app";
24
24
  const command = options.command ?? ["node", manifest.entry];
25
25
  const lines = [`from ${base}`, `workdir ${workdir}`, "copy package.json package-lock.json ./", "run npm ci --omit=dev", "copy . .", `cmd ${JSON.stringify(command)}`];
package/readme.txt CHANGED
@@ -50,27 +50,27 @@
50
50
  <p align="center">
51
51
  <strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
52
52
  <strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
53
- <a href="https://github.com/iakadion/saddle/actions/workflows/ci.yml"><img src="https://github.com/iakadion/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
54
- <a href="https://github.com/iakadion/saddle/releases/tag/v1.0.0"><img src="https://img.shields.io/badge/release-v1.0.0-d35d3d" alt="Release 1.0.0" /></a>
55
- <a href="https://github.com/iakadion/saddle/blob/main/license.md"><img src="https://img.shields.io/badge/license-Proprietary--View--Only-202a2f" alt="Proprietary View Only" /></a>
53
+ <a href="https://github.com/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
54
+ <a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.2"><img src="https://img.shields.io/badge/release-v1.8.2-d35d3d" alt="Release 1.8.2" /></a>
55
+ <a href="https://github.com/wenathlan/saddle/blob/main/license.md"><img src="https://img.shields.io/badge/license-Proprietary--View--Only-202a2f" alt="Proprietary View Only" /></a>
56
56
  </p>
57
57
 
58
58
  > **Core idea:** storage is the durable side of the working set; the runner is replaceable; the artifact is the boundary. **Storage == Compute** — RAM and disk are the same construct, differing only by usage flag.
59
59
 
60
60
  Saddle is a **JavaScript ESM engine** for jobs that move data between storage, a working set, an injected runner and durable artifacts. It is also a **virtual machine you publish as a package** that runs on other people's computers (GitHub Actions, Forgejo, Gitea, GitLab, Codeberg, free Docker containers) and turns unlimited third-party storage buckets into virtual RAM/GPU/CPU. Nothing runs on the operator's local machine.
61
61
 
62
- Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. Package `@devthink/saddle` published to npm, GitHub Packages, Maven, NuGet, RubyGems and GHCR (auto-mirrored to jsDelivr).
62
+ Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. The canonical JavaScript package is `@wenathlan/saddle`; GitHub Packages npm, Maven and GHCR use the transferred `wenathlan` owner namespace, while NuGet and RubyGems retain their unscoped ecosystem package names.
63
63
 
64
64
  ## Start here
65
65
 
66
66
  Saddle requires **Node.js 22 or newer**.
67
67
 
68
68
  ```bash
69
- npm install @devthink/saddle
69
+ npm install @wenathlan/saddle
70
70
  ```
71
71
 
72
72
  ```js
73
- import { scrapeurl, formatforagent } from "@devthink/saddle";
73
+ import { scrapeurl, formatforagent } from "@wenathlan/saddle";
74
74
 
75
75
  const result = await scrapeurl("https://example.com", { format: "markdown" });
76
76
  const context = formatforagent(result, { maxchunksize: 2000, keypoints: 4 });
@@ -130,7 +130,7 @@ Saddle coordinates contracts instead of hiding providers. A repo + CI runner is
130
130
  - repository_dispatch = IPC
131
131
 
132
132
  ```js
133
- import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@devthink/saddle";
133
+ import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@wenathlan/saddle";
134
134
  const events = eventbus();
135
135
  const run = engine({
136
136
  storage: localstorage("./.saddle-data"),
@@ -160,4 +160,4 @@ saddle deploy --target netlify
160
160
  ## Security boundaries
161
161
 
162
162
  | Boundary | Policy |
163
- | --- | ---
163
+ | --- | --- |
@@ -0,0 +1,86 @@
1
+ /**
2
+ * content normalization classifies bounded response bytes without owning a parser, transport or storage backend.
3
+ */
4
+
5
+ const extensions = Object.freeze({
6
+ ".json": "application/json",
7
+ ".map": "application/json",
8
+ ".xml": "application/xml",
9
+ ".rss": "application/rss+xml",
10
+ ".atom": "application/atom+xml",
11
+ ".md": "text/markdown",
12
+ ".markdown": "text/markdown",
13
+ ".html": "text/html",
14
+ ".htm": "text/html",
15
+ ".txt": "text/plain",
16
+ ".csv": "text/csv"
17
+ });
18
+
19
+ /** Detects a normalized media type from a header, URL suffix or caller fallback. */
20
+ export function detectcontenttype(header, url, fallback = "application/octet-stream") {
21
+ const value = String(header ?? "").split(";", 1)[0].trim().toLowerCase();
22
+ if (value) return value;
23
+ try {
24
+ const pathname = new URL(url).pathname.toLowerCase();
25
+ const suffix = Object.keys(extensions).find((extension) => pathname.endsWith(extension));
26
+ if (suffix) return extensions[suffix];
27
+ } catch { /* invalid or absent URLs remain caller-owned metadata */ }
28
+ return String(fallback).toLowerCase();
29
+ }
30
+
31
+ /** Normalizes bounded input bytes into a serializable text, JSON or binary result. */
32
+ export function normalizeresult(input, options = {}) {
33
+ const contenttype = detectcontenttype(options.contenttype, options.url, options.defaultcontenttype ?? "application/octet-stream");
34
+ const bytes = tobytes(input);
35
+ const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
36
+ if (bytes.byteLength > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
37
+ const kind = contentkind(contenttype);
38
+ if (kind === "binary") return { contenttype, kind, size: bytes.byteLength, bytes };
39
+ const text = new TextDecoder(options.charset ?? "utf-8", { fatal: false }).decode(bytes).replaceAll("\r\n", "\n");
40
+ if (text.length > boundedlimit(options.maxtext, maxbytes)) throw normalizationerror("TEXT_TOO_LARGE", "decoded content exceeds the configured text limit");
41
+ if (kind === "json") {
42
+ try { return { contenttype, kind, size: bytes.byteLength, content: text, data: JSON.parse(text) }; } catch (error) { throw normalizationerror("INVALID_JSON", `invalid JSON content: ${error.message}`); }
43
+ }
44
+ return { contenttype, kind, size: bytes.byteLength, content: text };
45
+ }
46
+
47
+ /** Reads a caller-provided response body after checking declared and actual size limits. */
48
+ export async function normalizeresponse(response, options = {}) {
49
+ if (!response || typeof response !== "object") throw new TypeError("response is required");
50
+ const headers = response.headers;
51
+ const header = typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"];
52
+ const declared = Number(typeof headers?.get === "function" ? headers.get("content-length") : headers?.["content-length"]);
53
+ const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
54
+ if (Number.isFinite(declared) && declared > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
55
+ const body = typeof response.arrayBuffer === "function" ? await response.arrayBuffer() : await response.text();
56
+ return normalizeresult(body, { ...options, contenttype: header ?? options.contenttype, url: options.url ?? response.url });
57
+ }
58
+
59
+ function contentkind(contenttype) {
60
+ if (contenttype === "application/json" || contenttype.endsWith("+json")) return "json";
61
+ if (contenttype === "text/html" || contenttype === "application/xhtml+xml") return "html";
62
+ if (contenttype === "application/xml" || contenttype === "text/xml" || contenttype.endsWith("+xml")) return "xml";
63
+ if (contenttype === "text/markdown") return "markdown";
64
+ if (contenttype.startsWith("text/")) return "text";
65
+ return "binary";
66
+ }
67
+
68
+ function tobytes(input) {
69
+ if (typeof input === "string") return new TextEncoder().encode(input);
70
+ if (input instanceof Uint8Array) return input;
71
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
72
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
73
+ throw new TypeError("content must be a string, ArrayBuffer or typed array");
74
+ }
75
+
76
+ function boundedlimit(value, fallback) {
77
+ const limit = Number(value ?? fallback);
78
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("content limit must be a positive safe integer");
79
+ return limit;
80
+ }
81
+
82
+ function normalizationerror(code, message) {
83
+ const error = new Error(message);
84
+ error.code = code;
85
+ return error;
86
+ }
@@ -5,17 +5,57 @@ export async function replay(session, adapter, options = {}) {
5
5
  if (!session?.events || typeof adapter?.move !== "function") throw new TypeError("replay requires session events and browser adapter");
6
6
  const speed = options.speed ?? 1;
7
7
  let previous = 0;
8
+ let currentcontext = normalizecontext(options.initialcontext);
9
+ let contextswitches = 0;
8
10
  for (const event of session.events) {
9
11
  const wait = Math.max(0, (event.t - previous) / speed);
10
12
  if (wait) await delay(wait);
11
13
  previous = event.t;
14
+ const eventcontext = normalizecontext(event.context ?? event);
15
+ if (eventcontext && contextchanged(currentcontext, eventcontext)) {
16
+ await restorecontext(adapter, eventcontext, currentcontext);
17
+ currentcontext = eventcontext;
18
+ contextswitches += 1;
19
+ }
12
20
  if (event.type === "move") await adapter.move(event);
13
21
  else if (event.type === "click") await adapter.click(event);
14
22
  else if (event.type === "drag") await adapter.drag(event);
15
23
  else if (event.type === "scroll") await adapter.scroll(event);
16
24
  else if (event.type === "key") await adapter.key(event);
17
25
  }
18
- return { events: session.events.length, duration: previous / speed };
26
+ return { events: session.events.length, duration: previous / speed, contextswitches };
27
+ }
28
+
29
+ function normalizecontext(value) {
30
+ if (value === undefined || value === null) return undefined;
31
+ if (typeof value !== "object" || Array.isArray(value)) throw new TypeError("replay context must be an object");
32
+ const context = {};
33
+ for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) {
34
+ if (typeof value[name] !== "string" || !value[name]) throw new TypeError(`replay ${name} must be a non-empty string`);
35
+ context[name] = value[name];
36
+ }
37
+ return Object.keys(context).length ? context : undefined;
38
+ }
39
+
40
+ function contextchanged(previous, next) {
41
+ return ["windowid", "tabid", "frameid"].some((name) => previous?.[name] !== next[name]);
42
+ }
43
+
44
+ async function restorecontext(adapter, next, previous) {
45
+ if (typeof adapter.restorecontext === "function") {
46
+ await adapter.restorecontext({ ...next }, previous ? { ...previous } : undefined);
47
+ return;
48
+ }
49
+ const methods = [["windowid", "selectwindow"], ["tabid", "selecttab"], ["frameid", "selectframe"]];
50
+ for (const [name, method] of methods) if (next[name] !== undefined && previous?.[name] !== next[name]) {
51
+ if (typeof adapter[method] !== "function") {
52
+ const error = new Error(`replay adapter cannot restore ${name}`);
53
+ error.code = "REPLAY_CONTEXT_UNSUPPORTED";
54
+ error.context = name;
55
+ throw error;
56
+ }
57
+ await adapter[method](next[name], { ...next });
58
+ }
19
59
  }
20
60
 
21
61
  function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
@@ -5,14 +5,14 @@ import { workflowinputs } from "./manifest.js";
5
5
 
6
6
  export function githubworkflow(manifest) {
7
7
  const input = workflowinputs(manifest);
8
- return `name: ${manifest.name}\non:\n workflow_dispatch:\n inputs:\n jobid:\n required: true\n type: string\n command:\n required: true\n type: string\n default: ${manifest.command}\n repository_dispatch:\n types: [saddle-job]\npermissions:\n contents: read\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${input.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n - run: npm ci\n - run: \${{ github.event.inputs.command || '${manifest.command}' }}\n env:\n SBOT_JOB_ID: \${{ github.event.inputs.jobid || github.event.client_payload.jobid }}\n - uses: actions/upload-artifact@v4\n with:\n name: saddle-results\n path: ${manifest.artifacts.join("\n ")}\n if-no-files-found: ignore\n`;
8
+ return `name: ${manifest.name}\non:\n workflow_dispatch:\n inputs:\n jobid:\n required: true\n type: string\n command:\n required: true\n type: string\n default: ${manifest.command}\n repository_dispatch:\n types: [saddle-job]\npermissions:\n contents: read\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${input.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 26.7.0\n - run: npm ci\n - run: \${{ github.event.inputs.command || '${manifest.command}' }}\n env:\n SBOT_JOB_ID: \${{ github.event.inputs.jobid || github.event.client_payload.jobid }}\n - uses: actions/upload-artifact@v4\n with:\n name: saddle-results\n path: ${manifest.artifacts.join("\n ")}\n if-no-files-found: ignore\n`;
9
9
  }
10
10
 
11
11
  export function forgejoworkflow(manifest) { return genericworkflow(manifest, "forgejo"); }
12
12
  export function giteaworkflow(manifest) { return genericworkflow(manifest, "gitea"); }
13
- export function woodpeckerworkflow(manifest) { return `when:\n - event: push\n - event: manual\nsteps:\n process:\n image: node:22\n commands:\n - npm ci\n - ${manifest.command}\n`;
13
+ export function woodpeckerworkflow(manifest) { return `when:\n - event: push\n - event: manual\nsteps:\n process:\n image: node:26.7.0\n commands:\n - npm ci\n - ${manifest.command}\n`;
14
14
  }
15
- export function gitlabworkflow(manifest) { return `stages:\n - process\nprocess:\n stage: process\n image: node:22\n script:\n - npm ci\n - ${manifest.command}\n artifacts:\n when: always\n paths:\n${manifest.artifacts.map((item) => ` - ${item}`).join("\n")}\n`; }
15
+ export function gitlabworkflow(manifest) { return `stages:\n - process\nprocess:\n stage: process\n image: node:26.7.0\n script:\n - npm ci\n - ${manifest.command}\n artifacts:\n when: always\n paths:\n${manifest.artifacts.map((item) => ` - ${item}`).join("\n")}\n`; }
16
16
 
17
- function genericworkflow(manifest, name) { return `name: ${manifest.name}\non:\n workflow_dispatch:\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${manifest.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n - run: npm ci\n - run: ${manifest.command}\n`;
17
+ function genericworkflow(manifest, name) { return `name: ${manifest.name}\non:\n workflow_dispatch:\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${manifest.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 26.7.0\n - run: npm ci\n - run: ${manifest.command}\n`;
18
18
  }