@wenathlan/saddle 1.8.1
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 +203 -0
- package/README.md +192 -0
- package/adapters/forge.js +16 -0
- package/adapters/forgejo.js +8 -0
- package/adapters/github.js +19 -0
- package/adapters/gitlab.js +10 -0
- package/adapters/huggingface.js +6 -0
- package/adapters/socket.js +14 -0
- package/adapters/transport.js +30 -0
- package/ai/chunk.js +22 -0
- package/ai/llmstxt.js +12 -0
- package/ai/provenance.js +18 -0
- package/ai/rag.js +14 -0
- package/ai/tokens.js +9 -0
- package/api/auth.js +13 -0
- package/api/contracts.js +17 -0
- package/api/control.js +33 -0
- package/api/http.js +12 -0
- package/api/rate.js +31 -0
- package/api/security.js +42 -0
- package/api/service.js +36 -0
- package/binary/build.js +17 -0
- package/bot/adapter.js +8 -0
- package/bot/bot.js +39 -0
- package/bot/commands.js +18 -0
- package/bot/permissions.js +16 -0
- package/browser/actions.js +33 -0
- package/browser/agent.js +9 -0
- package/browser/context.js +52 -0
- package/browser/fingerprint.js +12 -0
- package/browser/index.js +10 -0
- package/browser/recorder.js +15 -0
- package/browser/session.js +19 -0
- package/browser/snapshot.js +57 -0
- package/captcha/contract.js +15 -0
- package/captcha/evidence.js +9 -0
- package/captcha/guard.js +10 -0
- package/cli/main.js +36 -0
- package/core/errors.js +37 -0
- package/core/events.js +21 -0
- package/core/hash.js +73 -0
- package/core/ids.js +15 -0
- package/crawl/crawler.js +29 -0
- package/crawl/frontier.js +34 -0
- package/crawl/normalize.js +14 -0
- package/crawl/persistent.js +13 -0
- package/dispatch/resumable.js +31 -0
- package/dispatch/workflow.js +33 -0
- package/docs/assets/architecture.svg +45 -0
- package/docs/assets/saddlemark.svg +13 -0
- package/docs/comparativeaudit.md +63 -0
- package/docs/ecosystemplan.md +59 -0
- package/docs/enginearchitecture.md +83 -0
- package/docs/featureaudit.md +63 -0
- package/docs/gapmatrix.md +80 -0
- package/docs/libraryapi.md +63 -0
- package/docs/modes.md +27 -0
- package/docs/productindex.md +28 -0
- package/docs/registryresearch.md +56 -0
- package/docs/release.md +28 -0
- package/docs/release17notes.md +24 -0
- package/docs/release181notes.md +15 -0
- package/docs/release18notes.md +15 -0
- package/docs/roadmapp2p3.md +33 -0
- package/docs/toolchains.md +28 -0
- package/docs/usage.md +107 -0
- package/domain/artifacts.js +13 -0
- package/domain/jobs.js +20 -0
- package/domain/providers.js +8 -0
- package/domain/runtime.js +10 -0
- package/domain/sessions.js +34 -0
- package/errors/taxonomy.js +18 -0
- package/examples/localjob.js +15 -0
- package/examples/publicapi.js +7 -0
- package/extension/README.md +23 -0
- package/extension/content.js +85 -0
- package/extension/index.js +5 -0
- package/extension/manifest.json +10 -0
- package/extension/popup.css +13 -0
- package/extension/popup.html +24 -0
- package/extension/popup.js +25 -0
- package/extension/protocol.js +76 -0
- package/extension/serviceworker.js +43 -0
- package/extension/worker.js +20 -0
- package/format/check.js +21 -0
- package/index.js +120 -0
- package/library/public.js +83 -0
- package/license.md +203 -0
- package/license.txt +203 -0
- package/mcp/browser.js +12 -0
- package/mcp/server.js +28 -0
- package/mcp/transport.js +14 -0
- package/memory/bridge.js +16 -0
- package/memory/engine.js +45 -0
- package/memory/modes.js +55 -0
- package/memory/objects.js +18 -0
- package/memory/targets.js +21 -0
- package/memory/transforms.js +15 -0
- package/modes/matrix.js +20 -0
- package/modes/modes.js +16 -0
- package/modes/resolve.js +39 -0
- package/package.json +47 -0
- package/packager/manifest.js +28 -0
- package/packager/publish.js +15 -0
- package/persistence/adapter.js +8 -0
- package/persistence/drizzle.js +10 -0
- package/persistence/memory.js +26 -0
- package/persistence/migrations.js +14 -0
- package/persistence/prisma.js +23 -0
- package/persistence/schema.js +29 -0
- package/persistence/sql.js +30 -0
- package/protocol/blocks.js +18 -0
- package/protocol/json.js +5 -0
- package/protocol/ndjson.js +17 -0
- package/protocol/sse.js +22 -0
- package/proxy/pool.js +12 -0
- package/queue/idempotency.js +12 -0
- package/queue/persistent.js +44 -0
- package/queue/queue.js +50 -0
- package/queue/saga.js +13 -0
- package/readme.txt +163 -0
- package/retry/circuit.js +15 -0
- package/retry/policy.js +12 -0
- package/runners/health.js +23 -0
- package/runners/heartbeat.js +26 -0
- package/runners/inprocess.js +19 -0
- package/runners/scheduler.js +16 -0
- package/runtime/abort.js +10 -0
- package/runtime/compatibility.js +13 -0
- package/runtime/detect.js +14 -0
- package/runtime/engine.js +56 -0
- package/runtime/worker.js +18 -0
- package/scrape/cache.js +14 -0
- package/scrape/extract.js +14 -0
- package/scrape/robots.js +32 -0
- package/scrape/schema.js +21 -0
- package/scrape/scraper.js +40 -0
- package/scrape/semantic.js +22 -0
- package/server/node.js +34 -0
- package/sessions/file.js +13 -0
- package/sessions/replay.js +21 -0
- package/sessions/store.js +13 -0
- package/storage/adapter.js +8 -0
- package/storage/cache.js +54 -0
- package/storage/checksum.js +17 -0
- package/storage/chunked.js +58 -0
- package/storage/content.js +42 -0
- package/storage/filehosting.js +17 -0
- package/storage/githubcontents.js +18 -0
- package/storage/index.js +10 -0
- package/storage/local.js +35 -0
- package/storage/memory.js +28 -0
- package/storage/s3compatible.js +23 -0
- package/storage/sync.js +55 -0
- package/surfaces/adapters.js +48 -0
- package/surfaces/controls.js +37 -0
- package/surfaces/manifest.js +25 -0
- package/surfaces/n8n.js +24 -0
- package/surfaces/operations.js +43 -0
- package/surfaces/targets.js +16 -0
- package/webhook/delivery.js +26 -0
- package/webhook/receiver.js +20 -0
- package/webhook/signature.js +7 -0
- package/workflow/manifest.js +20 -0
- package/workflow/registry.js +16 -0
- package/workflow/templates.js +18 -0
- package/workflow/triggers.js +31 -0
package/modes/modes.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mode profiles describe paired operation surfaces without changing engine contracts.
|
|
3
|
+
*/
|
|
4
|
+
const names = ["computer", "library", "application", "browser", "desktop", "mobile", "extension", "cli", "binary", "internet", "physicalfile", "vectorfile", "visible", "headless"];
|
|
5
|
+
|
|
6
|
+
export function modeprofile(options = {}) {
|
|
7
|
+
const enabled = new Set(options.enabled ?? ["library", "cli", "binary"]);
|
|
8
|
+
return Object.fromEntries(names.map((name) => [name, { name, enabled: enabled.has(name), paired: Boolean(options.paired?.includes(name)) }]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function librarymode(factory) { return { name: "library", start: factory, stop: async () => undefined }; }
|
|
12
|
+
export function climode(run) { return { name: "cli", run }; }
|
|
13
|
+
export function binarymode(run) { return { name: "binary", run }; }
|
|
14
|
+
export function browsermode(adapter) { return { name: "browser", adapter }; }
|
|
15
|
+
export function headlessmode(adapter) { return { name: "headless", adapter }; }
|
|
16
|
+
export function computemode(adapter) { return { name: "computer", adapter }; }
|
package/modes/resolve.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mode resolver selects open defaults while preserving every caller choice.
|
|
3
|
+
*/
|
|
4
|
+
import { modeaxes, validatemode } from "./matrix.js";
|
|
5
|
+
|
|
6
|
+
/** Resolves an execution profile without starting a process or opening a socket. */
|
|
7
|
+
export function resolvemode(options = {}) {
|
|
8
|
+
const profile = {
|
|
9
|
+
execution: options.execution ?? "library",
|
|
10
|
+
runtime: options.runtime ?? "unknown",
|
|
11
|
+
memory: options.memory ?? "internal",
|
|
12
|
+
file: options.file ?? "internal",
|
|
13
|
+
dependency: options.dependency ?? "internal",
|
|
14
|
+
visibility: options.visibility ?? "headless",
|
|
15
|
+
pair: options.pair ?? "without"
|
|
16
|
+
};
|
|
17
|
+
for (const [axis, value] of Object.entries(profile)) if (!validatemode(axis, value)) throw new TypeError(`unsupported mode ${axis}:${value}`);
|
|
18
|
+
return { ...profile, capabilities: modecapabilities(profile) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns stable capabilities for adapters and diagnostics. */
|
|
22
|
+
export function modecapabilities(profile) {
|
|
23
|
+
return {
|
|
24
|
+
library: profile.execution === "library" || profile.execution === "application",
|
|
25
|
+
browser: profile.execution === "browser" || profile.execution === "extension" || profile.runtime === "browser" || profile.runtime === "worker",
|
|
26
|
+
cli: profile.execution === "cli" || profile.execution === "binary",
|
|
27
|
+
physicalmemory: profile.memory === "physical",
|
|
28
|
+
vectorizedmemory: profile.memory === "vectorized",
|
|
29
|
+
externalmemory: profile.memory === "external",
|
|
30
|
+
externalfile: profile.file === "external",
|
|
31
|
+
externaldependency: profile.dependency === "external",
|
|
32
|
+
visible: profile.visibility === "visible"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Applies one profile to a caller supplied operation. */
|
|
37
|
+
export async function withmode(options, operation) { if (typeof operation !== "function") throw new TypeError("mode operation is required"); return operation(resolvemode(options)); }
|
|
38
|
+
|
|
39
|
+
export { modeaxes };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wenathlan/saddle",
|
|
3
|
+
"version": "1.8.1",
|
|
4
|
+
"description": "binary computing engine that turns distributed storage into a publishable working set",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "GPL-3.0-only",
|
|
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",
|
|
11
|
+
"keywords": ["distributed computing", "virtual memory", "runner", "storage", "automation"],
|
|
12
|
+
"engines": { "node": ">=22" },
|
|
13
|
+
"bin": { "saddle": "./cli/main.js" },
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./index.js",
|
|
16
|
+
"./memory": "./memory/bridge.js",
|
|
17
|
+
"./memory-node": "./memory/bridge.js",
|
|
18
|
+
"./memory-engine": "./memory/engine.js",
|
|
19
|
+
"./storage": "./storage/index.js",
|
|
20
|
+
"./storage-node": "./storage/local.js",
|
|
21
|
+
"./runners": "./runners/scheduler.js",
|
|
22
|
+
"./sessions": "./domain/sessions.js",
|
|
23
|
+
"./modes": "./modes/resolve.js",
|
|
24
|
+
"./mode": "./modes/matrix.js",
|
|
25
|
+
"./browser": "./browser/index.js",
|
|
26
|
+
"./bot": "./bot/bot.js",
|
|
27
|
+
"./captcha": "./captcha/contract.js",
|
|
28
|
+
"./deploy": "./deploy/index.js",
|
|
29
|
+
"./extension": "./extension/index.js",
|
|
30
|
+
"./server-node": "./server/node.js",
|
|
31
|
+
"./sessions-file": "./sessions/file.js",
|
|
32
|
+
"./sessions-store": "./sessions/store.js",
|
|
33
|
+
"./queue-persistent": "./queue/persistent.js",
|
|
34
|
+
"./captcha-evidence": "./captcha/evidence.js",
|
|
35
|
+
"./hash": "./core/hash.js",
|
|
36
|
+
"./worker": "./runtime/worker.js"
|
|
37
|
+
},
|
|
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
|
+
"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",
|
|
41
|
+
"formatcheck": "node format/check.js",
|
|
42
|
+
"test": "node --test tests/*.test.js",
|
|
43
|
+
"run": "node cli/main.js",
|
|
44
|
+
"pack:check": "npm run check && npm run formatcheck && npm test && npm pack --dry-run",
|
|
45
|
+
"prepublishOnly": "npm run pack:check"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* distribution manifests describe targets without executing a platform command.
|
|
3
|
+
*/
|
|
4
|
+
export function distributionmanifest(options = {}) {
|
|
5
|
+
if (!options.name || !options.version || !options.entry) throw new TypeError("distribution manifest requires name version and entry");
|
|
6
|
+
return {
|
|
7
|
+
name: options.name,
|
|
8
|
+
version: options.version,
|
|
9
|
+
entry: options.entry,
|
|
10
|
+
modes: options.modes ?? ["library", "cli", "binary"],
|
|
11
|
+
targets: options.targets ?? ["node", "container"],
|
|
12
|
+
files: options.files ?? [],
|
|
13
|
+
metadata: options.metadata ?? {}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function binaryplan(manifest, options = {}) {
|
|
18
|
+
return { tool: options.tool ?? "node", command: options.command ?? `node --experimental-sea-config ${options.config ?? "sea.config.json"}`, entry: manifest.entry, targets: options.targets ?? ["linux", "windows", "macos"] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function containerplan(manifest, options = {}) {
|
|
22
|
+
const base = options.base ?? "node:22-alpine";
|
|
23
|
+
const workdir = options.workdir ?? "/app";
|
|
24
|
+
const command = options.command ?? ["node", manifest.entry];
|
|
25
|
+
const lines = [`from ${base}`, `workdir ${workdir}`, "copy package.json package-lock.json ./", "run npm ci --omit=dev", "copy . .", `cmd ${JSON.stringify(command)}`];
|
|
26
|
+
if (options.port) lines.splice(3, 0, `expose ${options.port}`);
|
|
27
|
+
return { base, workdir, command, dockerfile: `${lines.join("\n")}\n` };
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish plans describe registry targets without executing a publish or requiring credentials.
|
|
3
|
+
*/
|
|
4
|
+
export function publishplan(manifest, options = {}) {
|
|
5
|
+
const version = manifest.version;
|
|
6
|
+
const packageName = manifest.name;
|
|
7
|
+
return {
|
|
8
|
+
package: { registry: options.npm ?? "npm", name: packageName, version, command: "npm publish --access public" },
|
|
9
|
+
github: { repository: options.repository ?? "", packages: true, command: "git push --follow-tags" },
|
|
10
|
+
container: { registry: options.ghcr ?? "ghcr.io", image: options.image ?? packageName, version, command: "docker push" },
|
|
11
|
+
cdn: [{ name: "jsdelivr", url: `https://cdn.jsdelivr.net/npm/${packageName}@${version}/index.js` }, { name: "unpkg", url: `https://unpkg.com/${packageName}@${version}/index.js` }, { name: "esm", url: `https://esm.sh/${packageName}@${version}` }]
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registrymanifest(manifest, options = {}) { return { name: manifest.name, version: manifest.version, surfaces: ["library", "cli", "binary", "container", ...(options.surfaces ?? [])], registries: ["npm", "github", "ghcr", "jsdelivr", "unpkg", "esm.sh"] }; }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* persistence adapters keep database and remote state outside the runtime core.
|
|
3
|
+
*/
|
|
4
|
+
export function persistenceadapter(methods) {
|
|
5
|
+
const required = ["savejob", "getjob", "updatejob", "listjobs", "saveevent", "listevents", "savesession", "readsession", "saveartifact", "getartifact", "savechunk", "getchunks"];
|
|
6
|
+
for (const name of required) if (typeof methods?.[name] !== "function") throw new TypeError(`persistence adapter requires ${name}`);
|
|
7
|
+
return methods;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* drizzle persistence accepts a small repository object generated by the caller's schema.
|
|
3
|
+
*/
|
|
4
|
+
import { persistenceadapter } from "./adapter.js";
|
|
5
|
+
|
|
6
|
+
export function drizzlepersistence(repository) {
|
|
7
|
+
const required = ["savejob", "getjob", "updatejob", "listjobs", "saveevent", "listevents", "savesession", "readsession", "saveartifact", "getartifact", "savechunk", "getchunks"];
|
|
8
|
+
for (const name of required) if (typeof repository?.[name] !== "function") throw new TypeError(`drizzle repository requires ${name}`);
|
|
9
|
+
return persistenceadapter(repository);
|
|
10
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory persistence is the local baseline for tests and offline operation.
|
|
3
|
+
*/
|
|
4
|
+
import { persistenceadapter } from "./adapter.js";
|
|
5
|
+
|
|
6
|
+
export function memorypersistence() {
|
|
7
|
+
const jobs = new Map();
|
|
8
|
+
const events = new Map();
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const artifacts = new Map();
|
|
11
|
+
const chunks = new Map();
|
|
12
|
+
return persistenceadapter({
|
|
13
|
+
async savejob(job) { jobs.set(job.id, structuredClone(job)); return jobs.get(job.id); },
|
|
14
|
+
async getjob(id) { return jobs.get(id) ? structuredClone(jobs.get(id)) : null; },
|
|
15
|
+
async updatejob(id, patch) { const current = jobs.get(id); if (!current) return null; const next = { ...current, ...patch, updatedat: Date.now() }; jobs.set(id, next); return structuredClone(next); },
|
|
16
|
+
async listjobs(filter = {}) { return [...jobs.values()].filter((job) => Object.entries(filter).every(([key, value]) => job[key] === value)).map((job) => structuredClone(job)); },
|
|
17
|
+
async saveevent(event) { const list = events.get(event.jobid) ?? []; list.push(structuredClone(event)); events.set(event.jobid, list); return event; },
|
|
18
|
+
async listevents(jobid) { return (events.get(jobid) ?? []).map((event) => structuredClone(event)); },
|
|
19
|
+
async savesession(session) { sessions.set(session.id, structuredClone(session)); return structuredClone(session); },
|
|
20
|
+
async readsession(id) { return sessions.get(id) ? structuredClone(sessions.get(id)) : null; },
|
|
21
|
+
async saveartifact(artifact) { artifacts.set(artifact.key, structuredClone(artifact)); return structuredClone(artifact); },
|
|
22
|
+
async getartifact(key) { return artifacts.get(key) ? structuredClone(artifacts.get(key)) : null; },
|
|
23
|
+
async savechunk(chunk) { chunks.set(chunk.id, structuredClone(chunk)); return structuredClone(chunk); },
|
|
24
|
+
async getchunks(artifactkey) { return [...chunks.values()].filter((chunk) => chunk.artifactkey === artifactkey).sort((left, right) => left.index - right.index).map((chunk) => structuredClone(chunk)); }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migration plans stay neutral so Prisma, Drizzle, MySQL2, Turso, and another SQL driver can map them.
|
|
3
|
+
*/
|
|
4
|
+
export const migrationlist = Object.freeze([
|
|
5
|
+
{ version: 1, name: "baseoperational", tables: ["jobs", "events", "sessions", "artifacts", "chunks"] },
|
|
6
|
+
{ version: 2, name: "persistentqueue", tables: ["queueitems"] },
|
|
7
|
+
{ version: 3, name: "webhookdeliveries", tables: ["webhookdeliveries"] }
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** Returns pending migration statements for a dialect. */
|
|
11
|
+
export function migrationplan(options = {}) { const current = options.current ?? 0; const dialect = options.dialect ?? "mysql"; return migrationlist.filter((migration) => migration.version > current).map((migration) => ({ ...migration, statements: migration.version === 2 ? [`create table if not exists queueitems (id text primary key, status text not null, attempts integer not null, payload json not null, result json, error json, createdat bigint not null, updatedat bigint not null)`] : migration.version === 3 ? [`create table if not exists webhookdeliveries (id text primary key, event text not null, receivedat bigint not null, processedat bigint, payload json not null)`] : [], dialect })); }
|
|
12
|
+
|
|
13
|
+
/** Returns the latest schema version. */
|
|
14
|
+
export function latestmigration() { return migrationlist.at(-1).version; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prisma persistence maps model delegates through an explicit model configuration.
|
|
3
|
+
*/
|
|
4
|
+
import { persistenceadapter } from "./adapter.js";
|
|
5
|
+
|
|
6
|
+
export function prismapersistence(client, options = {}) {
|
|
7
|
+
const models = { jobs: options.jobs ?? client?.job, events: options.events ?? client?.event, sessions: options.sessions ?? client?.session, artifacts: options.artifacts ?? client?.artifact, chunks: options.chunks ?? client?.chunk };
|
|
8
|
+
for (const [name, model] of Object.entries(models)) if (!model) throw new TypeError(`prisma model is missing: ${name}`);
|
|
9
|
+
return persistenceadapter({
|
|
10
|
+
async savejob(job) { return models.jobs.upsert({ where: { id: job.id }, create: job, update: job }); },
|
|
11
|
+
async getjob(id) { return models.jobs.findUnique({ where: { id } }); },
|
|
12
|
+
async updatejob(id, patch) { return models.jobs.update({ where: { id }, data: { ...patch, updatedat: Date.now() } }); },
|
|
13
|
+
async listjobs(filter = {}) { return models.jobs.findMany({ where: filter, orderBy: { createdat: "desc" } }); },
|
|
14
|
+
async saveevent(event) { return models.events.create({ data: event }); },
|
|
15
|
+
async listevents(jobid) { return models.events.findMany({ where: { jobid }, orderBy: { at: "asc" } }); },
|
|
16
|
+
async savesession(session) { return models.sessions.upsert({ where: { id: session.id }, create: session, update: session }); },
|
|
17
|
+
async readsession(id) { return models.sessions.findUnique({ where: { id } }); },
|
|
18
|
+
async saveartifact(artifact) { return models.artifacts.upsert({ where: { key: artifact.key }, create: artifact, update: artifact }); },
|
|
19
|
+
async getartifact(key) { return models.artifacts.findUnique({ where: { key } }); },
|
|
20
|
+
async savechunk(chunk) { return models.chunks.upsert({ where: { id: chunk.id }, create: chunk, update: chunk }); },
|
|
21
|
+
async getchunks(artifactkey) { return models.chunks.findMany({ where: { artifactkey }, orderBy: { index: "asc" } }); }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the schema descriptor is neutral so prisma drizzle mysql2 or another driver can map it.
|
|
3
|
+
*/
|
|
4
|
+
export const schemadefinition = Object.freeze({
|
|
5
|
+
jobs: { id: "text primary key", name: "text", status: "text", priority: "integer", input: "json", outputkey: "text", createdat: "integer", updatedat: "integer" },
|
|
6
|
+
events: { id: "text primary key", jobid: "text", type: "text", at: "integer", data: "json" },
|
|
7
|
+
sessions: { id: "text primary key", version: "integer", agentname: "text", originurl: "text", seed: "text", status: "text", startedat: "integer", finishedat: "integer", events: "json" },
|
|
8
|
+
artifacts: { key: "text primary key", sizebytes: "bigint", sha256: "text", contenttype: "text", createdat: "integer", metadata: "json" },
|
|
9
|
+
chunks: { id: "text primary key", artifactkey: "text", index: "integer", offset: "bigint", sizebytes: "integer", sha256: "text", storagekey: "text" }
|
|
10
|
+
,queueitems: { id: "text primary key", status: "text", attempts: "integer", payload: "json", result: "json", error: "json", createdat: "bigint", updatedat: "bigint" }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function schemasql(options = {}) {
|
|
14
|
+
const dialect = options.dialect ?? "mysql";
|
|
15
|
+
const json = dialect === "postgres" ? "jsonb" : "json";
|
|
16
|
+
const bigint = dialect === "sqlite" ? "integer" : "bigint";
|
|
17
|
+
return [
|
|
18
|
+
`create table if not exists jobs (id text primary key, name text not null, status text not null, priority integer not null, input ${json}, outputkey text, createdat ${bigint} not null, updatedat ${bigint} not null)`,
|
|
19
|
+
`create table if not exists events (id text primary key, jobid text not null, type text not null, at ${bigint} not null, data ${json} not null)`,
|
|
20
|
+
`create table if not exists sessions (id text primary key, version integer not null, agentname text not null, originurl text not null, seed text not null, status text not null, startedat ${bigint} not null, finishedat ${bigint}, events ${json} not null)`,
|
|
21
|
+
`create table if not exists artifacts (key text primary key, sizebytes ${bigint} not null, sha256 text not null, contenttype text not null, createdat ${bigint} not null, metadata ${json} not null)`,
|
|
22
|
+
`create table if not exists chunks (id text primary key, artifactkey text not null, chunkindex integer not null, byteoffset ${bigint} not null, sizebytes integer not null, sha256 text not null, storagekey text not null)`
|
|
23
|
+
,`create table if not exists queueitems (id text primary key, status text not null, attempts integer not null, payload ${json} not null, result ${json}, error ${json}, createdat ${bigint} not null, updatedat ${bigint} not null)`
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function prismaschema() {
|
|
28
|
+
return `model job { id String @id name String status String priority Int input Json? outputkey String? createdat BigInt updatedat BigInt }\nmodel event { id String @id jobid String type String at BigInt data Json }\nmodel session { id String @id version Int agentname String originurl String seed String status String startedat BigInt finishedat BigInt? events Json }\nmodel artifact { key String @id sizebytes BigInt sha256 String contenttype String createdat BigInt metadata Json }\nmodel chunk { id String @id artifactkey String chunkindex Int byteoffset BigInt sizebytes Int sha256 String storagekey String }`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sql persistence keeps query execution injected for mysql2 drizzle or another sql client.
|
|
3
|
+
*/
|
|
4
|
+
import { persistenceadapter } from "./adapter.js";
|
|
5
|
+
|
|
6
|
+
export function sqlpersistence(options = {}) {
|
|
7
|
+
if (typeof options.query !== "function") throw new TypeError("sql persistence requires query");
|
|
8
|
+
const table = options.table ?? { jobs: "jobs", events: "events", sessions: "sessions", artifacts: "artifacts", chunks: "chunks" };
|
|
9
|
+
async function one(statement, values = []) { const result = await options.query(statement, values); return result?.rows?.[0] ?? result?.[0]?.[0] ?? result?.[0] ?? null; }
|
|
10
|
+
async function many(statement, values = []) { const result = await options.query(statement, values); return result?.rows ?? result?.[0] ?? result ?? []; }
|
|
11
|
+
return persistenceadapter({
|
|
12
|
+
async savejob(job) { await options.query(`insert into ${table.jobs} (id,name,status,priority,input,outputkey,createdat,updatedat) values (?,?,?,?,?,?,?,?)`, [job.id, job.name, job.status, job.priority, JSON.stringify(job.input ?? null), job.outputkey ?? null, job.createdat, job.updatedat ?? job.createdat]); return job; },
|
|
13
|
+
async getjob(id) { const row = await one(`select * from ${table.jobs} where id = ?`, [id]); return row ? normalizejob(row) : null; },
|
|
14
|
+
async updatejob(id, patch) { const current = await this.getjob(id); if (!current) return null; const next = { ...current, ...patch, updatedat: Date.now() }; await options.query(`update ${table.jobs} set status = ?, priority = ?, input = ?, outputkey = ?, updatedat = ? where id = ?`, [next.status, next.priority, JSON.stringify(next.input ?? null), next.outputkey ?? null, next.updatedat, id]); return next; },
|
|
15
|
+
async listjobs(filter = {}) { const rows = await many(`select * from ${table.jobs} order by createdat desc`, []); return rows.map(normalizejob).filter((job) => Object.entries(filter).every(([key, value]) => job[key] === value)); },
|
|
16
|
+
async saveevent(event) { await options.query(`insert into ${table.events} (id,jobid,type,at,data) values (?,?,?,?,?)`, [event.id, event.jobid, event.type, event.at, JSON.stringify(event.data ?? {})]); return event; },
|
|
17
|
+
async listevents(jobid) { return (await many(`select * from ${table.events} where jobid = ? order by at asc`, [jobid])).map((row) => ({ ...row, data: parsejson(row.data) })); },
|
|
18
|
+
async savesession(session) { await options.query(`insert into ${table.sessions} (id,version,agentname,originurl,seed,status,startedat,finishedat,events) values (?,?,?,?,?,?,?,?,?)`, [session.id, session.version, session.agentname, session.originurl, session.seed, session.status, session.startedat, session.finishedat ?? null, JSON.stringify(session.events ?? [])]); return session; },
|
|
19
|
+
async readsession(id) { const row = await one(`select * from ${table.sessions} where id = ?`, [id]); return row ? { ...row, events: parsejson(row.events) } : null; },
|
|
20
|
+
async saveartifact(artifact) { await options.query(`insert into ${table.artifacts} (key,sizebytes,sha256,contenttype,createdat,metadata) values (?,?,?,?,?,?)`, [artifact.key, artifact.sizebytes, artifact.sha256, artifact.contenttype, artifact.createdat, JSON.stringify(artifact.metadata ?? {})]); return artifact; },
|
|
21
|
+
async getartifact(key) { const row = await one(`select * from ${table.artifacts} where key = ?`, [key]); return row ? { ...row, metadata: parsejson(row.metadata) } : null; },
|
|
22
|
+
async savechunk(chunk) { await options.query(`insert into ${table.chunks} (id,artifactkey,chunkindex,byteoffset,sizebytes,sha256,storagekey) values (?,?,?,?,?,?,?)`, [chunk.id, chunk.artifactkey, chunk.index, chunk.offset, chunk.sizebytes, chunk.sha256, chunk.storagekey]); return chunk; },
|
|
23
|
+
async getchunks(artifactkey) { return (await many(`select * from ${table.chunks} where artifactkey = ? order by chunkindex asc`, [artifactkey])).map((row) => ({ ...row, index: row.index ?? row.chunkindex, offset: row.offset ?? row.byteoffset })); }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function mysql2persistence(pool, options = {}) { if (typeof pool?.execute !== "function") throw new TypeError("mysql2 pool requires execute"); return sqlpersistence({ ...options, query: async (statement, values) => pool.execute(statement, values) }); }
|
|
28
|
+
|
|
29
|
+
function normalizejob(row) { return { ...row, input: parsejson(row.input) }; }
|
|
30
|
+
function parsejson(value) { if (value == null || typeof value === "object") return value; try { return JSON.parse(value); } catch { return value; } }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* block streaming yields bounded chunks and lets the consumer control backpressure.
|
|
3
|
+
*/
|
|
4
|
+
export async function* blockstream(input, options = {}) {
|
|
5
|
+
const blockbytes = options.blockbytes;
|
|
6
|
+
if (!Number.isInteger(blockbytes) || blockbytes < 1) throw new TypeError("blockbytes must be a positive integer");
|
|
7
|
+
let index = 0;
|
|
8
|
+
let offset = 0;
|
|
9
|
+
let pending = new Uint8Array(0);
|
|
10
|
+
for await (const part of input instanceof Uint8Array ? [input] : input) {
|
|
11
|
+
const data = part instanceof Uint8Array ? part : new TextEncoder().encode(String(part));
|
|
12
|
+
pending = concat(pending, data);
|
|
13
|
+
while (pending.byteLength > blockbytes) { const block = pending.slice(0, blockbytes); pending = pending.slice(blockbytes); yield { index, offset, data: block, final: false }; index += 1; offset += block.byteLength; }
|
|
14
|
+
}
|
|
15
|
+
if (pending.byteLength) yield { index, offset, data: pending, final: true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function concat(left, right) { const output = new Uint8Array(left.byteLength + right.byteLength); output.set(left); output.set(right, left.byteLength); return output; }
|
package/protocol/json.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndjson supports append only records and incremental parsing.
|
|
3
|
+
*/
|
|
4
|
+
import { jsondecode, jsonencode } from "./json.js";
|
|
5
|
+
|
|
6
|
+
export async function* ndjsonencode(values) { for await (const value of values) yield `${jsonencode(value)}\n`; }
|
|
7
|
+
|
|
8
|
+
export async function* ndjsondecode(input) {
|
|
9
|
+
let buffer = "";
|
|
10
|
+
for await (const chunk of input) {
|
|
11
|
+
buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
12
|
+
const lines = buffer.split(/\r?\n/);
|
|
13
|
+
buffer = lines.pop() ?? "";
|
|
14
|
+
for (const line of lines) if (line.trim()) yield jsondecode(line);
|
|
15
|
+
}
|
|
16
|
+
if (buffer.trim()) yield jsondecode(buffer);
|
|
17
|
+
}
|
package/protocol/sse.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sse helpers encode typed events without requiring a web framework.
|
|
3
|
+
*/
|
|
4
|
+
import { jsondecode, jsonencode } from "./json.js";
|
|
5
|
+
|
|
6
|
+
export function sseencode(event) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
if (event.id !== undefined) lines.push(`id: ${event.id}`);
|
|
9
|
+
if (event.event) lines.push(`event: ${event.event}`);
|
|
10
|
+
for (const line of jsonencode(event.data ?? {}).split("\n")) lines.push(`data: ${line}`);
|
|
11
|
+
return `${lines.join("\n")}\n\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ssedecode(chunk) {
|
|
15
|
+
const fields = {};
|
|
16
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
17
|
+
const index = line.indexOf(":");
|
|
18
|
+
if (index < 0) continue;
|
|
19
|
+
fields[line.slice(0, index)] = line.slice(index + 1).trimStart();
|
|
20
|
+
}
|
|
21
|
+
return { id: fields.id, event: fields.event, data: fields.data ? jsondecode(fields.data) : undefined };
|
|
22
|
+
}
|
package/proxy/pool.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy pool chooses healthy least used entries and never rotates blindly.
|
|
3
|
+
*/
|
|
4
|
+
export function proxypool(options = {}) {
|
|
5
|
+
const entries = (options.proxies ?? []).map((proxy) => ({ ...proxy, failures: 0, uses: 0, status: "active", lastused: 0 }));
|
|
6
|
+
const threshold = options.failurethreshold ?? 3;
|
|
7
|
+
const recovery = options.recoverytime ?? 300000;
|
|
8
|
+
function revive() { const now = Date.now(); for (const entry of entries) if (entry.status === "graveyard" && now - entry.failedat >= recovery) { entry.status = "active"; entry.failures = 0; } }
|
|
9
|
+
function choose() { revive(); const available = entries.filter((entry) => entry.status === "active"); if (!available.length) throw new Error("proxy pool has no healthy entries"); const selected = [...available].sort((left, right) => left.uses - right.uses || left.lastused - right.lastused)[0]; selected.uses += 1; selected.lastused = Date.now(); return { ...selected }; }
|
|
10
|
+
function report(id, result = {}) { const entry = entries.find((value) => value.id === id); if (!entry) return null; if (result.ok) { entry.failures = 0; entry.status = "active"; } else { entry.failures += 1; entry.status = entry.failures >= threshold ? "graveyard" : "active"; entry.failedat = Date.now(); } return { ...entry }; }
|
|
11
|
+
return { choose, report, list() { revive(); return entries.map((entry) => ({ ...entry })); } };
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* idempotency keys prevent duplicate delivery from at least once transports.
|
|
3
|
+
*/
|
|
4
|
+
export function idempotency() {
|
|
5
|
+
const results = new Map();
|
|
6
|
+
return {
|
|
7
|
+
has(key) { return results.has(key); },
|
|
8
|
+
get(key) { return results.get(key); },
|
|
9
|
+
set(key, value) { results.set(key, value); return value; },
|
|
10
|
+
delete(key) { results.delete(key); }
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* persistent queue stores job state in a caller selected file.
|
|
3
|
+
* running items return to queued when a process restores after a crash.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
/** Creates a crash recoverable queue with idempotent item identifiers. */
|
|
8
|
+
export function persistentqueue(options = {}) {
|
|
9
|
+
if (!options.path) throw new TypeError("persistent queue requires path");
|
|
10
|
+
const maxattempts = options.maxattempts ?? 3;
|
|
11
|
+
let state = { version: 1, items: [] };
|
|
12
|
+
let loaded = false;
|
|
13
|
+
|
|
14
|
+
/** Reads the queue once and returns running jobs to the waiting state. */
|
|
15
|
+
async function restore() {
|
|
16
|
+
if (loaded) return state;
|
|
17
|
+
try { state = JSON.parse(await readFile(options.path, "utf8")); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
18
|
+
for (const item of state.items ?? []) if (item.status === "running") item.status = "queued";
|
|
19
|
+
state.items ??= [];
|
|
20
|
+
loaded = true;
|
|
21
|
+
await persist();
|
|
22
|
+
return state;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Appends a queued job and persists the new state. */
|
|
26
|
+
async function enqueue(payload, metadata = {}) { await restore(); const id = metadata.id ?? `persistentjob${Date.now()}${state.items.length}`; if (state.items.some((item) => item.id === id)) return state.items.find((item) => item.id === id); const item = { id, payload, status: "queued", attempts: 0, createdat: Date.now(), updatedat: Date.now(), metadata }; state.items.push(item); await persist(); return { ...item }; }
|
|
27
|
+
|
|
28
|
+
/** Claims the first queued job with stable ordering. */
|
|
29
|
+
async function claim() { await restore(); const item = state.items.find((entry) => entry.status === "queued"); if (!item) return null; item.status = "running"; item.attempts += 1; item.updatedat = Date.now(); await persist(); return { ...item }; }
|
|
30
|
+
|
|
31
|
+
/** Commits a successful result. */
|
|
32
|
+
async function complete(id, result) { await restore(); const item = find(id); item.status = "completed"; item.result = result; item.updatedat = Date.now(); await persist(); return { ...item }; }
|
|
33
|
+
|
|
34
|
+
/** Records an error and either retries or closes the item as failed. */
|
|
35
|
+
async function fail(id, error) { await restore(); const item = find(id); item.error = { message: error?.message ?? String(error), code: error?.code }; item.status = item.attempts < maxattempts && error?.retryable !== false ? "queued" : "failed"; item.updatedat = Date.now(); await persist(); return { ...item }; }
|
|
36
|
+
|
|
37
|
+
/** Lists queue items for status and diagnostics. */
|
|
38
|
+
async function list(filter) { await restore(); return state.items.filter((item) => !filter || item.status === filter).map((item) => ({ ...item })); }
|
|
39
|
+
|
|
40
|
+
return { restore, enqueue, claim, complete, fail, list };
|
|
41
|
+
|
|
42
|
+
function find(id) { const item = state.items.find((entry) => entry.id === id); if (!item) throw new Error(`persistent queue item not found: ${id}`); return item; }
|
|
43
|
+
async function persist() { await writeFile(options.path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); }
|
|
44
|
+
}
|
package/queue/queue.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* job queue groups concurrency retry timeout and idempotency at one boundary.
|
|
3
|
+
*/
|
|
4
|
+
import { idempotency } from "./idempotency.js";
|
|
5
|
+
|
|
6
|
+
export function jobqueue(options = {}) {
|
|
7
|
+
const limit = options.concurrency ?? 1;
|
|
8
|
+
const attempts = options.maxattempts ?? 3;
|
|
9
|
+
const retryall = options.retryall ?? false;
|
|
10
|
+
const keys = options.idempotency ?? idempotency();
|
|
11
|
+
const pending = [];
|
|
12
|
+
let active = 0;
|
|
13
|
+
let sequence = 0;
|
|
14
|
+
async function drain() {
|
|
15
|
+
while (active < limit && pending.length) {
|
|
16
|
+
const item = pending.shift();
|
|
17
|
+
active += 1;
|
|
18
|
+
execute(item).then(item.resolve, item.reject).finally(() => { active -= 1; void drain(); });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function execute(item) {
|
|
22
|
+
if (item.key && keys.has(item.key)) return keys.get(item.key);
|
|
23
|
+
let last;
|
|
24
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
25
|
+
try {
|
|
26
|
+
const result = await item.handler({ id: item.id, payload: item.payload, attempt });
|
|
27
|
+
if (item.key) keys.set(item.key, result);
|
|
28
|
+
return result;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
last = error;
|
|
31
|
+
const retryable = retryall || error?.retryable === true || error?.code === "RUNNER_UNAVAILABLE";
|
|
32
|
+
if (!retryable || attempt === attempts) throw error;
|
|
33
|
+
await delay((options.backoff ?? 250) * 2 ** (attempt - 1));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw last;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
add(payload, handler, options = {}) {
|
|
40
|
+
if (typeof handler !== "function") return Promise.reject(new TypeError("queue handler is required"));
|
|
41
|
+
const id = options.id ?? `queuejob${sequence++}`;
|
|
42
|
+
return new Promise((resolve, reject) => { pending.push({ id, payload, handler, key: options.key, resolve, reject }); void drain(); });
|
|
43
|
+
},
|
|
44
|
+
size() { return pending.length; },
|
|
45
|
+
active() { return active; },
|
|
46
|
+
async idle() { while (pending.length || active) await delay(10); }
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
|
package/queue/saga.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* saga executes compensations in reverse order when a multi step operation fails.
|
|
3
|
+
*/
|
|
4
|
+
export async function saga(steps, context = {}) {
|
|
5
|
+
const completed = [];
|
|
6
|
+
try {
|
|
7
|
+
for (const step of steps) { const value = await step.run(context); completed.push({ step, value }); }
|
|
8
|
+
return completed.map((item) => item.value);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
for (const item of completed.reverse()) if (typeof item.step.compensate === "function") await item.step.compensate(context, item.value);
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|