@tetherto/mdk-skill 0.0.0 → 0.12.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 +202 -0
- package/README.md +133 -0
- package/dist/skills/mdk/SKILL.md +114 -0
- package/dist/skills/mdk/references/architecture.md +89 -0
- package/dist/skills/mdk/references/glossary.md +27 -0
- package/dist/skills/mdk/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk/references/package-index.md +53 -0
- package/dist/skills/mdk/references/protocol.md +95 -0
- package/dist/skills/mdk-create-app/SKILL.md +231 -0
- package/dist/skills/mdk-deployment/SKILL.md +161 -0
- package/dist/skills/mdk-gateway-plugin/SKILL.md +192 -0
- package/dist/skills/mdk-gateway-plugin/references/controller-patterns.md +99 -0
- package/dist/skills/mdk-gateway-plugin/references/plugin-authoring.md +121 -0
- package/dist/skills/mdk-publish-security/SKILL.md +177 -0
- package/dist/skills/mdk-publish-security/references/vuln-classes.md +151 -0
- package/dist/skills/mdk-publish-security/scripts/scan-packages.mjs +544 -0
- package/dist/skills/mdk-site-sizing/SKILL.md +160 -0
- package/dist/skills/mdk-site-sizing/eval/held-out.json +104 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers-mixed.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-10devices-1workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-15devices-2workers.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-200devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-500devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-50devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/references/envelope.json +284 -0
- package/dist/skills/mdk-site-sizing/references/envelope.md +42 -0
- package/dist/skills/mdk-site-sizing/scripts/compile-envelope.mjs +45 -0
- package/dist/skills/mdk-site-sizing/scripts/eval-held-out.mjs +50 -0
- package/dist/skills/mdk-site-sizing/scripts/lib.mjs +591 -0
- package/dist/skills/mdk-site-sizing/scripts/parse-site.mjs +25 -0
- package/dist/skills/mdk-site-sizing/scripts/size-site.mjs +49 -0
- package/dist/skills/mdk-ui-component/SKILL.md +207 -0
- package/dist/skills/mdk-ui-component/references/page-recipe.md +78 -0
- package/dist/skills/mdk-ui-component/references/ui-registry.json +1 -0
- package/dist/skills/mdk-worker-plugin/SKILL.md +198 -0
- package/dist/skills/mdk-worker-plugin/assets/mdk-contract.template.json +45 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/README.md +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mdk-contract.json +26 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mock/server.js +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/package.json +23 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/smoke.config.js +41 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/client.js +47 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/reboot.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/set-power-mode.js +10 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/hashrate-rt.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power-mode.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/temperature.js +5 -0
- package/dist/skills/mdk-worker-plugin/references/contract-authoring.md +135 -0
- package/dist/skills/mdk-worker-plugin/references/device-families.md +96 -0
- package/dist/skills/mdk-worker-plugin/references/local-testing.md +134 -0
- package/dist/skills/mdk-worker-plugin/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk-worker-plugin/references/worker-base-api.md +152 -0
- package/dist/skills/mdk-worker-plugin/scripts/validate-contract.mjs +129 -0
- package/dist/skills/mdk-worker-plugin/scripts/worker-smoke.mjs +256 -0
- package/package.json +47 -1
- package/src/assemble.mjs +11 -0
- package/src/index.d.ts +57 -0
- package/src/index.mjs +254 -0
- package/src/install.mjs +57 -0
- package/src/sources.map.json +34 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Worker base API — `@tetherto/mdk-worker`
|
|
2
|
+
|
|
3
|
+
Load this when hosting a plugin, debugging envelope dispatch, or deciding what
|
|
4
|
+
belongs in the plugin vs the caller. Two Worker Plugin shapes exist side by
|
|
5
|
+
side: the module-exporting shape below, hosted on `WorkerRuntime`, and the
|
|
6
|
+
directory-loaded shape (see [Directory-loaded plugins](#directory-loaded-plugins-workerruntimev2)
|
|
7
|
+
below), hosted on `WorkerRuntimeV2` — the shape `mdk create worker` scaffolds
|
|
8
|
+
today. Source: [`backend/core/mdk-worker/lib/worker-runtime.js`](../../../../../../backend/core/mdk-worker/lib/worker-runtime.js),
|
|
9
|
+
[`lib/worker-runtime-v2.js`](../../../../../../backend/core/mdk-worker/lib/worker-runtime-v2.js), and
|
|
10
|
+
[`lib/plugin-loader.js`](../../../../../../backend/core/mdk-worker/lib/plugin-loader.js);
|
|
11
|
+
package entry [`backend/core/mdk-worker/index.js`](../../../../../../backend/core/mdk-worker/index.js) exports
|
|
12
|
+
`{ WorkerRuntime, WorkerRuntimeV2, loadPlugin, loadContract, createInstance, createModuleContext }`.
|
|
13
|
+
|
|
14
|
+
## The plugin contract (`loadPlugin`)
|
|
15
|
+
|
|
16
|
+
A Worker Plugin is `{ contract, dir, connect, disconnect? }`:
|
|
17
|
+
|
|
18
|
+
- `contract` — the parsed `mdk-contract.json`.
|
|
19
|
+
- `dir` — `__dirname` of the plugin; handler paths resolve against it.
|
|
20
|
+
- `connect(config, { deviceId }) => device` — build (and probe) one device
|
|
21
|
+
client. Throwing here holds the device `offline` without failing the boot.
|
|
22
|
+
- `disconnect(device, { deviceId })` — optional; close sockets etc.
|
|
23
|
+
|
|
24
|
+
`loadPlugin` **eagerly requires every handler** declared under
|
|
25
|
+
`capabilities.telemetry` and `capabilities.commands` — a missing file,
|
|
26
|
+
non-function export, missing `handler` field, or duplicate name aborts with
|
|
27
|
+
`ERR_PLUGIN_HANDLER_NOT_FOUND` / `ERR_PLUGIN_HANDLER_NOT_FUNCTION` /
|
|
28
|
+
`ERR_PLUGIN_HANDLER_MISSING` / `ERR_PLUGIN_DUPLICATE_NAME`. Nothing loads
|
|
29
|
+
lazily at request time. It returns
|
|
30
|
+
`{ contract, publishedContract, handlers, connect, disconnect }` where
|
|
31
|
+
`publishedContract` has all `handler` paths stripped (that copy is what
|
|
32
|
+
`capability.response` sends).
|
|
33
|
+
|
|
34
|
+
## Handler signature
|
|
35
|
+
|
|
36
|
+
Telemetry and command handlers are identical in shape:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
module.exports = async (ctx, params) => result
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`ctx` is frozen per device: `{ deviceId, device, config, services }`.
|
|
43
|
+
`device` is whatever `connect()` returned; `services` is `null` unless the
|
|
44
|
+
caller injected worker-infra services. Handlers never see envelopes or
|
|
45
|
+
transport — the runtime wraps their return value (or thrown error) into the
|
|
46
|
+
response envelope.
|
|
47
|
+
|
|
48
|
+
## `new WorkerRuntime(plugin, opts)`
|
|
49
|
+
|
|
50
|
+
| Option | Required | Meaning |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `workerId` | yes | Identity string; `ERR_WORKER_ID_REQUIRED` otherwise |
|
|
53
|
+
| `devices` | yes* | `[{ deviceId, config? }]`; duplicates/missing ids throw. *`allowEmptyDevices: true` permits a device-less provisioning-first boot |
|
|
54
|
+
| `kernelTopic` | no | Hex string or Buffer; when set, the runtime announces on this DHT topic |
|
|
55
|
+
| `store` | no | Persistent store (Hyperbee-style) for the DHT/RPC seeds → stable public key across restarts; without it keys are random per boot |
|
|
56
|
+
| `storeDir` | no | Directory for file-backed DHT/RPC seeds (same stability as `store`, for hosts that do not own a Hyperbee). Ignored when `store` is set |
|
|
57
|
+
| `services` | no | Worker-infra services (logs, settings, stats, comments, provisioning…). Served as built-ins ahead of plugin handlers and exposed to handlers as `ctx.services` |
|
|
58
|
+
| `bootstrap` | no | Custom DHT bootstrap nodes (hermetic tests use `hyperdht/testnet`) |
|
|
59
|
+
|
|
60
|
+
One runtime hosts N same-type devices behind one HRPC channel. The device
|
|
61
|
+
list is fixed at construction.
|
|
62
|
+
|
|
63
|
+
## Lifecycle
|
|
64
|
+
|
|
65
|
+
- `await start()` — opens device contexts (`connect` per device; failures →
|
|
66
|
+
`offline`), starts the HRPC server responding on the `'mdk'` method, and —
|
|
67
|
+
if `kernelTopic` is set — joins the topic in server mode, writing its public
|
|
68
|
+
key to each incoming connection and re-announcing every 30 s. Returns
|
|
69
|
+
`{ publicKey }`.
|
|
70
|
+
- `await stop()` — tears down swarm/RPC/DHT and calls `disconnect` per online
|
|
71
|
+
device.
|
|
72
|
+
- `getPublicKey()` — HRPC public key (null before `start`).
|
|
73
|
+
- `getDeviceContext(deviceId)` — the frozen ctx of an *online* device, for the
|
|
74
|
+
process that owns the runtime (e.g. a sampler loop); null while offline. On
|
|
75
|
+
`WorkerRuntimeV2` this returns `{ deviceId, config, services }` with no
|
|
76
|
+
`device` key — see [below](#directory-loaded-plugins-workerruntimev2).
|
|
77
|
+
- `await handleRequest(envelope)` — the entire protocol surface; usable
|
|
78
|
+
directly in-process for tests (no `start()` needed for identity/capability;
|
|
79
|
+
device-touching actions need contexts opened by `start()`).
|
|
80
|
+
|
|
81
|
+
## Directory-loaded plugins (`WorkerRuntimeV2`)
|
|
82
|
+
|
|
83
|
+
A directory-loaded Worker Plugin exports no module of its own: the package
|
|
84
|
+
ships only `mdk-contract.json` at its root plus the handler files it declares
|
|
85
|
+
under `src/`. There is no `plugin/index.js` and no `connect()`/`disconnect()`.
|
|
86
|
+
`new WorkerRuntimeV2(dir, opts)` takes the package's directory in place of a
|
|
87
|
+
plugin object — it calls `loadContract(dir)` internally to resolve the
|
|
88
|
+
contract and every handler path, then builds one plugin instance per
|
|
89
|
+
configured device via `createInstance({ dir, entries, device })` when each
|
|
90
|
+
device's context opens.
|
|
91
|
+
|
|
92
|
+
- **Handler signature**: `async (params) => result` — no `ctx` argument.
|
|
93
|
+
Handlers instead `require('@tetherto/mdk-worker/device')`, an ambient module
|
|
94
|
+
giving `{ id, opts, env, config, logger }` for the device the handler is
|
|
95
|
+
currently bound to. `opts` is that device's own connection config (the
|
|
96
|
+
`config` entry from `opts.devices` at construction); `env` and `config` are
|
|
97
|
+
the plugin-wide blocks passed as `opts.env`/`opts.config` to the runtime.
|
|
98
|
+
- **Isolation**: `WorkerRuntimeV2` loads the package's files into a private
|
|
99
|
+
module registry per device, so a client module that binds to the ambient
|
|
100
|
+
device at load time already gets one instance per device with no explicit
|
|
101
|
+
per-device construction, and `require('../client')` inside one device's
|
|
102
|
+
handlers always resolves to that device's own instance.
|
|
103
|
+
- **No boot-time probe, no offline state**: every declared device reports
|
|
104
|
+
`online` as soon as `runtime.start()` returns. An unreachable device
|
|
105
|
+
surfaces as an error inside the telemetry payload for the channel that
|
|
106
|
+
touched the network (or `status: 'FAILED'` for a command), not as
|
|
107
|
+
`ERR_DEVICE_UNAVAILABLE`.
|
|
108
|
+
- **No `disconnect()`**: whatever a handler module opens at load time (a
|
|
109
|
+
socket, a file handle) lives until the process exits; nothing closes it
|
|
110
|
+
automatically.
|
|
111
|
+
- **Builtin `health` channel**: every directory-loaded plugin answers
|
|
112
|
+
`telemetry.pull { query: { type: 'health' } }` automatically — `{ status,
|
|
113
|
+
id, workerId }` for the addressed device (never `env`, `opts` or `config`)
|
|
114
|
+
— unless the contract itself declares a `health` channel, which always wins.
|
|
115
|
+
|
|
116
|
+
`loadContract(dir)` and `createInstance({ dir, entries, device })` are also
|
|
117
|
+
usable directly, without a runtime, for contract-level tests — see
|
|
118
|
+
[`local-testing.md`](./local-testing.md).
|
|
119
|
+
|
|
120
|
+
## Envelope dispatch (`handleRequest`)
|
|
121
|
+
|
|
122
|
+
| Action | Behaviour |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `identity.request` | `{ workerId, devices: [{ deviceId }] }` |
|
|
125
|
+
| `capability.request` | `{ contract: publishedContract }` (built-in commands merged in when services are present) |
|
|
126
|
+
| `telemetry.pull` | `query.type` routing — see [`../../mdk/references/protocol.md`](../../mdk/references/protocol.md). `metrics` runs every telemetry handler; a channel name runs one; `list` returns device statuses; service built-ins (`logs`, `settings`, `stats`, …) take precedence over plugin channels |
|
|
127
|
+
| `command.request` | Built-ins first (provisioning/store commands work for offline devices); then device must exist and be online; unknown command → `ERR_UNKNOWN_COMMAND`; handler success → `{ commandId, status: 'SUCCESS', result }`, throw → `{ status: 'FAILED', error }` |
|
|
128
|
+
| `health.ping` | `{ status: 'OK' }` |
|
|
129
|
+
| `state.pull` | `{ state: { <deviceId>: { status } }, deviceCount, workerId }` |
|
|
130
|
+
| `write.calls.request` | Only when `services.actions` is injected |
|
|
131
|
+
| anything else | `ERR_UNKNOWN_ACTION` |
|
|
132
|
+
|
|
133
|
+
Failure isolation: a handler that throws poisons only that field
|
|
134
|
+
(`metrics.<name> = { error }`) or that command — never the Worker. An offline
|
|
135
|
+
device answers `ERR_DEVICE_UNAVAILABLE`; a wrong id `ERR_DEVICE_NOT_FOUND`.
|
|
136
|
+
|
|
137
|
+
## Param normalization
|
|
138
|
+
|
|
139
|
+
Legacy write paths dispatch positionally (`{ value: x }` or
|
|
140
|
+
`{ args: [...] }`). The runtime maps those onto your contract-declared param
|
|
141
|
+
names before your handler runs, so handlers only ever see named params —
|
|
142
|
+
provided the contract declares params accurately.
|
|
143
|
+
|
|
144
|
+
## What the runtime does NOT do
|
|
145
|
+
|
|
146
|
+
- It does not validate contracts against the JSON Schema (that's
|
|
147
|
+
[`../scripts/validate-contract.mjs`](../scripts/validate-contract.mjs) and the catalogue lint).
|
|
148
|
+
- It does not enforce param types or `min`/`max` bounds — the **Kernel's**
|
|
149
|
+
dispatcher does, and only for declared bounds. In-process callers of
|
|
150
|
+
`handleRequest` bypass that check (the smoke harness replicates it).
|
|
151
|
+
- It has no HTTP mode and no CLI — a caller process constructs it (see
|
|
152
|
+
the minimal caller in [`local-testing.md`](./local-testing.md#3-standalone-protocol-check-workerruntimev2-real-envelopes-still-no-kernel)).
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validate a worker's mdk-contract.json against ../references/mdk-contract.schema.json.
|
|
3
|
+
//
|
|
4
|
+
// node validate-contract.mjs <path/to/mdk-contract.json> # exit 0/1
|
|
5
|
+
// node validate-contract.mjs --schema-only <path/to/mdk-contract.json> # skip semantic checks
|
|
6
|
+
//
|
|
7
|
+
// Zero-dependency: implements the JSON Schema subset the schema uses, plus
|
|
8
|
+
// semantic checks the schema cannot express — handler files exist, names are
|
|
9
|
+
// unique, and number params declare min/max (the Kernel enforces
|
|
10
|
+
// ERR_PARAM_RANGE only for declared bounds).
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const SCHEMA_PATH = path.resolve(SCRIPT_DIR, '..', 'references', 'mdk-contract.schema.json')
|
|
18
|
+
|
|
19
|
+
function jsonType (v) {
|
|
20
|
+
if (Array.isArray(v)) return 'array'
|
|
21
|
+
if (v === null) return 'null'
|
|
22
|
+
return typeof v
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function matchesType (expected, v) {
|
|
26
|
+
if (expected === 'integer') return typeof v === 'number' && Number.isInteger(v)
|
|
27
|
+
if (expected === 'array') return Array.isArray(v)
|
|
28
|
+
if (expected === 'object') return jsonType(v) === 'object'
|
|
29
|
+
return typeof v === expected
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateNode (schema, value, at, errors) {
|
|
33
|
+
if (schema.type && !matchesType(schema.type, value)) {
|
|
34
|
+
errors.push(`ERR_CONTRACT_TYPE: ${at} expected ${schema.type}, got ${jsonType(value)}`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
38
|
+
errors.push(`ERR_CONTRACT_ENUM: ${at} is ${JSON.stringify(value)}, allowed: ${schema.enum.join(' | ')}`)
|
|
39
|
+
}
|
|
40
|
+
if (jsonType(value) === 'object') {
|
|
41
|
+
for (const key of schema.required || []) {
|
|
42
|
+
if (!(key in value)) errors.push(`ERR_CONTRACT_REQUIRED: ${at} missing required property '${key}'`)
|
|
43
|
+
}
|
|
44
|
+
const props = schema.properties || {}
|
|
45
|
+
for (const [key, v] of Object.entries(value)) {
|
|
46
|
+
if (props[key]) {
|
|
47
|
+
validateNode(props[key], v, `${at}/${key}`, errors)
|
|
48
|
+
} else if (schema.additionalProperties === false) {
|
|
49
|
+
errors.push(`ERR_CONTRACT_UNEXPECTED: ${at} has unexpected property '${key}'`)
|
|
50
|
+
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
51
|
+
validateNode(schema.additionalProperties, v, `${at}/${key}`, errors)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value) && schema.items) {
|
|
56
|
+
value.forEach((v, i) => validateNode(schema.items, v, `${at}/${i}`, errors))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function semanticChecks (contract, contractDir, errors) {
|
|
61
|
+
const caps = contract.capabilities
|
|
62
|
+
if (jsonType(caps) !== 'object') return
|
|
63
|
+
|
|
64
|
+
for (const section of ['telemetry', 'commands']) {
|
|
65
|
+
const entries = Array.isArray(caps[section]) ? caps[section] : []
|
|
66
|
+
const seen = new Set()
|
|
67
|
+
entries.forEach((entry, i) => {
|
|
68
|
+
const at = `/capabilities/${section}/${i}`
|
|
69
|
+
if (jsonType(entry) !== 'object') return
|
|
70
|
+
if (entry.name) {
|
|
71
|
+
if (seen.has(entry.name)) errors.push(`ERR_CONTRACT_DUPLICATE_NAME: ${at} '${entry.name}' already declared in ${section}`)
|
|
72
|
+
seen.add(entry.name)
|
|
73
|
+
}
|
|
74
|
+
if (typeof entry.handler === 'string' && entry.handler) {
|
|
75
|
+
const file = path.resolve(contractDir, entry.handler)
|
|
76
|
+
if (!fs.existsSync(file)) errors.push(`ERR_CONTRACT_HANDLER_NOT_FOUND: ${at} handler '${entry.handler}' does not exist (resolved: ${file})`)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const commands = Array.isArray(caps.commands) ? caps.commands : []
|
|
82
|
+
commands.forEach((cmd, i) => {
|
|
83
|
+
if (jsonType(cmd) !== 'object' || !Array.isArray(cmd.params)) return
|
|
84
|
+
cmd.params.forEach((p, j) => {
|
|
85
|
+
if (jsonType(p) !== 'object' || p.type !== 'number') return
|
|
86
|
+
const at = `/capabilities/commands/${i}/params/${j}`
|
|
87
|
+
if (typeof p.min !== 'number' || typeof p.max !== 'number') {
|
|
88
|
+
errors.push(`ERR_CONTRACT_PARAM_BOUNDS_MISSING: ${at} ('${cmd.name}'.'${p.name}') — number params must declare min and max so the Kernel can enforce ERR_PARAM_RANGE`)
|
|
89
|
+
} else if (p.min > p.max) {
|
|
90
|
+
errors.push(`ERR_CONTRACT_PARAM_BOUNDS_INVALID: ${at} min ${p.min} > max ${p.max}`)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const args = process.argv.slice(2)
|
|
97
|
+
const schemaOnly = args.includes('--schema-only')
|
|
98
|
+
const target = args.find((a) => !a.startsWith('--'))
|
|
99
|
+
if (!target) {
|
|
100
|
+
console.error('usage: node validate-contract.mjs [--schema-only] <path/to/mdk-contract.json>')
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contractPath = path.resolve(process.cwd(), target)
|
|
105
|
+
if (!fs.existsSync(contractPath)) {
|
|
106
|
+
console.error(`ERR_CONTRACT_FILE_NOT_FOUND: ${contractPath}`)
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let contract
|
|
111
|
+
try {
|
|
112
|
+
contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'))
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`ERR_CONTRACT_PARSE: ${contractPath}: ${err.message}`)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))
|
|
119
|
+
const errors = []
|
|
120
|
+
validateNode(schema, contract, '', errors)
|
|
121
|
+
if (!schemaOnly) semanticChecks(contract, path.dirname(contractPath), errors)
|
|
122
|
+
|
|
123
|
+
if (errors.length) {
|
|
124
|
+
console.error(`FAIL: ${target} — ${errors.length} problem(s):`)
|
|
125
|
+
for (const err of errors) console.error(` ${err}`)
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`OK: ${target} conforms to mdk-contract.schema.json${schemaOnly ? '' : ' (+ semantic checks)'}`)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// In-process smoke harness for a Worker Plugin — no Kernel, no DHT.
|
|
3
|
+
//
|
|
4
|
+
// node worker-smoke.mjs <worker-dir> [--config <json | path>]
|
|
5
|
+
//
|
|
6
|
+
// Device opts come from --config (inline JSON or a .json file) or from
|
|
7
|
+
// <worker-dir>/smoke.config.js exporting { setup } -> { config, commands?,
|
|
8
|
+
// env?, pluginConfig?, teardown? }. Asserts: the contract loads, every
|
|
9
|
+
// declared telemetry handler returns the contract-declared type, bounded
|
|
10
|
+
// number params reject out-of-range values, and each smoke.config command
|
|
11
|
+
// executes. Uses WorkerRuntimeV2's loadContract + createInstance path
|
|
12
|
+
// (directory-loaded plugin, plain (params) handlers, ambient device context).
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { createRequire } from 'node:module'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
|
|
21
|
+
const TYPE_OK = {
|
|
22
|
+
number: (v) => typeof v === 'number' && Number.isFinite(v),
|
|
23
|
+
string: (v) => typeof v === 'string',
|
|
24
|
+
boolean: (v) => typeof v === 'boolean',
|
|
25
|
+
array: (v) => Array.isArray(v),
|
|
26
|
+
object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Replica of the Kernel dispatcher's pre-dispatch validation
|
|
30
|
+
// (CommandDispatcher._validateCommand) — bounds are enforced Kernel-side,
|
|
31
|
+
// so an in-process harness must check them itself. Kept in sync manually.
|
|
32
|
+
function validateCommand (capabilities, command, params) {
|
|
33
|
+
if (!capabilities.commands) return { valid: true }
|
|
34
|
+
const commands = capabilities.commands
|
|
35
|
+
const cmdDef = Array.isArray(commands)
|
|
36
|
+
? commands.find((c) => c.name === command)
|
|
37
|
+
: commands[command]
|
|
38
|
+
|
|
39
|
+
if (!cmdDef) return { valid: false, error: `ERR_COMMAND_NOT_IN_CAPABILITIES: ${command}` }
|
|
40
|
+
|
|
41
|
+
if (cmdDef.params && Array.isArray(cmdDef.params) && params) {
|
|
42
|
+
for (const paramDef of cmdDef.params) {
|
|
43
|
+
const value = params[paramDef.name]
|
|
44
|
+
if (value === undefined) continue
|
|
45
|
+
if (paramDef.type === 'number' && typeof value !== 'number') {
|
|
46
|
+
return { valid: false, error: `ERR_PARAM_TYPE: ${paramDef.name} must be number` }
|
|
47
|
+
}
|
|
48
|
+
if (paramDef.type === 'string' && typeof value !== 'string') {
|
|
49
|
+
return { valid: false, error: `ERR_PARAM_TYPE: ${paramDef.name} must be string` }
|
|
50
|
+
}
|
|
51
|
+
if (paramDef.type === 'number') {
|
|
52
|
+
if (paramDef.min !== undefined && value < paramDef.min) {
|
|
53
|
+
return { valid: false, error: `ERR_PARAM_RANGE: ${paramDef.name} below min ${paramDef.min}` }
|
|
54
|
+
}
|
|
55
|
+
if (paramDef.max !== undefined && value > paramDef.max) {
|
|
56
|
+
return { valid: false, error: `ERR_PARAM_RANGE: ${paramDef.name} above max ${paramDef.max}` }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { valid: true }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Monorepo fallback for a checkout without node_modules links: walk up from
|
|
65
|
+
// this script's own dir (never the worker dir, which may be untrusted) and stop
|
|
66
|
+
// at the repo root (the first dir holding .git). The candidate must really be
|
|
67
|
+
// @tetherto/mdk-worker.
|
|
68
|
+
function findMonorepoWorkerBase () {
|
|
69
|
+
let dir = SCRIPT_DIR
|
|
70
|
+
for (;;) {
|
|
71
|
+
const candidate = path.join(dir, 'backend', 'core', 'mdk-worker')
|
|
72
|
+
if (fs.existsSync(path.join(candidate, 'lib', 'contract-loader.js'))) {
|
|
73
|
+
try {
|
|
74
|
+
if (JSON.parse(fs.readFileSync(path.join(candidate, 'package.json'), 'utf8')).name === '@tetherto/mdk-worker') return candidate
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
const parent = path.dirname(dir)
|
|
78
|
+
if (parent === dir || fs.existsSync(path.join(dir, '.git'))) return null
|
|
79
|
+
dir = parent
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseArgs (argv) {
|
|
84
|
+
const args = { workerDir: null, config: null }
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
if (argv[i] === '--config') args.config = argv[++i]
|
|
87
|
+
else if (!args.workerDir) args.workerDir = argv[i]
|
|
88
|
+
}
|
|
89
|
+
return args
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { workerDir: workerDirArg, config: configArg } = parseArgs(process.argv.slice(2))
|
|
93
|
+
if (!workerDirArg) {
|
|
94
|
+
console.error('usage: node worker-smoke.mjs <worker-dir> [--config <json | path>]')
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const workerDir = path.resolve(process.cwd(), workerDirArg)
|
|
99
|
+
const pluginDir = fs.existsSync(path.join(workerDir, 'plugin', 'mdk-contract.json'))
|
|
100
|
+
? path.join(workerDir, 'plugin')
|
|
101
|
+
: workerDir
|
|
102
|
+
if (!fs.existsSync(path.join(pluginDir, 'mdk-contract.json'))) {
|
|
103
|
+
console.error(`ERR_SMOKE_NO_PLUGIN: no mdk-contract.json under ${workerDir}`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const requireFromWorker = createRequire(path.join(workerDir, 'noop.js'))
|
|
108
|
+
const requireFromScript = createRequire(path.join(SCRIPT_DIR, 'noop.js'))
|
|
109
|
+
|
|
110
|
+
let loadContract
|
|
111
|
+
let createInstance
|
|
112
|
+
// Resolution order: explicit MDK_WORKER_BASE, then the worker's declared
|
|
113
|
+
// @tetherto/mdk-worker dependency, then a monorepo checkout around this script.
|
|
114
|
+
function useBase (base) {
|
|
115
|
+
loadContract = requireFromScript(path.join(base, 'lib', 'contract-loader.js')).loadContract
|
|
116
|
+
createInstance = requireFromScript(path.join(base, 'lib', 'instance-loader.js')).createInstance
|
|
117
|
+
}
|
|
118
|
+
if (process.env.MDK_WORKER_BASE) {
|
|
119
|
+
useBase(path.resolve(process.env.MDK_WORKER_BASE))
|
|
120
|
+
} else {
|
|
121
|
+
let mdkWorker = null
|
|
122
|
+
try {
|
|
123
|
+
mdkWorker = requireFromWorker('@tetherto/mdk-worker')
|
|
124
|
+
} catch {}
|
|
125
|
+
if (mdkWorker) {
|
|
126
|
+
loadContract = mdkWorker.loadContract
|
|
127
|
+
createInstance = mdkWorker.createInstance
|
|
128
|
+
} else {
|
|
129
|
+
const base = findMonorepoWorkerBase()
|
|
130
|
+
if (!base) {
|
|
131
|
+
console.error('ERR_SMOKE_NO_WORKER_BASE: cannot resolve @tetherto/mdk-worker from the worker dir (run npm install there, or set MDK_WORKER_BASE)')
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
useBase(base)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const failures = []
|
|
139
|
+
let checks = 0
|
|
140
|
+
function check (ok, label, detail) {
|
|
141
|
+
checks++
|
|
142
|
+
if (ok) {
|
|
143
|
+
console.log(` ok ${label}`)
|
|
144
|
+
} else {
|
|
145
|
+
failures.push(`${label}${detail ? ` — ${detail}` : ''}`)
|
|
146
|
+
console.error(` FAIL ${label}${detail ? ` — ${detail}` : ''}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resolveSpec () {
|
|
151
|
+
if (configArg) {
|
|
152
|
+
if (configArg.trim().startsWith('{')) return { config: JSON.parse(configArg) }
|
|
153
|
+
const p = path.resolve(process.cwd(), configArg)
|
|
154
|
+
if (p.endsWith('.json')) return { config: JSON.parse(fs.readFileSync(p, 'utf8')) }
|
|
155
|
+
const mod = requireFromWorker(p)
|
|
156
|
+
return mod.setup ? mod.setup() : { config: mod }
|
|
157
|
+
}
|
|
158
|
+
const smokeConfig = path.join(workerDir, 'smoke.config.js')
|
|
159
|
+
if (!fs.existsSync(smokeConfig)) {
|
|
160
|
+
console.error(`ERR_SMOKE_NO_CONFIG: no --config given and no ${smokeConfig}`)
|
|
161
|
+
process.exit(1)
|
|
162
|
+
}
|
|
163
|
+
return requireFromWorker(smokeConfig).setup()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main () {
|
|
167
|
+
const { contract, entries, dir } = loadContract(pluginDir)
|
|
168
|
+
const declaredTel = (contract.capabilities.telemetry || []).length
|
|
169
|
+
const declaredCmd = (contract.capabilities.commands || []).length
|
|
170
|
+
console.log(`smoke: ${contract.metadata.brand} (${contract.metadata.deviceFamily}) — ${declaredTel} telemetry, ${declaredCmd} commands`)
|
|
171
|
+
check(true, 'contract loads (loadContract: mdk-contract.json + every handler path)')
|
|
172
|
+
|
|
173
|
+
const spec = await resolveSpec()
|
|
174
|
+
const deviceId = 'smoke-0'
|
|
175
|
+
|
|
176
|
+
let instance = null
|
|
177
|
+
try {
|
|
178
|
+
instance = createInstance({
|
|
179
|
+
dir,
|
|
180
|
+
entries,
|
|
181
|
+
device: {
|
|
182
|
+
id: deviceId,
|
|
183
|
+
opts: Object.freeze({ ...(spec.config || {}) }),
|
|
184
|
+
env: Object.freeze({ ...(spec.env || {}) }),
|
|
185
|
+
config: Object.freeze({ ...(spec.pluginConfig || {}) }),
|
|
186
|
+
workerId: 'smoke',
|
|
187
|
+
logger: () => {}
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
check(true, 'createInstance binds handlers for one device')
|
|
191
|
+
} catch (err) {
|
|
192
|
+
check(false, 'createInstance binds handlers for one device', err.message)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (instance) {
|
|
196
|
+
for (const entry of contract.capabilities.telemetry || []) {
|
|
197
|
+
const typeOk = TYPE_OK[entry.type] || ((v) => v !== undefined)
|
|
198
|
+
try {
|
|
199
|
+
const value = await instance.telemetry.get(entry.name)({})
|
|
200
|
+
check(value !== undefined && typeOk(value),
|
|
201
|
+
`telemetry '${entry.name}' returns a ${entry.type}`,
|
|
202
|
+
value === undefined ? 'returned undefined' : `returned ${JSON.stringify(value)}`)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
check(false, `telemetry '${entry.name}' returns a ${entry.type}`, err.message)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const cmd of contract.capabilities.commands || []) {
|
|
209
|
+
for (const p of cmd.params || []) {
|
|
210
|
+
if (p.type !== 'number' || p.min === undefined || p.max === undefined) continue
|
|
211
|
+
const below = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.min - 1 })
|
|
212
|
+
check(!below.valid && /ERR_PARAM_RANGE/.test(below.error || ''),
|
|
213
|
+
`command '${cmd.name}' rejects ${p.name}=${p.min - 1} (below min)`, below.error || 'accepted')
|
|
214
|
+
const above = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.max + 1 })
|
|
215
|
+
check(!above.valid && /ERR_PARAM_RANGE/.test(above.error || ''),
|
|
216
|
+
`command '${cmd.name}' rejects ${p.name}=${p.max + 1} (above max)`, above.error || 'accepted')
|
|
217
|
+
const atBounds = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.min }).valid &&
|
|
218
|
+
validateCommand(contract.capabilities, cmd.name, { [p.name]: p.max }).valid
|
|
219
|
+
check(atBounds, `command '${cmd.name}' accepts ${p.name} at [${p.min}, ${p.max}]`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const [name, params] of Object.entries(spec.commands || {})) {
|
|
224
|
+
const gate = validateCommand(contract.capabilities, name, params)
|
|
225
|
+
if (!gate.valid) {
|
|
226
|
+
check(false, `command '${name}' executes with sample params`, gate.error)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const handler = instance.commands.get(name)
|
|
230
|
+
if (!handler) {
|
|
231
|
+
check(false, `command '${name}' executes with sample params`, 'not declared in contract')
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
await handler(params)
|
|
236
|
+
check(true, `command '${name}' executes with sample params`)
|
|
237
|
+
} catch (err) {
|
|
238
|
+
check(false, `command '${name}' executes with sample params`, err.message)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (spec.teardown) await spec.teardown()
|
|
244
|
+
|
|
245
|
+
if (failures.length) {
|
|
246
|
+
console.error(`\nSMOKE FAIL: ${failures.length}/${checks} check(s) failed`)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
console.log(`\nSMOKE PASS: ${checks} check(s)`)
|
|
250
|
+
process.exit(0)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch((err) => {
|
|
254
|
+
console.error(`ERR_SMOKE: ${err.message}`)
|
|
255
|
+
process.exit(1)
|
|
256
|
+
})
|
package/package.json
CHANGED
|
@@ -1 +1,47 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@tetherto/mdk-skill",
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"description": "MDK Developer Skill Suite — Agent Skills (SKILL.md) bundle assembled from the MDK monorepo's real artifacts and versioned to track the MDK release line. Copy-only assembler: each library owns its artifacts, this package curates them into dist/skills/ and installs them flat into .cursor/skills/ or .claude/skills/.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tether",
|
|
7
|
+
"mdk",
|
|
8
|
+
"agent-skills"
|
|
9
|
+
],
|
|
10
|
+
"author": "Tether",
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./src/index.mjs",
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.d.ts",
|
|
21
|
+
"import": "./src/index.mjs",
|
|
22
|
+
"default": "./src/index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src/*.mjs",
|
|
29
|
+
"src/index.d.ts",
|
|
30
|
+
"src/sources.map.json"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"assemble": "node src/assemble.mjs",
|
|
34
|
+
"compile:envelope": "node src/skills/mdk-site-sizing/scripts/compile-envelope.mjs",
|
|
35
|
+
"test": "node src/skills/mdk-site-sizing/scripts/compile-envelope.mjs && node src/assemble.mjs && node --test tests/site-sizing.test.mjs tests/install.test.mjs tests/install-cli.test.mjs tests/scan-packages.test.mjs && node src/skills/mdk-site-sizing/scripts/eval-held-out.mjs",
|
|
36
|
+
"install:skills": "node src/assemble.mjs && node src/install.mjs",
|
|
37
|
+
"prepack": "node src/assemble.mjs"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/tetherto/mdk.git",
|
|
42
|
+
"directory": "packages/mdk-skill"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/assemble.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI wrapper around the programmatic `assemble()` (see index.mjs).
|
|
3
|
+
import { assemble } from './index.mjs'
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const { files, version } = assemble()
|
|
7
|
+
console.log(`assembled ${files} file(s) (mdk ${version}) -> dist/skills`)
|
|
8
|
+
} catch (err) {
|
|
9
|
+
console.error(`ERR_ASSEMBLE: ${err instanceof Error ? err.message : String(err)}`)
|
|
10
|
+
process.exit(1)
|
|
11
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type SkillClient = 'cursor' | 'claude' | 'all';
|
|
2
|
+
|
|
3
|
+
export interface InstalledEntry {
|
|
4
|
+
client: string;
|
|
5
|
+
dir: string;
|
|
6
|
+
/** Stale MDK-shipped skill dirs removed from `dir` because the current suite no longer ships them. */
|
|
7
|
+
removed: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SkippedEntry {
|
|
11
|
+
client: string;
|
|
12
|
+
skill: string;
|
|
13
|
+
dir: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InstallResult {
|
|
18
|
+
skills: string[];
|
|
19
|
+
installed: InstalledEntry[];
|
|
20
|
+
/** Existing skill dirs left untouched: symlinks, or not marked `metadata.suite: mdk-developer-skill`. */
|
|
21
|
+
skipped: SkippedEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InstallOptions {
|
|
25
|
+
/** Target client(s). Defaults to `'all'`. */
|
|
26
|
+
client?: SkillClient;
|
|
27
|
+
/** Project root to install into. Defaults to `process.cwd()`. */
|
|
28
|
+
target?: string;
|
|
29
|
+
/** Assemble the suite first if `dist/skills/` is empty. Defaults to `true`. */
|
|
30
|
+
assembleIfMissing?: boolean;
|
|
31
|
+
/** Called once per skipped skill dir. Defaults to `console.warn`. */
|
|
32
|
+
onWarn?: (message: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AssembleResult {
|
|
36
|
+
files: number;
|
|
37
|
+
version: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export declare const CLIENT_DIRS: Record<string, string[]>;
|
|
41
|
+
|
|
42
|
+
/** True when the source map and repo root are available to assemble from. */
|
|
43
|
+
export declare function canAssemble(): boolean;
|
|
44
|
+
|
|
45
|
+
/** True when `dist/skills/` already holds at least one assembled skill. */
|
|
46
|
+
export declare function isAssembled(): boolean;
|
|
47
|
+
|
|
48
|
+
/** Assemble the skill suite from source artifacts into `dist/skills/`. */
|
|
49
|
+
export declare function assemble(): AssembleResult;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install the assembled skills into a project's client skills directories.
|
|
53
|
+
* Throws if a client dir segment under the target is a symlink; skips (and
|
|
54
|
+
* warns about) existing same-named skill dirs that are not suite skills. Stale
|
|
55
|
+
* MDK-shipped skills are swept; user-authored skills are left untouched.
|
|
56
|
+
*/
|
|
57
|
+
export declare function installSkills(options?: InstallOptions): InstallResult;
|