diff --git a/API_SUPPORT.json b/API_SUPPORT.json index 1fd1313..59bfb30 100644 --- a/API_SUPPORT.json +++ b/API_SUPPORT.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sdkVersion": "1.1.0", + "sdkVersion": "1.2.0", "releaseStatus": "stable", "pluginPeerRange": ">=1.0.0 <2.0.0", "policy": "docs/SUPPORT_POLICY.md", @@ -814,6 +814,36 @@ ], "declarationsSha256": "8a18db17f11049dcaace7bd98aae453b572ed126689f1f88fd4b8c8bd57582c6" }, + { + "entry": "./lsp-node", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "LSP_LIMITS", + "createLspBridge", + "createLspFrameDecoder", + "encodeLspMessage", + "lspCapabilityMatrix", + "parseLspCapabilityMatrix" + ], + "declarationNames": [ + "LSP_LIMITS", + "LspBridge", + "LspCapabilityMatrix", + "LspCloseReceipt", + "LspFrameDecoder", + "LspInspection", + "createLspBridge", + "createLspFrameDecoder", + "encodeLspMessage", + "lspCapabilityMatrix", + "parseLspCapabilityMatrix" + ], + "declarationsSha256": "396e9316a0d718964dbd3ae158e328b5da0320098f1ffc171d1710ea86650e69" + }, { "entry": "./package.json", "stability": "metadata", @@ -1002,7 +1032,7 @@ "declarationNames": [ "SCHEMAS" ], - "declarationsSha256": "259961a4a30f11c661bafd71e3e8314012c13d8a179294af09558df5606e580a" + "declarationsSha256": "5d1672a66f8d8d5ba8c6acd49e89cbaf13c12585dcedb81f5ec402567d949f65" }, { "entry": "./secrets", @@ -1161,6 +1191,59 @@ ], "declarationsSha256": "3d67f76fc0f5003d57b931d8104079d17e31cf5c30c8369085357377e734440b" }, + { + "entry": "./tool-streams", + "stability": "stable", + "availability": "optional", + "environments": [ + "node", + "browser" + ], + "runtimeNames": [ + "TOOL_STREAM_LIMITS", + "createJobToolEventSink", + "createToolStreamParser", + "parseToolStreamEvent", + "parseTypeScriptDiagnosticLine" + ], + "declarationNames": [ + "TOOL_STREAM_LIMITS", + "ToolStreamData", + "ToolStreamEvent", + "ToolStreamParser", + "ToolStreamStats", + "createJobToolEventSink", + "createToolStreamParser", + "parseToolStreamEvent", + "parseTypeScriptDiagnosticLine" + ], + "declarationsSha256": "9ae25b6f223e610f183fdeaf7c592d6d6f75d08065fe19ab37c90f227a5b7cb2" + }, + { + "entry": "./tool-streams-node", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "TOOL_RUNNER_LIMITS", + "createToolRunner", + "hashToolFile", + "parseToolRegistration" + ], + "declarationNames": [ + "TOOL_RUNNER_LIMITS", + "ToolRegistration", + "ToolRunReceipt", + "ToolRunner", + "TrustedProcessDefinition", + "createToolRunner", + "hashToolFile", + "parseToolRegistration" + ], + "declarationsSha256": "1d605181a25a7dce2b2e667038601cdfeb7a1ae40a5b476ba2de7a8cdb3a6bfb" + }, { "entry": "./transfer-queue", "stability": "stable", @@ -1368,6 +1451,117 @@ ], "declarationsSha256": "7027bd158d8e2e105f3999f1137930468dde63d729c999eb6712c2bd69bc40fe" }, + { + "entry": "./workflow-cache", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "WORKFLOW_CACHE_LIMITS", + "createMemoryWorkflowCacheStore", + "createWorkflowCache", + "fingerprintWorkflowInputs", + "parseWorkflowCacheEntry", + "parseWorkflowFingerprint" + ], + "declarationNames": [ + "WORKFLOW_CACHE_LIMITS", + "WorkflowCache", + "WorkflowCacheEntry", + "WorkflowCachePolicy", + "WorkflowCacheResult", + "WorkflowCacheStore", + "WorkflowFingerprint", + "WorkflowFingerprintInput", + "createMemoryWorkflowCacheStore", + "createWorkflowCache", + "fingerprintWorkflowInputs", + "parseWorkflowCacheEntry", + "parseWorkflowFingerprint" + ], + "declarationsSha256": "32415b84477e4e33fefd80a549a5880b2f508558932045b0bae8c16ed8839ac4" + }, + { + "entry": "./workflow-cache-node", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "createNodeWorkflowCacheStore" + ], + "declarationNames": [ + "NodeWorkflowCacheStore", + "createNodeWorkflowCacheStore" + ], + "declarationsSha256": "84dc99a6019529e6462c2006c9b096affb9837ab7876ce502280a15fd61b5c0e" + }, + { + "entry": "./workflows", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "WORKFLOW_LIMITS", + "createCommandJobExecutor", + "createMemoryWorkflowStore", + "createWorkflowRunner", + "parseWorkflowDefinition", + "parseWorkflowRecord", + "prepareWorkflowPlan", + "recoverWorkflowRecord" + ], + "declarationNames": [ + "WORKFLOW_LIMITS", + "WorkflowApproval", + "WorkflowAuthorization", + "WorkflowBinding", + "WorkflowCacheEvidence", + "WorkflowCommand", + "WorkflowDefinition", + "WorkflowExecutor", + "WorkflowPlan", + "WorkflowRecord", + "WorkflowRunner", + "WorkflowScope", + "WorkflowState", + "WorkflowStep", + "WorkflowStepRecord", + "WorkflowStore", + "createCommandJobExecutor", + "createMemoryWorkflowStore", + "createWorkflowRunner", + "parseWorkflowDefinition", + "parseWorkflowRecord", + "prepareWorkflowPlan", + "recoverWorkflowRecord" + ], + "declarationsSha256": "869ff4713bf1351d08feb14816160e2a0636b88662eebc01fe8b5f2126aabf96" + }, + { + "entry": "./workflows-node", + "stability": "stable", + "availability": "optional", + "environments": [ + "node" + ], + "runtimeNames": [ + "createNodeWorkflowStore" + ], + "declarationNames": [ + "NodeWorkflowStore", + "OwnedStoreCleanup", + "OwnedWorkflowStoreInspection", + "OwnedWorkflowStoreOptions", + "createNodeWorkflowStore" + ], + "declarationsSha256": "354890d8ee31bca7833f1e422975ffc4e257ac9e990908418ca2bfb0544c6bfc" + }, { "entry": "./workspace-client", "stability": "stable", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fab792..7845c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.2.0 — 2026-10-05 + +- Add bounded command DAGs with typed output bindings, exact plan approval, per-step grants, existing command-job integration and durable CAS checkpoints. Explicit restart recovery and retries create new attempts without automatic replay. +- Add opt-in deterministic content fingerprints, scoped TTL/LRU caches, single-flight fills, current authorization, hash verification and owned-store recovery. Cache reuse has distinct evidence and no fresh job ID. +- Add portable JSONL/text stream parsing with fragmented UTF-8, diagnostics/progress/log correlation, backpressure, literal masking and bounded output. The optional Node runner uses fixed operator-pinned tools and direct-child cleanup. +- Add an optional stdio LSP 3.17 subset: 15 mapped language features, UTF-16 snapshots, cancellation/stale results, versioned diagnostics, conditional edit proposals and explicit unsupported capabilities. +- Include independent installed workflow/tool/LSP examples, browser stream examples, actual TypeScript 5.9.3 and language-server 5.3.0 checks, seven public exports and eleven schemas. The actual server's unversioned diagnostics remain unsupported. +- Preserve existing 1.x API/protocol behavior and historical archives. Security audit and vulnerability validation remain deferred; this release does not claim DDS product integration or an execution sandbox. + ## 1.1.0 — 2026-10-05 - Add exact dependency tree locks, deterministic offline bundles, LICENSE/NOTICE inventories and CycloneDX 1.6 SBOMs. Install only into a new directory, without scripts, downloads or permission inheritance. diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index a95c79b..2694284 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -31,6 +31,7 @@ "docs/LANGUAGE_EDITING.md", "docs/LICENSING.md", "docs/LOCALIZATION.md", + "docs/LSP.md", "docs/OBSERVATION.md", "docs/PROJECTS.md", "docs/PROJECT_QUERIES.md", @@ -45,9 +46,11 @@ "docs/SETTINGS.md", "docs/SUPPORT_POLICY.md", "docs/THEMES.md", + "docs/TOOL_STREAMS.md", "docs/TRANSFER_QUEUE.md", "docs/UPLOADS.md", "docs/VERIFYING.md", + "docs/WORKFLOWS.md", "docs/WORKSPACES.md", "docs/WORKSPACE_EDITS.md", "docs/api.md", @@ -106,6 +109,8 @@ "examples/language-editing/LICENSE", "examples/language-editing/plugin.mjs", "examples/language-editing/run.mjs", + "examples/lsp/run.mjs", + "examples/lsp/synthetic-server.mjs", "examples/project-query/run.mjs", "examples/project-session/run.mjs", "examples/project-tools/browser.html", @@ -125,6 +130,9 @@ "examples/release-verification/README.md", "examples/release-verification/run.mjs", "examples/stored-artifacts/run.mjs", + "examples/tool-streams-browser/browser.html", + "examples/tool-streams-browser/browser.mjs", + "examples/tool-streams-browser/serve.mjs", "examples/transfer-queue/browser.html", "examples/transfer-queue/browser.mjs", "examples/transfer-queue/host.mjs", @@ -141,6 +149,11 @@ "examples/user-workspace/project/design.sv", "examples/user-workspace/server.mjs", "examples/user-workspace/tool.mjs", + "examples/workflow-tools/run.mjs", + "examples/workflow-tools/synthetic-tool.mjs", + "examples/workflows/fixture.mjs", + "examples/workflows/interrupted-child.mjs", + "examples/workflows/run.mjs", "examples/workspace-edits/LICENSE", "examples/workspace-edits/child.mjs", "examples/workspace-edits/plugin.mjs", @@ -191,6 +204,8 @@ "schemas/language-snippet.schema.json", "schemas/locale-catalog.schema.json", "schemas/localized-message.schema.json", + "schemas/lsp-capability-matrix.schema.json", + "schemas/lsp-close-receipt.schema.json", "schemas/manifest.schema.json", "schemas/memory-job-snapshot.schema.json", "schemas/plugin-bundle.schema.json", @@ -224,8 +239,12 @@ "schemas/stored-job.schema.json", "schemas/test-trace.schema.json", "schemas/theme.schema.json", + "schemas/tool-registration.schema.json", + "schemas/tool-run-receipt.schema.json", + "schemas/tool-stream-event.schema.json", "schemas/transfer-queue-options.schema.json", "schemas/transfer-snapshot.schema.json", + "schemas/trusted-process-definition.schema.json", "schemas/upload-capabilities.schema.json", "schemas/upload-chunk.schema.json", "schemas/upload-reference.schema.json", @@ -234,6 +253,11 @@ "schemas/upstream-candidate.schema.json", "schemas/upstream-manifest.schema.json", "schemas/upstream-snapshot.schema.json", + "schemas/workflow-cache-entry.schema.json", + "schemas/workflow-definition.schema.json", + "schemas/workflow-fingerprint.schema.json", + "schemas/workflow-plan.schema.json", + "schemas/workflow-record.schema.json", "schemas/workspace-conditional-file.schema.json", "schemas/workspace-edit-preview.schema.json", "schemas/workspace-edit-receipt.schema.json", @@ -320,9 +344,13 @@ "src/localization-schemas.mjs", "src/localization.d.mts", "src/localization.mjs", + "src/lsp-node.d.mts", + "src/lsp-node.mjs", + "src/lsp-transport-node.mjs", "src/node-project-context.mjs", "src/node-store-context.mjs", "src/node-workspace-context.mjs", + "src/owned-json-store-node.mjs", "src/patterns.mjs", "src/project-patterns.mjs", "src/project-query-node.d.mts", @@ -364,11 +392,16 @@ "src/text-edits.mjs", "src/themes.d.mts", "src/themes.mjs", + "src/tool-streams-node.d.mts", + "src/tool-streams-node.mjs", + "src/tool-streams.d.mts", + "src/tool-streams.mjs", "src/transfer-queue-contracts.mjs", "src/transfer-queue-schemas.mjs", "src/transfer-queue.d.mts", "src/transfer-queue.mjs", "src/transfer-transient.mjs", + "src/trusted-process-node.mjs", "src/updates-node.d.mts", "src/updates-node.mjs", "src/updates.d.mts", @@ -386,6 +419,16 @@ "src/upstream.d.mts", "src/upstream.mjs", "src/version.mjs", + "src/workflow-cache-node.d.mts", + "src/workflow-cache-node.mjs", + "src/workflow-cache.d.mts", + "src/workflow-cache.mjs", + "src/workflow-internals.mjs", + "src/workflow-schemas.mjs", + "src/workflows-node.d.mts", + "src/workflows-node.mjs", + "src/workflows.d.mts", + "src/workflows.mjs", "src/workspace-client.d.mts", "src/workspace-client.mjs", "src/workspace-edit-contracts.mjs", diff --git a/README.md b/README.md index 80ae970..823a101 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,15 @@ a small ESM package, TypeScript declarations and versioned contracts. Connect your own workspace and tools with the Node.js host and HTTP client. The SDK has **zero runtime dependencies**. -Version **1.1.0** adds [offline bundles, detached provenance, reviewed updates and upstream candidates](docs/RELEASE_TOOLS.md). +Version **1.2.0** adds [command workflows and incremental cache](docs/WORKFLOWS.md), +[registered tool streams](docs/TOOL_STREAMS.md) and a [public stdio LSP bridge](docs/LSP.md). +Run `npm run example:workflows`, `npm run example:workflow-tools` and `npm run example:lsp` +for independent hosts, durable recovery, actual command jobs and public synthetic tools. +The tool-output parser also has `npm run example:tool-streams-browser`; execution, +workflow/cache stores and LSP are optional Node features. Existing approved TypeScript +and language-server installations can be supplied explicitly to the examples. + +Version 1.1 adds [offline bundles, detached provenance, reviewed updates and upstream candidates](docs/RELEASE_TOOLS.md). Run `npm run example:release-tools` for a complete independent installation, signature, approval, active-job version switch, rollback and durable readback example. These Node-only tools use operator-owned trust and explicit execution grants. @@ -62,7 +70,7 @@ application, private engines/services and production signing systems are outside this repository. Read the [public scope](docs/PUBLIC_SCOPE.md) and [compatibility table](docs/COMPATIBILITY.md) for exact boundaries. -Version **1.1.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain +Version **1.2.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain compatible. Manifest v2 adds commands, permission-scoped workspace and backend APIs, and open-source or proprietary distribution metadata. Themes export to the XML format already supported by DDS. The workspace host runs trusted plugins @@ -122,7 +130,7 @@ mkdir my-dds-plugin cd my-dds-plugin npm init -y npm pkg set type=module -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v1.1.0/altifigence-dds-plugin-sdk-1.1.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v1.2.0/altifigence-dds-plugin-sdk-1.2.0.tgz ``` Copy the example's `plugin.mjs` and `run.mjs` into this directory, then run: diff --git a/SECURITY.md b/SECURITY.md index f2beefc..02cb656 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security -The current stable API release is **1.1.0**. It retains the security fixes from +The current stable API release is **1.2.0**. It retains the security fixes from 0.3.2. Update workspace servers and clients together, using a current patched Node 22 or 24; do not use a pre-0.3.2 workspace host. Report an SDK @@ -59,7 +59,7 @@ protect storage, verify the current identity and implement revocation themselves ## 한국어 -현재 안정 API 릴리스는 **1.1.0**이며 0.3.2의 보안 수정을 유지합니다. workspace **서버와 클라이언트를 모두 +현재 안정 API 릴리스는 **1.2.0**이며 0.3.2의 보안 수정을 유지합니다. workspace **서버와 클라이언트를 모두 0.3.2 이상**으로 갱신하고 Node 22/24의 최신 보안 패치를 사용하세요. 클라이언트만 갱신하면 오래된 서버의 파일 검사와 요청 수신 제한은 바뀌지 않습니다. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 1e8ea9d..fdcdb3f 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,5 +1,26 @@ # Compatibility and availability +## 1.2 workflows and external tools + +The seven additional exports are optional. Workflows, cache, their durable Node +stores, registered process execution and LSP require Node. `tool-streams` is also +portable to browsers. None changes the existing workspace protocol or implicitly +grants process access. Plugins importing new APIs must declare a minimum of 1.2.0; +the packer does not infer that minimum from their source code. + +Existing 1.0/1.1 exports remain compatible. Workflow/cache schemas are new v1 +contracts with exact scope, digest and bounded data requirements. Restart does not +automatically replay commands. The LSP capability matrix reports explicit supported +and unsupported subsets, with synthetic and actual-server evidence kept separate. +See [workflows](WORKFLOWS.md), [tool streams](TOOL_STREAMS.md) and [LSP](LSP.md). + +## 1.1 release tools + +Offline bundles, detached provenance, reviewed updates and upstream candidates are +optional Node APIs. Existing archive verification preserves historical metadata; +provenance and current host permissions remain independent checks. See +[release tools](RELEASE_TOOLS.md) for operator trust, rollback and recovery limits. + ## 1.0 stable API `1.0.0` retains the RC runtime/protocol API. Newly packed plugins use diff --git a/docs/LSP.md b/docs/LSP.md new file mode 100644 index 0000000..9c8d166 --- /dev/null +++ b/docs/LSP.md @@ -0,0 +1,124 @@ +# Public LSP bridge + +SDK 1.2's optional Node-only `lsp-node` export connects an operator-approved stdio +server to existing public SDK language contracts. It uses the +[LSP 3.17 protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) +subset below. It does not claim complete LSP support or DDS product integration. + +```sh +node node_modules/@altifigence/dds-plugin-sdk/examples/lsp/run.mjs +``` + +The redistributable synthetic server implements its own framing independently of +the SDK. The example exercises 15 features, versioned diagnostics, document +lifecycle, unchanged workspace files and owned-process shutdown. In a checkout, +use `npm run example:lsp`. + +## Connect and discover + +`createLspBridge({process, workspaceRoot, scope, authorize, ...})` takes the same +operator-owned `TrustedProcessDefinition` as [registered tools](TOOL_STREAMS.md). +Its arguments are fixed, pins and exact version probe are checked, and initialization +negotiates UTF-16 positions and full document snapshots. Incremental-only payloads, +nonlocal URIs and servers negotiating another position encoding are unsupported. +The supported server sync kinds are full and incremental; for either, this client +sends full-text changes without a range, which LSP permits. + +Call `bridge.capabilities()` for the explicit matrix. An advertised capability +means negotiated availability, not that every possible result shape will work. +`bridge.provider(kind)` can be registered directly with +`context.registerLanguageProvider(kind, selector, provider)`. Alternatively, +`bridge.request(sdkRequest, {signal})` returns the existing SDK language result. +The request includes scope and a current DocumentSnapshot; SDK result validation +remains in force. + +| SDK feature | LSP method | Result boundary | +| --- | --- | --- | +| completion | textDocument/completion | Literal items; snippets and resolve unsupported | +| hover | textDocument/hover | Literal text; markup is not rendered as HTML | +| definition / references | textDocument/definition / references | Authorized local locations | +| document-symbols / document-symbol-tree | textDocument/documentSymbol | Flat or hierarchical SDK symbols | +| signature-help | textDocument/signatureHelp | Bounded signatures and active argument | +| prepare-rename / rename | textDocument/prepareRename / rename | Range preview / conditional edit proposal | +| format-document / format-range | textDocument/formatting / rangeFormatting | Conditional edit proposals | +| code-actions | textDocument/codeAction | Literal actions and edit proposals; commands disabled | +| semantic-tokens | textDocument/semanticTokens/full | Negotiated legend and validated full tokens | +| folding-ranges | textDocument/foldingRange | Validated snapshot ranges | +| inlay-hints | textDocument/inlayHint | Literal labels and tooltips | + +Unsupported features are explicit: completion resolve/snippets, code-action +resolve, semantic deltas, server commands, dynamic registration, create/rename/delete +resource operations, socket transports and unversioned diagnostics. Unknown server +requests receive unsupported responses; `workspace/applyEdit` returns `applied:false`. +Configuration is a bounded host-supplied object, not arbitrary file/network access. + +## Documents, permissions and edits + +`syncDocument(snapshot)` sends didOpen/didChange, and `closeDocument(uri)` sends +didClose. File URIs must resolve to the same local workspace without links. +Canonical filesystem paths account for Windows drive-letter casing. Results may +reference another authorized open document or a document supplied by the optional +`readDocument(path, {signal})` host port. That port must return the exact requested +snapshot. Every source and edit target requires current host authorization. + +The bridge tracks document versions and a generation. Any synchronized change or +close cancels outstanding work; stale and late responses never become current +results. This conservative generation also cancels unrelated requests when a +document changes. Duplicate active SDK request IDs are rejected. UTF-16 ranges +must fit the relevant snapshot. Edit proposals carry base hashes and use existing +[workspace edit](WORKSPACE_EDITS.md) preview/CAS application; the bridge never +applies edits or executes code-action commands. + +`textDocument/publishDiagnostics` must carry the current model version. Unversioned +or stale notifications are dropped and counted. The optional `onDiagnostics` +callback receives an existing SDK DiagnosticsResult after authorization and range +validation. It is bounded by the request timeout. Configure explicit masks for +known sensitive text; literal masking is not general secret detection. + +## Lifecycle and limits + +`close()` sends didClose, shutdown and exit, then stops its owned direct process +and closes pipes. The receipt distinguishes acknowledged graceful protocol +shutdown from a refused/crashed server and reports actual process closure and +pending requests. A server may acknowledge shutdown but still need the bounded +owned-process stop. Crashes do not trigger an automatic restart or replay. The +operator creates a fresh bridge and resends selected current snapshots explicitly. +Parent cancellation stops the session. The host owns any descendants launched by +its trusted server; the SDK is not a process-tree manager or sandbox. + +| Resource | Maximum | +| --- | --- | +| Message body / header | 1 MiB / 8 KiB | +| Session output / messages | 64 MiB / 20,000 | +| Pending requests / queued writes | 16 / 16 | +| Documents / aggregate text bytes | 32 / 4 MiB | +| Request deadline | 30 seconds (default 5 seconds) | +| Initialize deadline | At least 10 seconds, up to the configured request deadline | +| Discarded stderr bytes | 64 KiB, then the session stops | + +The framing helpers `encodeLspMessage` and `createLspFrameDecoder` are also public +Node utilities. They use UTF-8 byte lengths, bounded incremental Content-Length +framing and explicit malformed/truncated-frame failures. They do not open a transport. + +## Actual TypeScript server example + +The tested independent server is the community +[typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) +**5.3.0**, with **TypeScript 5.9.3**. Provide existing approved installations: + +```sh +node node_modules/@altifigence/dds-plugin-sdk/examples/lsp/run.mjs --typescript-root /absolute/path/to/typescript --language-server-root /absolute/path/to/typescript-language-server +``` + +This explicitly enables the real-server example in addition to the synthetic one. +It checks completion, hover, definition, references, symbols, signature help, +formatting and semantic tokens: eight features. The example pins selected local +implementation files and Node, probes the exact server version, fixes tsserver's +path and disables automatic typings acquisition. It does not download dependencies. +The operator must trust the complete server/tool installation and its OS access. + +Version 5.3.0 omits optional `initialize.serverInfo` and emits unversioned diagnostics; +the example identifies it through pins/version probe and does **not** claim its +diagnostics work through this bridge. Versioned diagnostics are verified with the +independent synthetic server. Feature support and successful actual-server checks +are reported separately. Other servers and versions require their own acceptance. diff --git a/docs/SUPPORT_POLICY.md b/docs/SUPPORT_POLICY.md index 0e52ce5..ebf5ac7 100644 --- a/docs/SUPPORT_POLICY.md +++ b/docs/SUPPORT_POLICY.md @@ -1,6 +1,6 @@ # SDK 1.x support and migration -`1.1.0` is the current stable SDK release in the 1.x line. Pin the installed archive; newly packed plugins +`1.2.0` is the current stable SDK release in the 1.x line. Pin the installed archive; newly packed plugins use `>=1.0.0 <2.0.0`. The policy below applies to this line. Security audit and vulnerability validation remain pending under the owner's 2026-10-05 deferral; stable API status does not constitute security or DDS product acceptance. @@ -27,7 +27,7 @@ experimental changes are announced in minor-release notes and require fresh acceptance. Its presence is not a cross-browser compatibility claim. Other browser ports also require their documented capability and permission checks. -No stable API is deprecated in 1.1.0. Future deprecations appear in declarations, +No stable API is deprecated in 1.2.0. Future deprecations appear in declarations, the changelog and migration guide for at least two minor releases and six months before removal in a new major. Runtime execution does not emit unsolicited warning logs or change grants. A security emergency is documented explicitly. diff --git a/docs/TOOL_STREAMS.md b/docs/TOOL_STREAMS.md new file mode 100644 index 0000000..f1bdd08 --- /dev/null +++ b/docs/TOOL_STREAMS.md @@ -0,0 +1,110 @@ +# Registered tools and output streams + +SDK 1.2's optional `tool-streams` export parses output in Node or a browser. It +has no process/network authority. `tool-streams-node` is a Node-only runner for +fixed operator-approved tools. Both have public declarations and JSON schemas. + +```sh +node node_modules/@altifigence/dds-plugin-sdk/examples/workflow-tools/run.mjs +node node_modules/@altifigence/dds-plugin-sdk/examples/tool-streams-browser/serve.mjs +``` + +The first uses a redistributable SDK-authored synthetic tool. The second serves a +loopback browser page with fragmented UTF-8, progress, diagnostics, unknown output, +literal masking, final partial-line and cancellation examples. Neither downloads +or installs tools. In a checkout, use `npm run example:workflow-tools` and +`npm run example:tool-streams-browser`. + +## Portable parser + +```js +import {createToolStreamParser} from '@altifigence/dds-plugin-sdk/tool-streams'; + +const parser = createToolStreamParser({ + format: 'jsonl', jobId, commandId, toolId, toolVersion, + secrets: knownSensitiveValues, sensitivePaths: operatorPaths, + signal, onEvent: async event => await consumer.write(event), +}); +try { + for await (const bytes of stdout) await parser.write(bytes, 'stdout'); + const stats = await parser.finish(); +} finally { parser.dispose(); } +``` + +Await each `write` for backpressure, feed stderr with its own stream name, and +`finish()` only after both streams end. Separate fatal UTF-8 decoders retain split +characters and partial lines. JSONL supports `log`, `progress`, `diagnostic` and +`unknown` records. Invalid JSONL/diagnostic lines produce explicit `unknown` events; +malformed UTF-8 or resource limits stop the parser. Plain text becomes log events. +The `typescript` text parser recognizes relative `file(line,column): error TS…` +messages and converts one-based positions to zero-based ranges. + +Events carry version, sequence, job, command, tool/version and stdout/stderr +correlation. Diagnostics use relative paths, ordered nonnegative positions, +severity, message and optional code/source. Without source text, a tool stream +cannot prove that a position lies inside the actual document; the consuming host +must validate it against its current snapshot before publishing editor diagnostics. +`createJobToolEventSink(job, {onDiagnostic})` sends progress/logs to an existing +JobReporter and leaves diagnostics with a caller-owned sink. + +Masking replaces configured nonempty single-line literal values and paths after +line assembly, including literals split across chunks. It does not detect encoded, +transformed or unknown secrets. Masked diagnostic paths become unknown events. +Do not pass raw sensitive output to an unrelated logger before parsing it. + +## Operator registration and execution + +`createToolRunner({tools, workspaceRoot, authorize})` accepts registrations with: + +- An approved process ID/version, absolute executable, SHA-256, selected artifact + paths/digests, fixed version-probe arguments/output, explicit environment and + fixed arguments with typed `{input}` slots. +- A backend ID, closed input data schema, declared relative file input slots, + output format and parser. + +The host owns the whole tool installation and decides which implementation files +must be pinned. The SDK checks the supplied pins and bounded exact version output; +it does not infer a complete dependency tree from an entry script. Argument slots +are required scalar fields; flag/line injection and nonlocal file paths are rejected. +The plugin cannot select an executable or shell through invocation input. + +`runner.run({toolId, version, input, jobId, commandId, onEvent, signal})` checks +current authorization before preparation, before start, on every event and after +completion. It uses `shell: false` and only the explicitly supplied environment, +owns the direct child and its pipes, waits for trailing output, and returns a +receipt with state, reason, exit code, signal and parser statistics. Cancellation, +deadlines and `close()` stop owned work; start failures and nonzero exits remain +failures. It does not terminate unrelated processes or discover/kill descendant +process trees. Registered tools are trusted operator code, not sandboxed plugins. + +## Tested adapters + +| Adapter | Evidence and boundary | +| --- | --- | +| SDK synthetic tool 1.0.0 | Independent JSONL process; normal/nonzero, fragmented UTF-8, trailing line, cancellation, output limits | +| TypeScript 5.9.3 | Actual compiler; `--noEmit --noLib --pretty false`; TS2322 diagnostic from a disposable file with explicit intrinsic types | +| Plain text | Log records only; no tool-specific semantic interpretation | + +To run the actual compiler example, supply an existing approved installation: + +```sh +node node_modules/@altifigence/dds-plugin-sdk/examples/workflow-tools/run.mjs --typescript-root /absolute/path/to/typescript +``` + +The example hashes the selected local Node executable and TypeScript files and +checks `Version 5.9.3`. This is an explicit local demonstration, not independent +publisher authentication. Other versions are unsupported until separately verified. +The SDK has zero runtime dependencies; TypeScript and the language server are +development/test dependencies only. + +| Resource | Maximum | +| --- | --- | +| UTF-8 chunk / line / complete output | 64 KiB / 16 KiB / 2 MiB | +| Events / queued writes / event callback | 4096 / 8 / 5 seconds | +| Registered tools / concurrent runs | 32 / 4 (default 2) | +| Invocation deadline | 10 minutes (default 60 seconds) | +| Version-probe output / deadline | 16 KiB / 5 seconds | +| Pinned artifacts / size of each | 64 / 256 MiB | + +See [workflows](WORKFLOWS.md) to compose steps and [LSP](LSP.md) for stdio language +servers. This release adds no private DDS backend or product installation path. diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md new file mode 100644 index 0000000..120766b --- /dev/null +++ b/docs/WORKFLOWS.md @@ -0,0 +1,137 @@ +# Workflows and incremental cache + +SDK 1.2 adds optional Node-only `workflows`, `workflows-node`, `workflow-cache` and +`workflow-cache-node` exports. They compose public command jobs in an operator-owned +host. They do not provide a DDS Cloud scheduler or run automatically after restart. + +Run the complete example from a checkout with `npm run example:workflows`, or from +an installed SDK: + +```sh +node node_modules/@altifigence/dds-plugin-sdk/examples/workflows/run.mjs +``` + +The example creates disposable workspaces and stores. It runs existing PluginHost +command jobs, reuses three cache hits, changes one input file, executes only that +step and its dependent step, and blocks a dependent step after failure. It then +kills its own child after a durable checkpoint, reads an interrupted attempt and +explicitly retries one unfinished step. Its JSON receipt reports counts, timings, +cache metrics and stored bytes. Timings describe the local fixture, not a service SLA. + +## Review, execute and resume + +`prepareWorkflowPlan(definition, commands)` returns an immutable plan and SHA-256. +Definitions contain a workspace/security scope, unique step IDs, registered command +IDs, explicit `needs`, input bindings and grants. Each binding is either `{value}` +or `{step, path}`. Output bindings name a direct dependency and a required object +path; source and target data schemas must match exactly. Cycles, missing commands, +duplicate IDs, missing required inputs and schema mismatches fail before execution. +Inputs use the closed [data schema subset](DATA_SCHEMAS.md). Secret-reference +schemas are unsupported in persisted workflows; the host must also exclude raw +secrets from ordinary values. + +```js +import {prepareWorkflowPlan, createWorkflowRunner} from '@altifigence/dds-plugin-sdk/workflows'; + +const plan = prepareWorkflowPlan(definition, registeredCommands); +// Present plan.definition, plan.commands and plan.sha256 to the operator. +const runner = createWorkflowRunner({ + commands: registeredCommands, store, execute, + authorize: context => hostPolicy.allows(context), +}); +const record = await runner.run(plan, { + approval: {approved: true, planSha256: reviewedSha256}, signal, +}); +``` + +`execute(step, command, input, context)` is a trusted host port. It receives a new +job ID, attempt ID and cancellation signal, and returns `{output, artifacts?}`. +`createCommandJobExecutor({resolveHost})` connects this port to an existing +PluginHost. The resolver must return `{host, scope, grants, pluginSha256}` for the +reviewed step. It must choose an isolated host whose effective grants exactly match +the returned list; SDK metadata cannot inspect or confine arbitrary host code. +Step grants are never merged with another step's grants. Current authorization is +checked before execution, after results and before retained-result reuse. + +`continue` lets independent branches complete and marks descendants of failure as +`blocked`. `fail-fast` cancels remaining work. Every checkpoint includes the plan, +input and output digests, parent attempt/job, step job IDs, artifact references, +timestamps, cache evidence and failure codes. Referenced job artifacts retain their +own retention and permission rules; a workflow record does not extend their life. +A host handler that ignores cancellation remains counted as unsettled, and another +run is refused until it settles. Arbitrary host ports must honor cancellation. + +`runner.recover(attemptId)` reads the stored checkpoint and returns pending/running +work as `interrupted`. It never calls the executor. Resume passes that exact +checkpoint as `previous` and adds `previousAttemptId` to a new digest-bound +approval. The result has a new attempt ID. Successful unexpired steps with matching +inputs can be retained; `retrySteps` explicitly reruns selected steps and their +descendants, independent of declaration order. Expired partial output requires an +explicit retry selection. Changed plan, schema, grants or plugin pins need a new +plan rather than resuming an old checkpoint. + +## Durable ownership and retention + +`createNodeWorkflowStore({directory, workspaceRoot, scope})` uses a single-writer +lock and a scope/workspace identity marker. Both existing directories must be real, +disjoint directories. Records use checksummed envelopes, atomic replacement and +compare-and-swap revisions. Windows does not report directory fsync support; +this is process-restart recovery, not a power-loss durability guarantee. + +Open normally after a clean `close()`. After a known crash, +`recoverStaleLock: true` permits recovery only when the recorded same-machine PID +is proven dead. Live or uncertain owners remain conflicts. PID reuse may require +operator intervention. A leftover recovery lock is preserved for inspection. + +The default `cleanup()` removes only recognized bounded corrupt records and +unfinished temporary writes. Unknown entries and links are preserved. Explicit +`cleanup({expiredBefore: Date.now()})` also removes expired terminal records; +pending/running records are retained for recovery. It returns removed and preserved +filenames. The store stops at its quota and does not delete workspace source files. +The caller owns cleanup and rotation of memory stores and closed store directories. + +## Cache eligibility and invalidation + +Use `fingerprintWorkflowInputs` with actual file bytes and an explicit policy: +`optIn`, `deterministic`, `declaredInputsComplete`, `secretDependent`. It hashes SDK +version, workspace/security scope, command, plugin/tool digests, command schema, +settings, input, declared environment names/values, grants and each file's content. +It does not use mtime as a content identity. Determinism and completeness are host +attestations; the SDK cannot discover hidden process inputs or detect all secrets. + +Unknown/incomplete inputs, nondeterminism, secret dependence, undeclared/missing +environment and secret-reference inputs return an ineligible fingerprint with +reasons. They still execute through `cache.run` but return `bypass` and are not +retained. Secret-reference results are also not stored. Never opt in commands with +external side effects or unrepresented dependencies. + +`createWorkflowCache({authorize, store?})` rechecks current authorization on lookup, +fill, after fill, hits and return. Hits verify value digest, scope, grants and TTL. +Concurrent eligible misses share a fill; cancelling one waiter does not cancel +other waiters. A fill with no waiters is cancelled. TTL and LRU bound retained +results, and `cleanup()` handles expiry and owned-store recovery. Corrupt entries +are misses. A storage write failure returns a computed value with a distinct +reason; it is not reported as a successful retained cache write. + +The workflow executor can return `cache: {state, key, reason}` with the cached +output. `hit` and `shared` records have no fresh job ID and cannot claim mutable +job artifacts. The example applies cache lookup only after dependencies succeed. +Changed leaf content invalidates that leaf; changed output changes its dependents' +input hashes. A failed dependency blocks its descendants before cache lookup. + +| Resource | Maximum | +| --- | --- | +| Workflow steps / concurrency | 64 / 8 | +| Plan / record / aggregate outputs | 1 MiB / 2 MiB / 1 MiB | +| Individual JSON result | 64 KiB | +| Workflow / step deadline | 10 minutes; step cannot exceed workflow | +| Partial-result retention | 24 hours | +| Durable workflow records / storage | 64 / 32 MiB | +| Fingerprinted files / each / total | 64 / 4 MiB / 16 MiB | +| Cache entries / retained value bytes | 512 / 16 MiB | +| Concurrent cache fills / fill timeout | 8 / 60 seconds | + +The Node cache store additionally bounds envelope storage to 64 MiB. Schema files +cover definitions, plans, records, fingerprints and cache entries; runtime parsers +also enforce cross-field, byte-budget and hash constraints. See [tool streams](TOOL_STREAMS.md) +for registered tool execution and [LSP](LSP.md) for language-server integration. diff --git a/docs/api.md b/docs/api.md index 3271358..42c21e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -349,3 +349,12 @@ for complete contracts, limits and independent examples. ## Release tools (1.1, Node only) See [release tools](RELEASE_TOOLS.md) for the complete bundle, Ed25519 provenance, approval/activation journal and upstream candidate APIs. The five entry points provide declarations and versioned schemas without adding SDK runtime dependencies. + +## Workflows, cache and tools (1.2) + +See [workflows and cache](WORKFLOWS.md) for DAG plans, per-step command jobs, +durable checkpoints, explicit resume and deterministic incremental reuse. +[Tool streams](TOOL_STREAMS.md) covers the portable parser and optional Node +process runner. [LSP](LSP.md) maps the public stdio bridge's negotiated subset to +existing language contracts. Seven additional exports have declarations and +eleven schemas; only the tool-output parser is portable to a browser. diff --git a/examples/lsp/run.mjs b/examples/lsp/run.mjs new file mode 100644 index 0000000..9e1dbf2 --- /dev/null +++ b/examples/lsp/run.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,realpath,writeFile,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {createLspBridge} from '@altifigence/dds-plugin-sdk/lsp-node'; +import {hashToolFile} from '@altifigence/dds-plugin-sdk/tool-streams-node'; +const root=await realpath(await mkdtemp(path.join(tmpdir(),'dds-lsp-example-'))),file=path.join(root,'sample.ts'),scope={projectId:'example',sessionId:'operator'}; +const fixture=fileURLToPath(new URL('./synthetic-server.mjs',import.meta.url)); +function argument(flag){const at=process.argv.indexOf(flag);if(at<0)return null;const value=process.argv[at+1];if(!value||!path.isAbsolute(value))throw Error('Pass an absolute, operator-approved installation for '+flag);return value;} +const tsRoot=argument('--typescript-root'),lsRoot=argument('--language-server-root'); +if(!!tsRoot!==!!lsRoot)throw Error('The actual server example needs both --typescript-root and --language-server-root'); +async function pinned(script,version,args,expectedVersionOutput,files=[]){return {id:'example-lsp',version,approved:true,executable:process.execPath,executableSha256:await hashToolFile(process.execPath),files:await Promise.all([script,...files].map(async file=>({path:file,sha256:await hashToolFile(file)}))),args:[script,...args],versionArgs:[script,'--version'],expectedVersionOutput,environment:{}};} +let bridge,counter=0; +const request=(snapshot,kind,extra={})=>({protocolVersion:1,requestId:'example-'+(++counter),scope,snapshot,kind,...(['document-symbols','document-symbol-tree','semantic-tokens','folding-ranges','inlay-hints','format-document','format-range','code-actions'].includes(kind)?{}:{position:{line:0,character:7}}),...extra}); +try{ + const text='const value = 1;\nfunction add(a: number, b: number) {\n return a + b;\n}\nadd(value, 2);\n';await writeFile(file,text); + const snapshot={uri:pathToFileURL(file).href,languageId:'typescript',modelVersion:1,workspaceRevision:'example',text},diagnostics=[]; + bridge=await createLspBridge({process:await pinned(fixture,'1.0.0',['normal'],'DDS LSP fixture 1.0.0'),workspaceRoot:root,scope,authorize:()=>true,expectedServerName:'DDS LSP fixture',onDiagnostics:result=>{diagnostics.push(result);}}); + const span={start:{line:0,character:0},end:{line:4,character:14}},checks=[['completion',{}],['hover',{}],['definition',{}],['references',{includeDeclaration:true}],['document-symbols',{}],['signature-help',{}],['prepare-rename',{}],['rename',{newName:'renamed'}],['format-document',{path:'sample.ts',formatOptions:{tabSize:2,insertSpaces:true}}],['format-range',{path:'sample.ts',range:{start:{line:0,character:0},end:{line:0,character:16}},formatOptions:{tabSize:2,insertSpaces:true}}],['code-actions',{path:'sample.ts',range:span,diagnosticContext:{revision:1,diagnostics:[]}}],['semantic-tokens',{}],['folding-ranges',{}],['inlay-hints',{range:span}],['document-symbol-tree',{}]]; + for(const [kind,extra]of checks){const result=await bridge.request(request(snapshot,kind,extra));assert.equal(result.kind,kind);assert.notEqual(result.data,null);} + assert.ok(diagnostics.length);assert.equal(await readFile(file,'utf8'),text); + const syntheticClose=await bridge.close();bridge=null;assert.equal(syntheticClose.graceful,true);assert.equal(syntheticClose.ownedProcessClosed,true); + let actualServer=null; + if(tsRoot){ + const tsserver=path.join(tsRoot,'lib','tsserver.js'),typescript=path.join(tsRoot,'lib','typescript.js'),cli=path.join(lsRoot,'lib','cli.mjs'); + const actualText='export const sample = { alpha: 1, beta: "two" };\nexport function add(a: number, b: number) { return a + b; }\nconst total = add(sample.alpha, 2);\nsample.alpha;\n';await writeFile(file,actualText); + const actual={...snapshot,text:actualText,modelVersion:2,workspaceRevision:'actual-server'}; + bridge=await createLspBridge({process:await pinned(cli,'5.3.0',['--stdio'],'5.3.0',[tsserver,typescript]),workspaceRoot:root,scope,authorize:()=>true,requestTimeoutMs:15000,initializationOptions:{hostInfo:'DDS Plugin SDK example',disableAutomaticTypingAcquisition:true,tsserver:{path:tsserver}}}); + const completion=await bridge.request(request(actual,'completion',{position:{line:3,character:7}}));assert.ok(completion.data.some(item=>item.label==='alpha')); + const hover=await bridge.request(request(actual,'hover',{position:{line:2,character:26}}));assert.match(hover.data.text,/number/); + const definition=await bridge.request(request(actual,'definition',{position:{line:2,character:26}}));assert.ok(definition.data.some(item=>item.path==='sample.ts')); + const references=await bridge.request(request(actual,'references',{position:{line:0,character:14},includeDeclaration:true}));assert.ok(references.data.length>=2); + const symbols=await bridge.request(request(actual,'document-symbols'));assert.ok(symbols.data.some(item=>item.name==='sample')); + const signature=await bridge.request(request(actual,'signature-help',{position:{line:2,character:31}}));assert.ok(signature.data.signatures[0].label.includes('add')); + await bridge.request(request(actual,'format-document',{path:'sample.ts',formatOptions:{tabSize:2,insertSpaces:true}})); + const semantic=await bridge.request(request(actual,'semantic-tokens'));assert.ok(semantic.data.data.length>0);assert.equal(await readFile(file,'utf8'),actualText); + const close=await bridge.close();bridge=null;assert.equal(close.ownedProcessClosed,true);assert.equal(close.pendingRequests,0); + actualServer={serverVersion:'5.3.0',typescriptVersion:'5.9.3',verifiedFeatures:8,unversionedDiagnostics:'unsupported'}; + } + console.log(JSON.stringify({verified:true,syntheticFeatures:15,versionedDiagnostics:true,filesUnchanged:true,actualServer,ownedProcessesClosed:true})); +}finally{await bridge?.close();assert.equal(path.dirname(root),await realpath(tmpdir()));assert.ok(path.basename(root).startsWith('dds-lsp-example-'));await rm(root,{recursive:true});} diff --git a/examples/lsp/synthetic-server.mjs b/examples/lsp/synthetic-server.mjs new file mode 100644 index 0000000..8fe22c2 --- /dev/null +++ b/examples/lsp/synthetic-server.mjs @@ -0,0 +1,46 @@ +// Independent public LSP fixture. It deliberately does not import the SDK parser or adapters. +if(process.argv.includes('--version')){process.stdout.write('DDS LSP fixture 1.0.0\n');process.exit(0);} +const mode=process.argv[2]??'normal',documents=new Map();let buffer=Buffer.alloc(0),shutDown=false; +const range=(line,start,end)=>({start:{line,character:start},end:{line,character:end}}); +function send(message){const bytes=Buffer.from(JSON.stringify({jsonrpc:'2.0',...message}));process.stdout.write(`Content-Length: ${bytes.length}\r\n\r\n`);process.stdout.write(bytes);} +const result=(id,value)=>send({id,result:value}); +const pause=ms=>new Promise(r=>setTimeout(r,ms)); +async function receive(message){ + const {id,method,params}=message; + if(method==='initialize'){ + if(mode==='frame'){process.stdout.write('Content-Length: nope\r\n\r\n{}');return;} + result(id,{serverInfo:{name:'DDS LSP fixture',version:'1.0.0'},capabilities:{positionEncoding:mode==='encoding'?'utf-8':'utf-16',textDocumentSync:{openClose:true,change:2},completionProvider:{resolveProvider:false},hoverProvider:mode!=='unsupported',definitionProvider:true,referencesProvider:true,documentSymbolProvider:true,signatureHelpProvider:{triggerCharacters:['(',',']},renameProvider:{prepareProvider:true},documentFormattingProvider:true,documentRangeFormattingProvider:true,codeActionProvider:true,semanticTokensProvider:{full:true,legend:{tokenTypes:['keyword','variable'],tokenModifiers:[]}},foldingRangeProvider:true,inlayHintProvider:true}});return; + } + if(method==='initialized'){if(mode==='server-request')send({id:'server-edit',method:'workspace/applyEdit',params:{edit:{changes:{}}}});return;} + if(method==='textDocument/didOpen'||method==='textDocument/didChange'){ + const doc=method.endsWith('didOpen')?params.textDocument:{...documents.get(params.textDocument.uri),version:params.textDocument.version,text:params.contentChanges[0].text};documents.set(doc.uri,doc); + send({method:'textDocument/publishDiagnostics',params:{uri:doc.uri,...(mode==='unversioned'?{}:{version:doc.version}),diagnostics:[{range:range(0,6,11),severity:2,message:'Fixture model '+doc.version,source:'fixture'}]}});return; + } + if(method==='textDocument/didClose'){documents.delete(params.textDocument.uri);return;} + if(method==='$/cancelRequest')return; + if(method==='shutdown'){if(mode==='shutdown'){send({id,error:{code:-32000,message:'Synthetic shutdown refusal'}});return;}shutDown=true;result(id,null);return;} + if(method==='exit'){process.exit(shutDown?0:1);} + if(id===undefined||!method)return; + const uri=params?.textDocument?.uri,doc=documents.get(uri),nameRange=range(0,6,11); + if(method==='textDocument/hover'){ + if(mode==='crash'){process.exit(7);}if(mode==='timeout')return;if(mode==='late')await pause(100); + const value={contents:{kind:'plaintext',value:'value: number'},range:mode==='invalid-range'?range(99,0,1):nameRange};result(id,value);if(mode==='duplicate')result(id,value);return; + } + if(method==='textDocument/completion'){result(id,{isIncomplete:false,items:[{label:'value',insertText:'value',detail:'local number'}]});return;} + if(method==='textDocument/definition'||method==='textDocument/references'){result(id,[{uri:mode==='external'?new URL('../outside.ts',uri).href:uri,range:nameRange}]);return;} + if(method==='textDocument/documentSymbol'){result(id,[{name:'value',kind:13,range:range(0,0,16),selectionRange:nameRange}]);return;} + if(method==='textDocument/signatureHelp'){result(id,{signatures:[{label:'add(a: number, b: number): number',parameters:[{label:'a: number'},{label:'b: number'}]}],activeSignature:0,activeParameter:1});return;} + if(method==='textDocument/prepareRename'){result(id,{range:nameRange,placeholder:'value'});return;} + if(method==='textDocument/rename'){result(id,{documentChanges:[{textDocument:{uri:mode==='outside-edit'?new URL('../outside.ts',uri).href:uri,version:doc.version},edits:[{range:nameRange,newText:params.newName}]}]});return;} + if(method==='textDocument/formatting'||method==='textDocument/rangeFormatting'){result(id,[{range:range(0,0,16),newText:'const value=1;'}]);return;} + if(method==='textDocument/codeAction'){result(id,[{title:'Change value',kind:'quickfix',edit:{changes:{[uri]:[{range:range(0,14,15),newText:'2'}]}}},{title:'Run external command',kind:'quickfix',command:{title:'External',command:'not-permitted'}}]);return;} + if(method==='textDocument/semanticTokens/full'){result(id,{resultId:'fixture-tokens',data:[0,0,5,0,0,0,6,5,1,0]});return;} + if(method==='textDocument/foldingRange'){result(id,[{startLine:1,startCharacter:0,endLine:3,endCharacter:1,kind:'region'}]);return;} + if(method==='textDocument/inlayHint'){result(id,[{position:{line:0,character:11},label:': number',kind:1}]);return;} + send({id,error:{code:-32601,message:'Unsupported fixture request'}}); +} +process.stdin.on('data',chunk=>{ + buffer=Buffer.concat([buffer,chunk]); + while(true){const end=buffer.indexOf('\r\n\r\n');if(end<0)break;const match=/Content-Length: (\d+)/i.exec(buffer.subarray(0,end).toString());if(!match)process.exit(2);const length=Number(match[1]);if(buffer.lengthprocess.exit(3));} +}); +process.stdin.on('end',()=>process.exit(shutDown?0:1)); diff --git a/examples/tool-streams-browser/browser.html b/examples/tool-streams-browser/browser.html new file mode 100644 index 0000000..b88fff3 --- /dev/null +++ b/examples/tool-streams-browser/browser.html @@ -0,0 +1,14 @@ + +DDS tool streams + +
DIGITAL DESIGN STUDIO · PLUGIN SDK

Tool output, one event at a time

+

Decode fragmented UTF-8 output into logs, progress and diagnostics. This example runs the parser in your browser. Starting external tools requires a Node host.

+
ReadyInput includes Korean text, a masked sample value, an invalid line and a final line without a newline.
+

Events

    + diff --git a/examples/tool-streams-browser/browser.mjs b/examples/tool-streams-browser/browser.mjs new file mode 100644 index 0000000..186bda4 --- /dev/null +++ b/examples/tool-streams-browser/browser.mjs @@ -0,0 +1,29 @@ +import {createToolStreamParser} from '/sdk/tool-streams.mjs'; +const run=document.querySelector('#run'),cancel=document.querySelector('#cancel'),status=document.querySelector('#status'),events=document.querySelector('#events'),receipt=document.querySelector('#receipt'); +const encoder=new TextEncoder(),context={jobId:'browser-job',commandId:'analyze',toolId:'example',toolVersion:'1.0.0'}; +const check=(condition,message)=>{if(!condition)throw Error(message);}; +function reset(){run.disabled=cancel.disabled=true;events.replaceChildren();receipt.textContent='';status.dataset.state='running';status.textContent='Reading output…';} +function show(event){const row=document.createElement('li');row.textContent=`${event.sequence} · ${event.data.kind} · ${event.data.message??`${event.data.completed}/${event.data.total}`}`;events.append(row);} +async function verify(cancellation){ + reset();let parser; + try{ + const received=[],controller=new AbortController(); + parser=createToolStreamParser({...context,signal:controller.signal,secrets:['sample-mask-value'],onEvent:async event=>{received.push(event);show(event);await Promise.resolve();}}); + if(cancellation){ + await parser.write(encoder.encode('{"kind":"log","level":"info","message":"First event received"}\n'));controller.abort(); + let stopped=false;try{await parser.write(encoder.encode('must not appear'));}catch(error){stopped=error.code==='CANCELLED';} + check(stopped&&received.length===1,'Cancellation did not stop the stream'); + receipt.textContent=JSON.stringify({verified:true,cancelled:true,events:received.length},null,2); + }else{ + const input=[{kind:'log',level:'info',message:'한글 sample-mask-value'},{kind:'progress',completed:1,total:2},{kind:'diagnostic',path:'sample.ts',range:{start:{line:0,character:6},end:{line:0,character:11}},severity:'error',message:'Example type mismatch',code:'TS2322'},'invalid-json-line',{kind:'log',level:'info',message:'Final partial line'}]; + const bytes=encoder.encode(input.map(value=>typeof value==='string'?value:JSON.stringify(value)).join('\n')); + for(let offset=0;offsetvoid verify(false));cancel.addEventListener('click',()=>void verify(true)); diff --git a/examples/tool-streams-browser/serve.mjs b/examples/tool-streams-browser/serve.mjs new file mode 100644 index 0000000..b1b765a --- /dev/null +++ b/examples/tool-streams-browser/serve.mjs @@ -0,0 +1,19 @@ +import {readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createServer} from 'node:http'; +const here=path.dirname(fileURLToPath(import.meta.url)),source=path.dirname(fileURLToPath(import.meta.resolve('@altifigence/dds-plugin-sdk/tool-streams'))); +const server=createServer(async(req,res)=>{ + try{ + if(req.method!=='GET'){res.writeHead(405);res.end();return;} + let file,type='text/javascript; charset=utf-8'; + if(req.url==='/'){file=path.join(here,'browser.html');type='text/html; charset=utf-8';} + else if(req.url==='/browser.mjs')file=path.join(here,'browser.mjs'); + else if(/^\/sdk\/[a-z0-9-]+\.mjs$/.test(req.url))file=path.join(source,req.url.slice(5)); + else{res.writeHead(404);res.end();return;} + const bytes=await readFile(file);res.writeHead(200,{'content-type':type,'cache-control':'no-store','x-content-type-options':'nosniff'});res.end(bytes); + }catch{res.writeHead(404);res.end();} +}); +await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);}); +console.log('Tool stream browser example: http://127.0.0.1:'+server.address().port); +let closed=false;for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>{if(!closed){closed=true;server.close();server.closeAllConnections();}}); diff --git a/examples/workflow-tools/run.mjs b/examples/workflow-tools/run.mjs new file mode 100644 index 0000000..c80f5fb --- /dev/null +++ b/examples/workflow-tools/run.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,realpath,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createToolRunner,hashToolFile} from '@altifigence/dds-plugin-sdk/tool-streams-node'; +const root=await realpath(await mkdtemp(path.join(tmpdir(),'dds-tool-example-'))); +const fixture=fileURLToPath(new URL('./synthetic-tool.mjs',import.meta.url)); +const flag=process.argv.indexOf('--typescript-root'),typescriptRoot=flag<0?null:process.argv[flag+1]; +if(flag>=0&&(!typescriptRoot||!path.isAbsolute(typescriptRoot)))throw Error('Pass an absolute, operator-approved TypeScript 5.9.3 installation'); +async function pinned(id,version,script,args,versionArgs,expectedVersionOutput,files=[]){return {id,version,approved:true,executable:process.execPath,executableSha256:await hashToolFile(process.execPath),files:await Promise.all([script,...files].map(async file=>({path:file,sha256:await hashToolFile(file)}))),args:[script,...args],versionArgs:[script,...versionArgs],expectedVersionOutput,environment:{}};} +const schema=properties=>({schemaVersion:1,schema:{type:'object',properties,required:Object.keys(properties),additionalProperties:false}}); +let runner; +try{ + const registrations=[{process:await pinned('synthetic','1.0.0',fixture,[{input:'mode'}],['--version'],'DDS synthetic tool 1.0.0'),backendId:'analyze',inputSchema:schema({mode:{type:'string',enum:['normal','crash']}}),pathInputs:[],format:'jsonl',parser:'plain'}]; + if(typescriptRoot){ + const script=path.join(typescriptRoot,'bin','tsc'); + registrations.push({process:await pinned('typescript','5.9.3',script,['--noEmit','--noLib','--pretty','false',{input:'source'}],['--version'],'Version 5.9.3',[path.join(typescriptRoot,'lib','_tsc.js')]),backendId:'typecheck',inputSchema:schema({source:{type:'string',maxLength:180}}),pathInputs:['source'],format:'text',parser:'typescript'}); + await writeFile(path.join(root,'sample.ts'),'interface Array {length:number; [n:number]:T} interface Boolean {} interface Function {} interface CallableFunction {} interface NewableFunction {} interface IArguments {} interface Number {} interface Object {} interface RegExp {} interface String {}\nconst count: number = "wrong";\n'); + } + runner=createToolRunner({tools:registrations,workspaceRoot:root,authorize:()=>true,secrets:['mask-demo-value']});const events=[]; + const request={toolId:'synthetic',version:'1.0.0',input:{mode:'normal'},jobId:'example-job',commandId:'analyze',onEvent:event=>{events.push(event);}}; + const receipt=await runner.run(request);assert.equal(receipt.state,'succeeded');assert.equal(events.at(-1).data.message,'last partial line');assert.ok(events.some(e=>e.data.kind==='progress'));assert.ok(events.some(e=>e.data.message==='한글 output'));assert.ok(!JSON.stringify(events).includes('mask-demo-value'));assert.ok(!JSON.stringify(events).includes(root)); + const nonzero=await runner.run({...request,input:{mode:'crash'}});assert.equal(nonzero.exitCode,7);assert.equal(nonzero.state,'failed'); + let actualCompiler=null; + if(typescriptRoot){events.length=0;const compiler=await runner.run({...request,toolId:'typescript',version:'5.9.3',commandId:'typecheck',input:{source:'sample.ts'}});assert.equal(compiler.reason,'NONZERO_EXIT');assert.ok(events.some(e=>e.data.kind==='diagnostic'&&e.data.code==='TS2322'&&e.data.path==='sample.ts'));actualCompiler={version:'5.9.3',diagnostic:'TS2322',zeroBased:true};} + await runner.close();assert.deepEqual(runner.inspect(),{active:0,pending:0,closed:true}); + console.log(JSON.stringify({verified:true,fragmentedUtf8:true,trailingLine:true,redacted:true,nonzeroExit:7,actualCompiler,ownedProcessesClosed:true})); +}finally{await runner?.close();assert.equal(path.dirname(root),await realpath(tmpdir()));assert.ok(path.basename(root).startsWith('dds-tool-example-'));await rm(root,{recursive:true});} diff --git a/examples/workflow-tools/synthetic-tool.mjs b/examples/workflow-tools/synthetic-tool.mjs new file mode 100644 index 0000000..e8ed9a9 --- /dev/null +++ b/examples/workflow-tools/synthetic-tool.mjs @@ -0,0 +1,16 @@ +// Public redistributable fixture; the operator pins this script and the Node executable. +if(process.argv.includes('--version')){process.stdout.write('DDS synthetic tool 1.0.0\n');process.exit(0);} +const mode=process.argv[2]??'normal'; +if(mode==='slow'){setInterval(()=>process.stdout.write(JSON.stringify({kind:'log',level:'info',message:'working'})+'\n'),25);} +else if(mode==='crash'){process.stdout.write(JSON.stringify({kind:'log',level:'info',message:'before crash'})+'\n');process.exitCode=7;} +else if(mode==='long'){process.stdout.write('x'.repeat(20_000));} +else if(mode==='flood'){for(let i=0;i<5000;i++)process.stdout.write(JSON.stringify({kind:'log',level:'info',message:'bounded event'})+'\n');} +else { + const bytes=Buffer.from(JSON.stringify({kind:'log',level:'info',message:'한글 output'})+'\n'); + const split=bytes.indexOf(Buffer.from('한'))+1;process.stdout.write(bytes.subarray(0,split)); + await new Promise(r=>setTimeout(r,5));process.stdout.write(bytes.subarray(split)); + process.stdout.write(JSON.stringify({kind:'progress',completed:1,total:1,message:'done'})+'\n'); + process.stdout.write(JSON.stringify({kind:'diagnostic',path:'sample.ts',range:{start:{line:0,character:2},end:{line:0,character:3}},severity:'warning',message:'Synthetic warning',source:'synthetic'})+'\n'); + process.stderr.write(JSON.stringify({kind:'log',level:'error',message:'mask-demo-value '+process.cwd()})+'\n'); + process.stdout.write(JSON.stringify({kind:'log',level:'info',message:'last partial line'})); +} diff --git a/examples/workflows/fixture.mjs b/examples/workflows/fixture.mjs new file mode 100644 index 0000000..cbcd985 --- /dev/null +++ b/examples/workflows/fixture.mjs @@ -0,0 +1,13 @@ +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +const number={type:'integer',minimum:0,maximum:10000}; +const object=properties=>({schemaVersion:1,schema:{type:'object',properties,required:Object.keys(properties),additionalProperties:false}}); +export const scope=Object.freeze({workspaceId:'example',securityScope:'operator'}); +export const pluginSha256=createHash('sha256').update(await readFile(new URL(import.meta.url))).digest('hex'); +export const commands=[ + {id:'measure',pluginId:'workflow-example',pluginSha256,inputSchema:object({source:{type:'string',maxLength:180}}),outputSchema:object({value:number}),grants:[]}, + {id:'combine',pluginId:'workflow-example',pluginSha256,inputSchema:object({left:number,right:number}),outputSchema:object({value:number}),grants:[]}, +]; +const measure=(id,source)=>({id,commandId:'measure',needs:[],input:{source:{value:source}},grants:[]}); +export const definition={schemaVersion:1,id:'example-build',scope,steps:[measure('left','left.txt'),measure('right','right.txt'),{id:'sum',commandId:'combine',needs:['left','right'],input:{left:{step:'left',path:['value']},right:{step:'right',path:['value']}},grants:[]}],policy:'continue',concurrency:2,timeoutMs:10000,stepTimeoutMs:5000,retentionMs:60000}; +export const recoveryDefinition={...definition,id:'recovery',steps:[measure('left','left.txt'),{...measure('right','right.txt'),needs:['left']}]}; diff --git a/examples/workflows/interrupted-child.mjs b/examples/workflows/interrupted-child.mjs new file mode 100644 index 0000000..122d5d4 --- /dev/null +++ b/examples/workflows/interrupted-child.mjs @@ -0,0 +1,13 @@ +// A disposable example child. The parent owns its lifetime and kills it after a durable checkpoint. +import {createNodeWorkflowStore} from '@altifigence/dds-plugin-sdk/workflows-node'; +import {prepareWorkflowPlan,createWorkflowRunner} from '@altifigence/dds-plugin-sdk/workflows'; +import {commands,recoveryDefinition} from './fixture.mjs'; +const options=JSON.parse(process.argv[2]),store=await createNodeWorkflowStore(options); +const plan=prepareWorkflowPlan(recoveryDefinition,commands); +const hold=setInterval(()=>{},1000); +try{ + await createWorkflowRunner({commands,store,authorize:()=>true,execute:async(step,_,__,context)=>{ + if(step.id==='right'){process.send({ready:true,attemptId:context.attemptId});await new Promise(()=>{});} + return {output:{value:10}}; + }}).run(plan,{approval:{approved:true,planSha256:plan.sha256}}); +}finally{clearInterval(hold);await store.close();} diff --git a/examples/workflows/run.mjs b/examples/workflows/run.mjs new file mode 100644 index 0000000..7b769d9 --- /dev/null +++ b/examples/workflows/run.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,realpath,readFile,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {createPluginHost,definePlugin} from '@altifigence/dds-plugin-sdk'; +import {prepareWorkflowPlan,createWorkflowRunner,createCommandJobExecutor} from '@altifigence/dds-plugin-sdk/workflows'; +import {createNodeWorkflowStore} from '@altifigence/dds-plugin-sdk/workflows-node'; +import {fingerprintWorkflowInputs,createWorkflowCache} from '@altifigence/dds-plugin-sdk/workflow-cache'; +import {createNodeWorkflowCacheStore} from '@altifigence/dds-plugin-sdk/workflow-cache-node'; +import {scope,pluginSha256,commands,definition,recoveryDefinition} from './fixture.mjs'; + +const parent=await realpath(await mkdtemp(path.join(tmpdir(),'dds-workflow-example-'))),workspaceRoot=path.join(parent,'workspace'); +let store,cacheStore,cache,host,child; +try{ + for(const name of ['workspace','workflows','cache','recovery'])await mkdir(path.join(parent,name)); + await writeFile(path.join(workspaceRoot,'left.txt'),'alpha');await writeFile(path.join(workspaceRoot,'right.txt'),'beta'); + const hostScope={projectId:'example',sessionId:'operator'},executed=[]; + host=createPluginHost({jobs:true,grants:[],scope:hostScope}); + await host.activate(definePlugin({manifestVersion:2,id:'workflow-example',name:'Workflow example',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./run.mjs',runtime:'workspace',capabilities:['commands'],permissions:[],supportedHosts:['test-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}},context=>{ + context.registerCommand({id:'measure',title:'Measure an example file'},async input=>{ + assert.ok(['left.txt','right.txt'].includes(input.source));executed.push(input.source); + const contents=await readFile(path.join(workspaceRoot,input.source),'utf8');if(contents==='FAIL')throw Error('Requested example failure'); + return {value:contents.length}; + }); + context.registerCommand({id:'combine',title:'Add measurements'},input=>{executed.push('sum');return {value:input.left+input.right};}); + })); + const storeOptions={directory:path.join(parent,'workflows'),workspaceRoot,scope}; + store=await createNodeWorkflowStore(storeOptions); + cacheStore=await createNodeWorkflowCacheStore({directory:path.join(parent,'cache'),workspaceRoot,scope}); + cache=createWorkflowCache({store:cacheStore,authorize:()=>true,ttlMs:60000}); + const execute=createCommandJobExecutor({resolveHost:({grants})=>({host,scope:hostScope,grants,pluginSha256})}); + const runner=createWorkflowRunner({commands,store,authorize:()=>true,execute:async(step,command,input,context)=>{ + const files=command.id==='measure'?[{path:input.source,bytes:await readFile(path.join(workspaceRoot,input.source))}]:[]; + const fingerprint=fingerprintWorkflowInputs({scope,commandId:command.id,pluginSha256,toolSha256:null,schema:command,settings:{},environment:{declared:[],values:{}},input,files,grants:step.grants,policy:{optIn:true,deterministic:true,declaredInputsComplete:true,secretDependent:false}}); + const result=await cache.run(fingerprint,{signal:context.signal,compute:async({signal})=>(await execute(step,command,input,{...context,signal})).output}); + return {output:result.value,cache:{key:fingerprint.key,state:result.state,reason:result.reason}}; + }}); + const plan=prepareWorkflowPlan(definition,commands),options={approval:{approved:true,planSha256:plan.sha256}}; + let started=performance.now();const first=await runner.run(plan,options),firstMs=performance.now()-started;assert.equal(first.state,'succeeded');assert.equal(first.steps[2].output.value,9);assert.equal(executed.length,3); + executed.length=0;started=performance.now();const repeated=await runner.run(plan,options),hitMs=performance.now()-started;assert.equal(executed.length,0);assert.ok(repeated.steps.every(s=>s.cache.state==='hit'&&s.jobId===null)); + await writeFile(path.join(workspaceRoot,'left.txt'),'changed');const incremental=await runner.run(plan,options); + assert.deepEqual(executed,['left.txt','sum']);assert.deepEqual(incremental.steps.map(s=>s.cache.state),['miss','hit','miss']);assert.equal(incremental.steps[2].output.value,11); + // A cached parent never runs before its dependencies have succeeded in this attempt. + executed.length=0;await writeFile(path.join(workspaceRoot,'left.txt'),'FAIL');const failed=await runner.run(plan,options); + assert.deepEqual(failed.steps.map(s=>s.state),['failed','succeeded','blocked']);assert.deepEqual(executed,['left.txt']);assert.equal(failed.steps[2].cache,null); + await store.close();store=await createNodeWorkflowStore(storeOptions);assert.deepEqual(await store.read(incremental.attemptId),incremental); + const metrics={firstMs,hitMs,cache:cache.inspect(),storedBytes:cacheStore.inspect().bytes}; + await cache.close();cache=null;await cacheStore.close();cacheStore=null;await store.close();store=null;host.dispose();host=null; + + const recoveryOptions={directory:path.join(parent,'recovery'),workspaceRoot,scope}; + child=spawn(process.execPath,[fileURLToPath(new URL('./interrupted-child.mjs',import.meta.url)),JSON.stringify(recoveryOptions)],{windowsHide:true,stdio:['ignore','ignore','pipe','ipc']}); + let timeout;const exited=once(child,'exit');let ready; + try{[ready]=await Promise.race([once(child,'message'),exited.then(()=>{throw Error('Example child exited before checkpoint');}),new Promise((_,reject)=>{timeout=setTimeout(()=>reject(Error('Checkpoint timeout')),10000);})]);}finally{clearTimeout(timeout);} + assert.equal(ready.ready,true);child.kill('SIGKILL');await exited;child=null; + await assert.rejects(createNodeWorkflowStore(recoveryOptions),error=>error.code==='CONFLICT'); + store=await createNodeWorkflowStore({...recoveryOptions,recoverStaleLock:true});let retries=0; + const resumedRunner=createWorkflowRunner({commands,store,authorize:()=>true,execute:async()=>{retries++;return {output:{value:20}};}}); + const interrupted=await resumedRunner.recover(ready.attemptId);assert.equal(interrupted.state,'interrupted');assert.deepEqual(interrupted.steps.map(s=>s.state),['succeeded','interrupted']);assert.equal(retries,0); + const recoveryPlan=prepareWorkflowPlan(recoveryDefinition,commands); + const resumed=await resumedRunner.run(recoveryPlan,{previous:interrupted,approval:{approved:true,planSha256:recoveryPlan.sha256,previousAttemptId:interrupted.attemptId}}); + assert.equal(resumed.state,'succeeded');assert.equal(retries,1);assert.equal(resumed.steps[0].reason,'REUSED');assert.equal(resumed.steps[0].sourceAttemptId,interrupted.attemptId);assert.notEqual(resumed.attemptId,interrupted.attemptId); + console.log(JSON.stringify({verified:true,commandJobs:true,cacheHits:3,incrementalSteps:2,failedDependencyBlocked:true,killedChildRecovery:true,automaticReplay:false,explicitRetrySteps:retries,metrics})); +}finally{ + if(child&&child.exitCode===null&&child.signalCode===null){const exited=once(child,'exit');child.kill('SIGKILL');await exited;} + host?.dispose();await cache?.close();await cacheStore?.close();await store?.close(); + assert.equal(path.dirname(parent),await realpath(tmpdir()));assert.ok(path.basename(parent).startsWith('dds-workflow-example-'));await rm(parent,{recursive:true}); +} diff --git a/package-lock.json b/package-lock.json index b3065e7..e0f0683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@altifigence/dds-plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "bin": { "dds-plugin": "bin/dds-plugin.mjs" }, "devDependencies": { - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-language-server": "5.3.0" }, "engines": { "node": ">=22 <23 || >=24 <25" @@ -31,6 +32,19 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/typescript-language-server": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz", + "integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "typescript-language-server": "lib/cli.mjs" + }, + "engines": { + "node": ">=20" + } } } } diff --git a/package.json b/package.json index 034c188..2a35465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "Plugin, theme and user-owned workspace tools for Digital Design Studio", "type": "module", "license": "Apache-2.0", @@ -184,6 +184,34 @@ "./upstream": { "types": "./src/upstream.d.mts", "import": "./src/upstream.mjs" + }, + "./workflows": { + "types": "./src/workflows.d.mts", + "import": "./src/workflows.mjs" + }, + "./workflows-node": { + "types": "./src/workflows-node.d.mts", + "import": "./src/workflows-node.mjs" + }, + "./workflow-cache": { + "types": "./src/workflow-cache.d.mts", + "import": "./src/workflow-cache.mjs" + }, + "./workflow-cache-node": { + "types": "./src/workflow-cache-node.d.mts", + "import": "./src/workflow-cache-node.mjs" + }, + "./tool-streams": { + "types": "./src/tool-streams.d.mts", + "import": "./src/tool-streams.mjs" + }, + "./tool-streams-node": { + "types": "./src/tool-streams-node.d.mts", + "import": "./src/tool-streams-node.mjs" + }, + "./lsp-node": { + "types": "./src/lsp-node.d.mts", + "import": "./src/lsp-node.mjs" } }, "bin": { @@ -257,9 +285,14 @@ "example:conformance-browser": "node examples/conformance/serve.mjs", "example:combined": "node examples/combined-workload/run.mjs", "benchmark:combined": "node scripts/benchmark-combined.mjs", - "example:release-tools": "node examples/release-tools/run.mjs" + "example:release-tools": "node examples/release-tools/run.mjs", + "example:workflows": "node examples/workflows/run.mjs", + "example:workflow-tools": "node examples/workflow-tools/run.mjs", + "example:lsp": "node examples/lsp/run.mjs", + "example:tool-streams-browser": "node examples/tool-streams-browser/serve.mjs" }, "devDependencies": { - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-language-server": "5.3.0" } } diff --git a/schemas/artifact-sink-capabilities.schema.json b/schemas/artifact-sink-capabilities.schema.json index e5d9dea..65bd063 100644 --- a/schemas/artifact-sink-capabilities.schema.json +++ b/schemas/artifact-sink-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/artifact-sink-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/artifact-sink-capabilities.schema.json", "title": "artifact-sink-capabilities", "type": "object", "properties": { diff --git a/schemas/artifact-storage-capabilities.schema.json b/schemas/artifact-storage-capabilities.schema.json index 058264e..2564284 100644 --- a/schemas/artifact-storage-capabilities.schema.json +++ b/schemas/artifact-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/artifact-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/artifact-storage-capabilities.schema.json", "title": "artifact-storage-capabilities", "oneOf": [ { diff --git a/schemas/artifact-transfer-receipt.schema.json b/schemas/artifact-transfer-receipt.schema.json index 06313f9..0277044 100644 --- a/schemas/artifact-transfer-receipt.schema.json +++ b/schemas/artifact-transfer-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/artifact-transfer-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/artifact-transfer-receipt.schema.json", "title": "artifact-transfer-receipt", "type": "object", "properties": { diff --git a/schemas/binary-artifact-capabilities.schema.json b/schemas/binary-artifact-capabilities.schema.json index 87bfeb7..08e5ac0 100644 --- a/schemas/binary-artifact-capabilities.schema.json +++ b/schemas/binary-artifact-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/binary-artifact-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/binary-artifact-capabilities.schema.json", "title": "binary-artifact-capabilities", "type": "object", "properties": { diff --git a/schemas/binary-artifact-chunk.schema.json b/schemas/binary-artifact-chunk.schema.json index c737186..9789f4a 100644 --- a/schemas/binary-artifact-chunk.schema.json +++ b/schemas/binary-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/binary-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/binary-artifact-chunk.schema.json", "title": "binary-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/binary-artifact-list.schema.json b/schemas/binary-artifact-list.schema.json index e43451c..6e4e6e1 100644 --- a/schemas/binary-artifact-list.schema.json +++ b/schemas/binary-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/binary-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/binary-artifact-list.schema.json", "title": "binary-artifact-list", "type": "object", "properties": { diff --git a/schemas/binary-artifact-reference.schema.json b/schemas/binary-artifact-reference.schema.json index e4a26e0..41ecd04 100644 --- a/schemas/binary-artifact-reference.schema.json +++ b/schemas/binary-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/binary-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/binary-artifact-reference.schema.json", "title": "binary-artifact-reference", "type": "object", "properties": { diff --git a/schemas/binary-artifact.schema.json b/schemas/binary-artifact.schema.json index 7ab78c1..fa4edf5 100644 --- a/schemas/binary-artifact.schema.json +++ b/schemas/binary-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/binary-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/binary-artifact.schema.json", "title": "binary-artifact", "type": "object", "properties": { diff --git a/schemas/browser-artifact-checkpoint.schema.json b/schemas/browser-artifact-checkpoint.schema.json index 63bcaf5..5db3392 100644 --- a/schemas/browser-artifact-checkpoint.schema.json +++ b/schemas/browser-artifact-checkpoint.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/browser-artifact-checkpoint.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/browser-artifact-checkpoint.schema.json", "title": "browser-artifact-checkpoint", "type": "object", "properties": { diff --git a/schemas/bundle-dependency.schema.json b/schemas/bundle-dependency.schema.json index 84213b9..58a5dd2 100644 --- a/schemas/bundle-dependency.schema.json +++ b/schemas/bundle-dependency.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/bundle-dependency.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/bundle-dependency.schema.json", "title": "bundle-dependency", "type": "object", "properties": { diff --git a/schemas/code-action.schema.json b/schemas/code-action.schema.json index 4006eae..94e56e6 100644 --- a/schemas/code-action.schema.json +++ b/schemas/code-action.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/code-action.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/code-action.schema.json", "title": "code-action", "type": "object", "properties": { diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 77c1693..9cf4948 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/command.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/command.schema.json", "title": "command", "type": "object", "properties": { diff --git a/schemas/completion-item.schema.json b/schemas/completion-item.schema.json index 2eea20d..f1df92d 100644 --- a/schemas/completion-item.schema.json +++ b/schemas/completion-item.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/completion-item.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/completion-item.schema.json", "title": "completion-item", "type": "object", "properties": { diff --git a/schemas/conformance-report.schema.json b/schemas/conformance-report.schema.json index dfbd8b5..5b9b161 100644 --- a/schemas/conformance-report.schema.json +++ b/schemas/conformance-report.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/conformance-report.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/conformance-report.schema.json", "title": "conformance-report", "type": "object", "properties": { diff --git a/schemas/data-schema.schema.json b/schemas/data-schema.schema.json index fbd1c8b..5818689 100644 --- a/schemas/data-schema.schema.json +++ b/schemas/data-schema.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/data-schema.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/data-schema.schema.json", "title": "data-schema", "type": "object", "properties": { diff --git a/schemas/development-definition.schema.json b/schemas/development-definition.schema.json index b78ce2f..7a315cb 100644 --- a/schemas/development-definition.schema.json +++ b/schemas/development-definition.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/development-definition.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/development-definition.schema.json", "title": "development-definition", "type": "object", "properties": { @@ -20,18 +20,18 @@ "commands": { "type": "array", "items": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/command.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/command.schema.json" }, "maxItems": 64 }, "settings": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/settings-definition.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/settings-definition.schema.json" }, "catalog": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/locale-catalog.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/locale-catalog.schema.json" }, "theme": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/theme.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/theme.schema.json" } }, "required": [ diff --git a/schemas/diagnostic-report.schema.json b/schemas/diagnostic-report.schema.json index 4bfd9e9..21fbf4d 100644 --- a/schemas/diagnostic-report.schema.json +++ b/schemas/diagnostic-report.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/diagnostic-report.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/diagnostic-report.schema.json", "title": "diagnostic-report", "type": "object", "properties": { diff --git a/schemas/diagnostics-request.schema.json b/schemas/diagnostics-request.schema.json index bc5593e..a6c28b5 100644 --- a/schemas/diagnostics-request.schema.json +++ b/schemas/diagnostics-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/diagnostics-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/diagnostics-request.schema.json", "title": "diagnostics-request", "type": "object", "properties": { diff --git a/schemas/diagnostics-result.schema.json b/schemas/diagnostics-result.schema.json index e780c5f..5414159 100644 --- a/schemas/diagnostics-result.schema.json +++ b/schemas/diagnostics-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/diagnostics-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/diagnostics-result.schema.json", "title": "diagnostics-result", "type": "object", "properties": { diff --git a/schemas/display-metadata.schema.json b/schemas/display-metadata.schema.json index 436459c..5a6d7a4 100644 --- a/schemas/display-metadata.schema.json +++ b/schemas/display-metadata.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/display-metadata.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/display-metadata.schema.json", "title": "display-metadata", "type": "object", "properties": { diff --git a/schemas/document-symbol-tree.schema.json b/schemas/document-symbol-tree.schema.json index c5f45a1..f3e49cf 100644 --- a/schemas/document-symbol-tree.schema.json +++ b/schemas/document-symbol-tree.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/document-symbol-tree.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/document-symbol-tree.schema.json", "title": "document-symbol-tree", "type": "array", "items": { diff --git a/schemas/error.schema.json b/schemas/error.schema.json index f0a93c9..b729c29 100644 --- a/schemas/error.schema.json +++ b/schemas/error.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/error.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/error.schema.json", "title": "error", "type": "object", "properties": { diff --git a/schemas/folding-ranges.schema.json b/schemas/folding-ranges.schema.json index c138491..f7977a1 100644 --- a/schemas/folding-ranges.schema.json +++ b/schemas/folding-ranges.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/folding-ranges.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/folding-ranges.schema.json", "title": "folding-ranges", "type": "array", "items": { diff --git a/schemas/formatting-options.schema.json b/schemas/formatting-options.schema.json index f2ba2c7..b2e3361 100644 --- a/schemas/formatting-options.schema.json +++ b/schemas/formatting-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/formatting-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/formatting-options.schema.json", "title": "formatting-options", "type": "object", "properties": { diff --git a/schemas/generation-receipt.schema.json b/schemas/generation-receipt.schema.json index cb321a7..9e1eacb 100644 --- a/schemas/generation-receipt.schema.json +++ b/schemas/generation-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/generation-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/generation-receipt.schema.json", "title": "generation-receipt", "type": "object", "properties": { diff --git a/schemas/generation-report.schema.json b/schemas/generation-report.schema.json index 5a93bc8..6ba9bf8 100644 --- a/schemas/generation-report.schema.json +++ b/schemas/generation-report.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/generation-report.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/generation-report.schema.json", "title": "generation-report", "type": "object", "properties": { diff --git a/schemas/inlay-hints.schema.json b/schemas/inlay-hints.schema.json index c82295e..7cae456 100644 --- a/schemas/inlay-hints.schema.json +++ b/schemas/inlay-hints.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/inlay-hints.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/inlay-hints.schema.json", "title": "inlay-hints", "type": "array", "items": { diff --git a/schemas/job-artifact-content.schema.json b/schemas/job-artifact-content.schema.json index 210352c..36ddc3f 100644 --- a/schemas/job-artifact-content.schema.json +++ b/schemas/job-artifact-content.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-artifact-content.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-artifact-content.schema.json", "title": "job-artifact-content", "type": "object", "properties": { diff --git a/schemas/job-capabilities.schema.json b/schemas/job-capabilities.schema.json index 82e2042..73d1bf7 100644 --- a/schemas/job-capabilities.schema.json +++ b/schemas/job-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-capabilities.schema.json", "title": "job-capabilities", "type": "object", "properties": { diff --git a/schemas/job-events.schema.json b/schemas/job-events.schema.json index 5f9ea2a..9a019a2 100644 --- a/schemas/job-events.schema.json +++ b/schemas/job-events.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-events.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-events.schema.json", "title": "job-events", "type": "object", "properties": { diff --git a/schemas/job-history-page.schema.json b/schemas/job-history-page.schema.json index a65681c..6c3e0ff 100644 --- a/schemas/job-history-page.schema.json +++ b/schemas/job-history-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-history-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-history-page.schema.json", "title": "job-history-page", "type": "object", "properties": { diff --git a/schemas/job-history-query.schema.json b/schemas/job-history-query.schema.json index fa408d6..f98f428 100644 --- a/schemas/job-history-query.schema.json +++ b/schemas/job-history-query.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-history-query.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-history-query.schema.json", "title": "job-history-query", "type": "object", "properties": { diff --git a/schemas/job-options.schema.json b/schemas/job-options.schema.json index 643709f..6269555 100644 --- a/schemas/job-options.schema.json +++ b/schemas/job-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-options.schema.json", "title": "job-options", "type": "object", "properties": { diff --git a/schemas/job-recovery.schema.json b/schemas/job-recovery.schema.json index b88c836..e89041f 100644 --- a/schemas/job-recovery.schema.json +++ b/schemas/job-recovery.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-recovery.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-recovery.schema.json", "title": "job-recovery", "oneOf": [ { @@ -125,7 +125,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -691,7 +691,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -1257,7 +1257,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/job-snapshot.schema.json b/schemas/job-snapshot.schema.json index b709553..07fdca3 100644 --- a/schemas/job-snapshot.schema.json +++ b/schemas/job-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-snapshot.schema.json", "title": "job-snapshot", "oneOf": [ { @@ -305,7 +305,7 @@ "const": "succeeded" }, "result": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/json-value.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json" } }, "required": [ diff --git a/schemas/job-storage-capabilities.schema.json b/schemas/job-storage-capabilities.schema.json index 39c83be..fdf9bac 100644 --- a/schemas/job-storage-capabilities.schema.json +++ b/schemas/job-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-storage-capabilities.schema.json", "title": "job-storage-capabilities", "oneOf": [ { diff --git a/schemas/job-store-identity.schema.json b/schemas/job-store-identity.schema.json index c84003e..3ccf729 100644 --- a/schemas/job-store-identity.schema.json +++ b/schemas/job-store-identity.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-store-identity.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-store-identity.schema.json", "title": "job-store-identity", "type": "object", "properties": { diff --git a/schemas/json-value.schema.json b/schemas/json-value.schema.json index fa6adb3..58499bb 100644 --- a/schemas/json-value.schema.json +++ b/schemas/json-value.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/json-value.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json", "title": "json-value", "$ref": "#/$defs/value", "x-maxUtf8Bytes": 262144, diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index d27e600..8c6e080 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/language-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/language-capabilities.schema.json", "title": "language-capabilities", "type": "object", "properties": { diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index aa74568..f7cea45 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/language-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/language-request.schema.json", "title": "language-request", "oneOf": [ { diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index ce9ee1f..3aa5197 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/language-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/language-result.schema.json", "title": "language-result", "oneOf": [ { @@ -3178,7 +3178,7 @@ "const": "document-symbol-tree" }, "data": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/document-symbol-tree.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/document-symbol-tree.schema.json" } }, "required": [ diff --git a/schemas/language-snippet.schema.json b/schemas/language-snippet.schema.json index 5e89a27..f4a2157 100644 --- a/schemas/language-snippet.schema.json +++ b/schemas/language-snippet.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/language-snippet.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/language-snippet.schema.json", "title": "language-snippet", "type": "string", "minLength": 0, diff --git a/schemas/locale-catalog.schema.json b/schemas/locale-catalog.schema.json index 2cc246d..6b1ff65 100644 --- a/schemas/locale-catalog.schema.json +++ b/schemas/locale-catalog.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/locale-catalog.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/locale-catalog.schema.json", "title": "locale-catalog", "type": "object", "properties": { diff --git a/schemas/localized-message.schema.json b/schemas/localized-message.schema.json index 8d614b2..8f056da 100644 --- a/schemas/localized-message.schema.json +++ b/schemas/localized-message.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/localized-message.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/localized-message.schema.json", "title": "localized-message", "type": "object", "properties": { diff --git a/schemas/lsp-capability-matrix.schema.json b/schemas/lsp-capability-matrix.schema.json new file mode 100644 index 0000000..c5dc870 --- /dev/null +++ b/schemas/lsp-capability-matrix.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/lsp-capability-matrix.schema.json", + "title": "lsp-capability-matrix", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "protocol": { + "const": "3.17" + }, + "transport": { + "const": "stdio" + }, + "positionEncoding": { + "const": "utf-16" + }, + "features": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sdkFeature": { + "enum": [ + "completion", + "hover", + "definition", + "references", + "document-symbols", + "signature-help", + "prepare-rename", + "rename", + "format-document", + "format-range", + "code-actions", + "semantic-tokens", + "folding-ranges", + "inlay-hints", + "document-symbol-tree" + ] + }, + "method": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "supported": { + "type": "boolean" + }, + "reason": { + "enum": [ + "advertised", + "not-advertised" + ] + } + }, + "required": [ + "sdkFeature", + "method", + "supported", + "reason" + ], + "additionalProperties": false + }, + "maxItems": 15, + "minItems": 15 + }, + "unsupported": { + "const": [ + "completion-resolve", + "completion-snippets", + "code-action-resolve", + "semantic-tokens-delta", + "server-commands", + "dynamic-capabilities", + "resource-operations", + "sockets", + "unversioned-diagnostics" + ] + } + }, + "required": [ + "schemaVersion", + "protocol", + "transport", + "positionEncoding", + "features", + "unsupported" + ], + "additionalProperties": false, + "$comment": "Runtime verifies the exact ordered feature/method pairs and reason consistent with support. Matrix support reports negotiation, not successful execution of every server feature." +} diff --git a/schemas/lsp-close-receipt.schema.json b/schemas/lsp-close-receipt.schema.json new file mode 100644 index 0000000..af5e75a --- /dev/null +++ b/schemas/lsp-close-receipt.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/lsp-close-receipt.schema.json", + "title": "lsp-close-receipt", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "graceful": { + "type": "boolean" + }, + "reason": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + ] + }, + "ownedProcessClosed": { + "type": "boolean" + }, + "pendingRequests": { + "type": "integer", + "minimum": 0, + "maximum": 16 + } + }, + "required": [ + "schemaVersion", + "graceful", + "reason", + "ownedProcessClosed", + "pendingRequests" + ], + "additionalProperties": false +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 08e800e..475c849 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/manifest.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/manifest.schema.json", "title": "manifest", "properties": { "id": { diff --git a/schemas/memory-job-snapshot.schema.json b/schemas/memory-job-snapshot.schema.json index 80f9956..5f8ae6b 100644 --- a/schemas/memory-job-snapshot.schema.json +++ b/schemas/memory-job-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/memory-job-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/memory-job-snapshot.schema.json", "title": "memory-job-snapshot", "type": "object", "properties": { @@ -8,12 +8,12 @@ "const": 1 }, "identity": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-store-identity.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-store-identity.schema.json" }, "records": { "type": "array", "items": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-job.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-job.schema.json" }, "maxItems": 256 }, diff --git a/schemas/plugin-bundle.schema.json b/schemas/plugin-bundle.schema.json index 460165d..0207f46 100644 --- a/schemas/plugin-bundle.schema.json +++ b/schemas/plugin-bundle.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/plugin-bundle.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/plugin-bundle.schema.json", "title": "plugin-bundle", "type": "object", "properties": { diff --git a/schemas/plugin-plan.schema.json b/schemas/plugin-plan.schema.json index bb3511b..baa5cbb 100644 --- a/schemas/plugin-plan.schema.json +++ b/schemas/plugin-plan.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/plugin-plan.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/plugin-plan.schema.json", "title": "plugin-plan", "type": "object", "properties": { diff --git a/schemas/plugin-update-plan.schema.json b/schemas/plugin-update-plan.schema.json index 4ec985f..70baa1d 100644 --- a/schemas/plugin-update-plan.schema.json +++ b/schemas/plugin-update-plan.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/plugin-update-plan.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/plugin-update-plan.schema.json", "title": "plugin-update-plan", "type": "object", "properties": { diff --git a/schemas/plugin-update-record.schema.json b/schemas/plugin-update-record.schema.json index ab32ea4..94f3142 100644 --- a/schemas/plugin-update-record.schema.json +++ b/schemas/plugin-update-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/plugin-update-record.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/plugin-update-record.schema.json", "title": "plugin-update-record", "type": "object", "properties": { diff --git a/schemas/project-entry.schema.json b/schemas/project-entry.schema.json index 74dc011..98bc1ea 100644 --- a/schemas/project-entry.schema.json +++ b/schemas/project-entry.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-entry.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-entry.schema.json", "title": "project-entry", "type": "object", "properties": { diff --git a/schemas/project-query-capabilities.schema.json b/schemas/project-query-capabilities.schema.json index 18dd3c2..2b30f7e 100644 --- a/schemas/project-query-capabilities.schema.json +++ b/schemas/project-query-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-query-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-query-capabilities.schema.json", "title": "project-query-capabilities", "type": "object", "properties": { diff --git a/schemas/project-query-options.schema.json b/schemas/project-query-options.schema.json index bb53486..dbea51a 100644 --- a/schemas/project-query-options.schema.json +++ b/schemas/project-query-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-query-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-query-options.schema.json", "title": "project-query-options", "oneOf": [ { diff --git a/schemas/project-query-page.schema.json b/schemas/project-query-page.schema.json index ac4bc49..edca018 100644 --- a/schemas/project-query-page.schema.json +++ b/schemas/project-query-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-query-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-query-page.schema.json", "title": "project-query-page", "type": "object", "properties": { diff --git a/schemas/project-snapshot.schema.json b/schemas/project-snapshot.schema.json index 82fb340..a1575dc 100644 --- a/schemas/project-snapshot.schema.json +++ b/schemas/project-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-snapshot.schema.json", "title": "project-snapshot", "type": "object", "properties": { diff --git a/schemas/project-watch-capabilities.schema.json b/schemas/project-watch-capabilities.schema.json index 80bd735..6346643 100644 --- a/schemas/project-watch-capabilities.schema.json +++ b/schemas/project-watch-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-watch-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-watch-capabilities.schema.json", "title": "project-watch-capabilities", "type": "object", "properties": { diff --git a/schemas/project-watch-event.schema.json b/schemas/project-watch-event.schema.json index 914bda1..7eade73 100644 --- a/schemas/project-watch-event.schema.json +++ b/schemas/project-watch-event.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-watch-event.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-watch-event.schema.json", "title": "project-watch-event", "type": "object", "properties": { diff --git a/schemas/project-watch-options.schema.json b/schemas/project-watch-options.schema.json index eb3c2be..c117325 100644 --- a/schemas/project-watch-options.schema.json +++ b/schemas/project-watch-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/project-watch-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/project-watch-options.schema.json", "title": "project-watch-options", "type": "object", "properties": { diff --git a/schemas/provenance-envelope.schema.json b/schemas/provenance-envelope.schema.json index b85da12..e6baa36 100644 --- a/schemas/provenance-envelope.schema.json +++ b/schemas/provenance-envelope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/provenance-envelope.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/provenance-envelope.schema.json", "title": "provenance-envelope", "type": "object", "properties": { diff --git a/schemas/provenance-receipt.schema.json b/schemas/provenance-receipt.schema.json index 84e9ae9..3517252 100644 --- a/schemas/provenance-receipt.schema.json +++ b/schemas/provenance-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/provenance-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/provenance-receipt.schema.json", "title": "provenance-receipt", "type": "object", "properties": { diff --git a/schemas/provenance-statement.schema.json b/schemas/provenance-statement.schema.json index 69b84fe..1690be2 100644 --- a/schemas/provenance-statement.schema.json +++ b/schemas/provenance-statement.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/provenance-statement.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/provenance-statement.schema.json", "title": "provenance-statement", "type": "object", "properties": { diff --git a/schemas/secret-reference.schema.json b/schemas/secret-reference.schema.json index a41fb02..71da5b8 100644 --- a/schemas/secret-reference.schema.json +++ b/schemas/secret-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/secret-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/secret-reference.schema.json", "title": "secret-reference", "type": "object", "properties": { diff --git a/schemas/secret-scope.schema.json b/schemas/secret-scope.schema.json index 7173070..07697ea 100644 --- a/schemas/secret-scope.schema.json +++ b/schemas/secret-scope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/secret-scope.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/secret-scope.schema.json", "title": "secret-scope", "type": "object", "properties": { diff --git a/schemas/semantic-legend.schema.json b/schemas/semantic-legend.schema.json index 9ba82f7..f77fd3b 100644 --- a/schemas/semantic-legend.schema.json +++ b/schemas/semantic-legend.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/semantic-legend.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/semantic-legend.schema.json", "title": "semantic-legend", "type": "object", "properties": { diff --git a/schemas/semantic-tokens-delta.schema.json b/schemas/semantic-tokens-delta.schema.json index b700fbb..1a3cecf 100644 --- a/schemas/semantic-tokens-delta.schema.json +++ b/schemas/semantic-tokens-delta.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/semantic-tokens-delta.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/semantic-tokens-delta.schema.json", "title": "semantic-tokens-delta", "type": "object", "properties": { diff --git a/schemas/semantic-tokens.schema.json b/schemas/semantic-tokens.schema.json index 89aab37..eea5f10 100644 --- a/schemas/semantic-tokens.schema.json +++ b/schemas/semantic-tokens.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/semantic-tokens.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/semantic-tokens.schema.json", "title": "semantic-tokens", "type": "object", "properties": { diff --git a/schemas/settings-definition.schema.json b/schemas/settings-definition.schema.json index 9efb8e2..07955d9 100644 --- a/schemas/settings-definition.schema.json +++ b/schemas/settings-definition.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/settings-definition.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/settings-definition.schema.json", "title": "settings-definition", "type": "object", "properties": { diff --git a/schemas/settings-snapshot.schema.json b/schemas/settings-snapshot.schema.json index 7c87732..25578df 100644 --- a/schemas/settings-snapshot.schema.json +++ b/schemas/settings-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/settings-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/settings-snapshot.schema.json", "title": "settings-snapshot", "type": "object", "properties": { diff --git a/schemas/settings-state.schema.json b/schemas/settings-state.schema.json index 266f6bd..56cf9a5 100644 --- a/schemas/settings-state.schema.json +++ b/schemas/settings-state.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/settings-state.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/settings-state.schema.json", "title": "settings-state", "type": "object", "properties": { diff --git a/schemas/signature-help.schema.json b/schemas/signature-help.schema.json index 43f060f..b33c421 100644 --- a/schemas/signature-help.schema.json +++ b/schemas/signature-help.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/signature-help.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/signature-help.schema.json", "title": "signature-help", "oneOf": [ { diff --git a/schemas/stored-artifact-chunk.schema.json b/schemas/stored-artifact-chunk.schema.json index 5342f35..210757d 100644 --- a/schemas/stored-artifact-chunk.schema.json +++ b/schemas/stored-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-artifact-chunk.schema.json", "title": "stored-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/stored-artifact-list.schema.json b/schemas/stored-artifact-list.schema.json index be6455c..435bf73 100644 --- a/schemas/stored-artifact-list.schema.json +++ b/schemas/stored-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-artifact-list.schema.json", "title": "stored-artifact-list", "type": "object", "properties": { diff --git a/schemas/stored-artifact-reference.schema.json b/schemas/stored-artifact-reference.schema.json index c4f205a..ea48c47 100644 --- a/schemas/stored-artifact-reference.schema.json +++ b/schemas/stored-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-artifact-reference.schema.json", "title": "stored-artifact-reference", "type": "object", "properties": { diff --git a/schemas/stored-artifact.schema.json b/schemas/stored-artifact.schema.json index 35d224c..20f6f89 100644 --- a/schemas/stored-artifact.schema.json +++ b/schemas/stored-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-artifact.schema.json", "title": "stored-artifact", "type": "object", "properties": { diff --git a/schemas/stored-job.schema.json b/schemas/stored-job.schema.json index cac4d7c..d2e8e72 100644 --- a/schemas/stored-job.schema.json +++ b/schemas/stored-job.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/stored-job.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/stored-job.schema.json", "title": "stored-job", "type": "object", "properties": { @@ -79,7 +79,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/test-trace.schema.json b/schemas/test-trace.schema.json index 6ce6008..5f12dc5 100644 --- a/schemas/test-trace.schema.json +++ b/schemas/test-trace.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/test-trace.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/test-trace.schema.json", "title": "test-trace", "type": "object", "properties": { diff --git a/schemas/tool-registration.schema.json b/schemas/tool-registration.schema.json new file mode 100644 index 0000000..cec1cc5 --- /dev/null +++ b/schemas/tool-registration.schema.json @@ -0,0 +1,975 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/tool-registration.schema.json", + "title": "tool-registration", + "type": "object", + "properties": { + "process": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "approved": { + "const": true + }, + "executable": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "executableSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 0 + }, + "args": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "object", + "properties": { + "input": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "input" + ], + "additionalProperties": false + } + ] + }, + "maxItems": 64, + "minItems": 0 + }, + "versionArgs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "maxItems": 64, + "minItems": 0 + }, + "expectedVersionOutput": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "environment": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]{0,127}$" + }, + "additionalProperties": { + "type": "string", + "minLength": 0, + "maxLength": 4096 + } + } + }, + "required": [ + "id", + "version", + "approved", + "executable", + "executableSha256", + "files", + "args", + "versionArgs", + "expectedVersionOutput", + "environment" + ], + "additionalProperties": false + }, + "backendId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "inputSchema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "pathInputs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + }, + "format": { + "enum": [ + "jsonl", + "text" + ] + }, + "parser": { + "enum": [ + "plain", + "typescript" + ] + } + }, + "required": [ + "process", + "backendId", + "inputSchema", + "pathInputs", + "format", + "parser" + ], + "additionalProperties": false, + "$comment": "Runtime checks required scalar argument slots and declared path inputs. Host authorization is checked before start, on events and after completion.", + "$defs": { + "dataNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "enum": { + "type": "array", + "items": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "enum": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "null" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "null" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "items": { + "$ref": "#/$defs/dataNode" + }, + "minItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "default": { + "type": "array", + "items": {}, + "minItems": 0, + "maxItems": 256 + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "properties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dataNode" + } + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "minItems": 0, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "object" + } + }, + "required": [ + "type", + "properties", + "additionalProperties" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "format": { + "const": "dds-secret-reference" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "type", + "format" + ], + "additionalProperties": false + } + ] + } + } +} diff --git a/schemas/tool-run-receipt.schema.json b/schemas/tool-run-receipt.schema.json new file mode 100644 index 0000000..01b1939 --- /dev/null +++ b/schemas/tool-run-receipt.schema.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/tool-run-receipt.schema.json", + "title": "tool-run-receipt", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "toolId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "toolVersion": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "jobId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "state": { + "enum": [ + "succeeded", + "failed", + "cancelled", + "timed_out" + ] + }, + "reason": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + ] + }, + "exitCode": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "integer" + } + ] + }, + "terminationSignal": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + ] + }, + "stream": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 2097152 + }, + "events": { + "type": "integer", + "minimum": 0, + "maximum": 4096 + }, + "unknown": { + "type": "integer", + "minimum": 0, + "maximum": 4096 + }, + "peakQueued": { + "type": "integer", + "minimum": 0, + "maximum": 8 + }, + "queued": { + "type": "integer", + "minimum": 0, + "maximum": 8 + }, + "closed": { + "type": "boolean" + }, + "failed": { + "type": "boolean" + } + }, + "required": [ + "bytes", + "events", + "unknown", + "peakQueued" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "schemaVersion", + "toolId", + "toolVersion", + "jobId", + "commandId", + "state", + "reason", + "exitCode", + "terminationSignal", + "stream" + ], + "additionalProperties": false +} diff --git a/schemas/tool-stream-event.schema.json b/schemas/tool-stream-event.schema.json new file mode 100644 index 0000000..211232b --- /dev/null +++ b/schemas/tool-stream-event.schema.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/tool-stream-event.schema.json", + "title": "tool-stream-event", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "jobId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "toolId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "toolVersion": { + "type": "string", + "minLength": 0, + "maxLength": 96 + }, + "stream": { + "enum": [ + "stdout", + "stderr" + ] + }, + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "log" + }, + "level": { + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "message": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "kind", + "level", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "progress" + }, + "completed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "total": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "message": { + "type": "string", + "minLength": 0, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "completed", + "total" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "diagnostic" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime requires a relative workspace file path without traversal or links." + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "severity": { + "enum": [ + "error", + "warning", + "info", + "hint" + ] + }, + "message": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "code": { + "type": "string", + "minLength": 0, + "maxLength": 128 + }, + "source": { + "type": "string", + "minLength": 0, + "maxLength": 128 + } + }, + "required": [ + "kind", + "path", + "range", + "severity", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "unknown" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "message": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + } + }, + "required": [ + "kind", + "reason", + "message" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "schemaVersion", + "sequence", + "jobId", + "commandId", + "toolId", + "toolVersion", + "stream", + "data" + ], + "additionalProperties": false, + "$comment": "Runtime checks ordered ranges, progress <= total and bounded well-formed text; the parser bounds UTF-8 bytes, events, queued writes and callback time." +} diff --git a/schemas/transfer-queue-options.schema.json b/schemas/transfer-queue-options.schema.json index 64f4291..46ec9b1 100644 --- a/schemas/transfer-queue-options.schema.json +++ b/schemas/transfer-queue-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/transfer-queue-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/transfer-queue-options.schema.json", "title": "transfer-queue-options", "type": "object", "properties": { diff --git a/schemas/transfer-snapshot.schema.json b/schemas/transfer-snapshot.schema.json index 5cfa26d..86b40fe 100644 --- a/schemas/transfer-snapshot.schema.json +++ b/schemas/transfer-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/transfer-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/transfer-snapshot.schema.json", "title": "transfer-snapshot", "type": "object", "properties": { diff --git a/schemas/trusted-process-definition.schema.json b/schemas/trusted-process-definition.schema.json new file mode 100644 index 0000000..104a5d8 --- /dev/null +++ b/schemas/trusted-process-definition.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/trusted-process-definition.schema.json", + "title": "trusted-process-definition", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "approved": { + "const": true + }, + "executable": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "executableSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 0 + }, + "args": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "object", + "properties": { + "input": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "input" + ], + "additionalProperties": false + } + ] + }, + "maxItems": 64, + "minItems": 0 + }, + "versionArgs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "maxItems": 64, + "minItems": 0 + }, + "expectedVersionOutput": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "environment": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]{0,127}$" + }, + "additionalProperties": { + "type": "string", + "minLength": 0, + "maxLength": 4096 + } + } + }, + "required": [ + "id", + "version", + "approved", + "executable", + "executableSha256", + "files", + "args", + "versionArgs", + "expectedVersionOutput", + "environment" + ], + "additionalProperties": false, + "$comment": "Operator-only configuration. Runtime requires absolute local executable/artifact paths, fixed version arguments, approved true, digest verification and exact bounded version-probe output. The process is trusted code, not sandboxed." +} diff --git a/schemas/upload-capabilities.schema.json b/schemas/upload-capabilities.schema.json index eb87ec9..98e5e57 100644 --- a/schemas/upload-capabilities.schema.json +++ b/schemas/upload-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upload-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upload-capabilities.schema.json", "title": "upload-capabilities", "type": "object", "properties": { diff --git a/schemas/upload-chunk.schema.json b/schemas/upload-chunk.schema.json index e223f4f..10c47a7 100644 --- a/schemas/upload-chunk.schema.json +++ b/schemas/upload-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upload-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upload-chunk.schema.json", "title": "upload-chunk", "type": "object", "properties": { diff --git a/schemas/upload-reference.schema.json b/schemas/upload-reference.schema.json index cc8db6d..3695aca 100644 --- a/schemas/upload-reference.schema.json +++ b/schemas/upload-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upload-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upload-reference.schema.json", "title": "upload-reference", "type": "object", "properties": { diff --git a/schemas/upload-spec.schema.json b/schemas/upload-spec.schema.json index 8a2acb1..b35a991 100644 --- a/schemas/upload-spec.schema.json +++ b/schemas/upload-spec.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upload-spec.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upload-spec.schema.json", "title": "upload-spec", "type": "object", "properties": { diff --git a/schemas/upload-status.schema.json b/schemas/upload-status.schema.json index 805f3af..6f2da4b 100644 --- a/schemas/upload-status.schema.json +++ b/schemas/upload-status.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upload-status.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upload-status.schema.json", "title": "upload-status", "type": "object", "properties": { diff --git a/schemas/upstream-candidate.schema.json b/schemas/upstream-candidate.schema.json index bd9d0d3..69219a9 100644 --- a/schemas/upstream-candidate.schema.json +++ b/schemas/upstream-candidate.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upstream-candidate.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upstream-candidate.schema.json", "title": "upstream-candidate", "type": "object", "properties": { diff --git a/schemas/upstream-manifest.schema.json b/schemas/upstream-manifest.schema.json index 6baffde..e508b92 100644 --- a/schemas/upstream-manifest.schema.json +++ b/schemas/upstream-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upstream-manifest.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upstream-manifest.schema.json", "title": "upstream-manifest", "type": "object", "properties": { diff --git a/schemas/upstream-snapshot.schema.json b/schemas/upstream-snapshot.schema.json index 07835cb..2cc1c90 100644 --- a/schemas/upstream-snapshot.schema.json +++ b/schemas/upstream-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/upstream-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/upstream-snapshot.schema.json", "title": "upstream-snapshot", "type": "object", "properties": { diff --git a/schemas/workflow-cache-entry.schema.json b/schemas/workflow-cache-entry.schema.json new file mode 100644 index 0000000..654e213 --- /dev/null +++ b/schemas/workflow-cache-entry.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workflow-cache-entry.schema.json", + "title": "workflow-cache-entry", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scope": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "securityScope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "workspaceId", + "securityScope" + ], + "additionalProperties": false + }, + "grants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + }, + "value": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json" + }, + "valueSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "createdAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "expiresAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "accessedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 65536 + } + }, + "required": [ + "schemaVersion", + "key", + "scope", + "grants", + "value", + "valueSha256", + "createdAt", + "expiresAt", + "accessedAt", + "byteLength" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 131072, + "$comment": "Runtime verifies canonical value digest/UTF-8 length, TTL ordering and rejects secret-reference values. Every cache use rechecks current host authorization." +} diff --git a/schemas/workflow-definition.schema.json b/schemas/workflow-definition.schema.json new file mode 100644 index 0000000..cf00b82 --- /dev/null +++ b/schemas/workflow-definition.schema.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workflow-definition.schema.json", + "title": "workflow-definition", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "scope": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "securityScope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "workspaceId", + "securityScope" + ], + "additionalProperties": false + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "needs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "value": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "step": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "path": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 8, + "minItems": 0 + } + }, + "required": [ + "step", + "path" + ], + "additionalProperties": false + } + ] + }, + "maxProperties": 128 + }, + "grants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "id", + "commandId", + "needs", + "input", + "grants" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + }, + "policy": { + "enum": [ + "fail-fast", + "continue" + ] + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "maximum": 8 + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "stepTimeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "retentionMs": { + "type": "integer", + "minimum": 1, + "maximum": 86400000 + } + }, + "required": [ + "schemaVersion", + "id", + "scope", + "steps", + "policy", + "concurrency", + "timeoutMs", + "stepTimeoutMs", + "retentionMs" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "Runtime checks acyclic unique steps, existing dependencies, input bindings and stepTimeoutMs <= timeoutMs." +} diff --git a/schemas/workflow-fingerprint.schema.json b/schemas/workflow-fingerprint.schema.json new file mode 100644 index 0000000..724726a --- /dev/null +++ b/schemas/workflow-fingerprint.schema.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workflow-fingerprint.schema.json", + "title": "workflow-fingerprint", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "eligible": { + "type": "boolean" + }, + "reasons": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 8, + "minItems": 0, + "uniqueItems": true + }, + "descriptor": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "sdkVersion": { + "const": "1.2.0" + }, + "scope": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "securityScope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "workspaceId", + "securityScope" + ], + "additionalProperties": false + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "pluginSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "toolSha256": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + ] + }, + "schemaSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "settingsSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "inputSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "environmentSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime requires a relative workspace file path without traversal or links." + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 4194304 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "byteLength", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 0 + }, + "grants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + }, + "policy": { + "type": "object", + "properties": { + "optIn": { + "type": "boolean" + }, + "deterministic": { + "type": "boolean" + }, + "declaredInputsComplete": { + "type": "boolean" + }, + "secretDependent": { + "type": "boolean" + } + }, + "required": [ + "optIn", + "deterministic", + "declaredInputsComplete", + "secretDependent" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "sdkVersion", + "scope", + "commandId", + "pluginSha256", + "toolSha256", + "schemaSha256", + "settingsSha256", + "inputSha256", + "environmentSha256", + "files", + "grants", + "policy" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "key", + "eligible", + "reasons", + "descriptor" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 65536, + "$comment": "Fingerprint generation hashes actual supplied file bytes, all declared inputs and SDK/plugin/tool/schema/settings/environment/scope/grants. Runtime verifies the descriptor digest; completeness and determinism are operator attestations." +} diff --git a/schemas/workflow-plan.schema.json b/schemas/workflow-plan.schema.json new file mode 100644 index 0000000..42b5d8b --- /dev/null +++ b/schemas/workflow-plan.schema.json @@ -0,0 +1,1073 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workflow-plan.schema.json", + "title": "workflow-plan", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "definition": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "scope": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "securityScope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "workspaceId", + "securityScope" + ], + "additionalProperties": false + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "needs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "value": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "step": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "path": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 8, + "minItems": 0 + } + }, + "required": [ + "step", + "path" + ], + "additionalProperties": false + } + ] + }, + "maxProperties": 128 + }, + "grants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "id", + "commandId", + "needs", + "input", + "grants" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + }, + "policy": { + "enum": [ + "fail-fast", + "continue" + ] + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "maximum": 8 + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "stepTimeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 600000 + }, + "retentionMs": { + "type": "integer", + "minimum": 1, + "maximum": 86400000 + } + }, + "required": [ + "schemaVersion", + "id", + "scope", + "steps", + "policy", + "concurrency", + "timeoutMs", + "stepTimeoutMs", + "retentionMs" + ], + "additionalProperties": false + }, + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "pluginSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "inputSchema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "grants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "maxItems": 64, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "id", + "pluginId", + "pluginSha256", + "inputSchema", + "outputSchema", + "grants" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 0 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "schemaVersion", + "definition", + "commands", + "sha256" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "Runtime prepares the exact immutable plan, verifies closed input/output schemas and required output paths, rejects secret-reference schemas and hashes the canonical body. Digest approval never substitutes for host authorization.", + "$defs": { + "dataNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "enum": { + "type": "array", + "items": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "enum": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "null" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "null" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "items": { + "$ref": "#/$defs/dataNode" + }, + "minItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "default": { + "type": "array", + "items": {}, + "minItems": 0, + "maxItems": 256 + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "properties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dataNode" + } + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "minItems": 0, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "object" + } + }, + "required": [ + "type", + "properties", + "additionalProperties" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "format": { + "const": "dds-secret-reference" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "type", + "format" + ], + "additionalProperties": false + } + ] + } + } +} diff --git a/schemas/workflow-record.schema.json b/schemas/workflow-record.schema.json new file mode 100644 index 0000000..723ffb0 --- /dev/null +++ b/schemas/workflow-record.schema.json @@ -0,0 +1,341 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workflow-record.schema.json", + "title": "workflow-record", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "workflowId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "parentAttemptId": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + ] + }, + "parentJobId": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + ] + }, + "planSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scope": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "securityScope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + }, + "required": [ + "workspaceId", + "securityScope" + ], + "additionalProperties": false + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "state": { + "enum": [ + "pending", + "running", + "succeeded", + "failed", + "cancelled", + "timed_out", + "blocked", + "interrupted" + ] + }, + "startedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "expiresAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "state": { + "enum": [ + "pending", + "running", + "succeeded", + "failed", + "cancelled", + "timed_out", + "blocked", + "interrupted" + ] + }, + "jobId": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + ] + }, + "sourceAttemptId": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + ] + }, + "inputSha256": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + ] + }, + "output": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/json-value.schema.json" + }, + "outputSha256": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + ] + }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime requires a relative workspace file path without traversal or links." + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "id", + "path", + "revision", + "byteLength" + ], + "additionalProperties": false + }, + "maxItems": 32, + "minItems": 0 + }, + "cache": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "state": { + "enum": [ + "hit", + "miss", + "shared", + "bypass" + ] + }, + "key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "state", + "key", + "reason" + ], + "additionalProperties": false + } + ] + }, + "reason": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + } + ] + }, + "startedAt": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + ] + }, + "finishedAt": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + ] + } + }, + "required": [ + "id", + "commandId", + "state", + "jobId", + "sourceAttemptId", + "inputSha256", + "output", + "outputSha256", + "artifacts", + "cache", + "reason", + "startedAt", + "finishedAt" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + } + }, + "required": [ + "schemaVersion", + "workflowId", + "attemptId", + "parentAttemptId", + "parentJobId", + "planSha256", + "scope", + "revision", + "state", + "startedAt", + "updatedAt", + "expiresAt", + "steps" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 2097152, + "$comment": "Runtime checks output hashes, success-only outputs/artifacts and time ordering. Host restart produces an interrupted view; only an explicitly approved new attempt executes." +} diff --git a/schemas/workspace-conditional-file.schema.json b/schemas/workspace-conditional-file.schema.json index 300e13d..e398a2d 100644 --- a/schemas/workspace-conditional-file.schema.json +++ b/schemas/workspace-conditional-file.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-conditional-file.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-conditional-file.schema.json", "title": "workspace-conditional-file", "oneOf": [ { diff --git a/schemas/workspace-edit-preview.schema.json b/schemas/workspace-edit-preview.schema.json index a82f36c..fcfa06f 100644 --- a/schemas/workspace-edit-preview.schema.json +++ b/schemas/workspace-edit-preview.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-edit-preview.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-edit-preview.schema.json", "title": "workspace-edit-preview", "type": "object", "properties": { diff --git a/schemas/workspace-edit-receipt.schema.json b/schemas/workspace-edit-receipt.schema.json index 6aeb08b..7cb97ab 100644 --- a/schemas/workspace-edit-receipt.schema.json +++ b/schemas/workspace-edit-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-edit-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-edit-receipt.schema.json", "title": "workspace-edit-receipt", "type": "object", "properties": { diff --git a/schemas/workspace-edit-record.schema.json b/schemas/workspace-edit-record.schema.json index e7913fd..f86a590 100644 --- a/schemas/workspace-edit-record.schema.json +++ b/schemas/workspace-edit-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-edit-record.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-edit-record.schema.json", "title": "workspace-edit-record", "type": "object", "properties": { diff --git a/schemas/workspace-edit.schema.json b/schemas/workspace-edit.schema.json index 1accbfd..0a5f620 100644 --- a/schemas/workspace-edit.schema.json +++ b/schemas/workspace-edit.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-edit.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-edit.schema.json", "title": "workspace-edit", "type": "object", "properties": { diff --git a/schemas/workspace-file-capabilities.schema.json b/schemas/workspace-file-capabilities.schema.json index 03429bd..78208ad 100644 --- a/schemas/workspace-file-capabilities.schema.json +++ b/schemas/workspace-file-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-file-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-file-capabilities.schema.json", "title": "workspace-file-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-file-revision.schema.json b/schemas/workspace-file-revision.schema.json index c5549eb..8cbc657 100644 --- a/schemas/workspace-file-revision.schema.json +++ b/schemas/workspace-file-revision.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-file-revision.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-file-revision.schema.json", "title": "workspace-file-revision", "type": "object", "properties": { diff --git a/schemas/workspace-project-capabilities.schema.json b/schemas/workspace-project-capabilities.schema.json index 088dfaf..61a28ec 100644 --- a/schemas/workspace-project-capabilities.schema.json +++ b/schemas/workspace-project-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-project-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-project-capabilities.schema.json", "title": "workspace-project-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-project-poll.schema.json b/schemas/workspace-project-poll.schema.json index 4890a22..ddec5a7 100644 --- a/schemas/workspace-project-poll.schema.json +++ b/schemas/workspace-project-poll.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.1.0/schemas/workspace-project-poll.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v1.2.0/schemas/workspace-project-poll.schema.json", "title": "workspace-project-poll", "type": "object", "properties": { diff --git a/scripts/api-support.mjs b/scripts/api-support.mjs index 40f82ee..08632fe 100644 --- a/scripts/api-support.mjs +++ b/scripts/api-support.mjs @@ -4,8 +4,8 @@ import ts from 'typescript'; import {SDK_PLUGIN_PEER_RANGE} from '../src/version.mjs'; const mode=process.argv[2];if(!['--write','--check'].includes(mode))throw Error('Use --write or --check'); const root=new URL('../',import.meta.url),pkg=JSON.parse(await readFile(new URL('package.json',root),'utf8')); -const optional=new Set(['./bundles','./provenance','./updates','./updates-node','./upstream','./workspace-node','./workspace-client','./jobs','./artifacts','./job-storage','./job-history','./job-storage-node','./artifact-storage','./artifact-storage-node','./workspace-edits','./workspace-edits-node','./project-watch','./project-watch-node','./project-query','./project-query-node','./artifact-transfer','./artifact-transfer-browser','./uploads','./uploads-node','./uploads-browser','./artifact-resume-browser','./transfer-queue','./settings','./secrets']); -const nodeOnly=new Set(['./bundles','./provenance','./updates','./updates-node','./upstream','./publishing','./devtools','./workspace-node','./job-storage-node','./artifact-storage-node','./workspace-edits-node','./project-watch-node','./project-query-node','./uploads-node','./conformance-node']); +const optional=new Set(['./workflows','./workflows-node','./workflow-cache','./workflow-cache-node','./tool-streams-node','./lsp-node','./tool-streams','./bundles','./provenance','./updates','./updates-node','./upstream','./workspace-node','./workspace-client','./jobs','./artifacts','./job-storage','./job-history','./job-storage-node','./artifact-storage','./artifact-storage-node','./workspace-edits','./workspace-edits-node','./project-watch','./project-watch-node','./project-query','./project-query-node','./artifact-transfer','./artifact-transfer-browser','./uploads','./uploads-node','./uploads-browser','./artifact-resume-browser','./transfer-queue','./settings','./secrets']); +const nodeOnly=new Set(['./workflows','./workflows-node','./workflow-cache','./workflow-cache-node','./tool-streams-node','./lsp-node','./bundles','./provenance','./updates','./updates-node','./upstream','./publishing','./devtools','./workspace-node','./job-storage-node','./artifact-storage-node','./workspace-edits-node','./project-watch-node','./project-query-node','./uploads-node','./conformance-node']); function typeNames(source){ const file=ts.createSourceFile('entry.d.mts',source,ts.ScriptTarget.Latest,true),names=new Set(); for(const statement of file.statements){ diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index bb3e117..6c21a1e 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -239,6 +239,14 @@ try { const releaseTools=JSON.parse(run(process.execPath,[join(installed,'examples','release-tools','run.mjs')],consumer)); assert.equal(releaseTools.offlineInstall,true);assert.equal(releaseTools.oldJobPinned,true);assert.equal(releaseTools.rollback,true);assert.equal(releaseTools.upstreamCandidate,true); await cp(join(root,'tests','types','release-tools.mts'),join(consumer,'release-tools.mts')); + for(const name of ['workflows','workflow-tools','lsp'])await cp(join(installed,'examples',name),join(consumer,name+'-example'),{recursive:true}); + const workflows=JSON.parse(run(process.execPath,['workflows-example/run.mjs'],consumer)); + assert.equal(workflows.killedChildRecovery,true);assert.equal(workflows.incrementalSteps,2);assert.equal(workflows.failedDependencyBlocked,true); + const workflowTools=JSON.parse(run(process.execPath,['workflow-tools-example/run.mjs','--typescript-root',join(root,'node_modules','typescript')],consumer)); + assert.equal(workflowTools.actualCompiler.diagnostic,'TS2322');assert.equal(workflowTools.ownedProcessesClosed,true); + const lsp=JSON.parse(run(process.execPath,['lsp-example/run.mjs','--typescript-root',join(root,'node_modules','typescript'),'--language-server-root',join(root,'node_modules','typescript-language-server')],consumer)); + assert.equal(lsp.syntheticFeatures,15);assert.equal(lsp.actualServer.verifiedFeatures,8);assert.equal(lsp.filesUnchanged,true);assert.equal(lsp.ownedProcessesClosed,true); + await cp(join(root,'tests','types','workflows.mts'),join(consumer,'workflows.mts')); await cp(join(root, 'tests', 'types', 'tsconfig.json'), join(consumer, 'tsconfig.json')); const typescript = join(root, 'node_modules', 'typescript', 'bin', 'tsc'); run(process.execPath, [typescript, '--noEmit', '-p', 'tsconfig.json'], consumer); diff --git a/src/lsp-node.d.mts b/src/lsp-node.d.mts new file mode 100644 index 0000000..829fb0c --- /dev/null +++ b/src/lsp-node.d.mts @@ -0,0 +1,13 @@ +import type {JsonValue,Scope,DocumentSnapshot,DiagnosticsResult,LanguageFeature,LanguageRequest,LanguageResult,LanguageProvider} from './index.mjs'; +import type {TrustedProcessDefinition} from './tool-streams-node.mjs'; +export const LSP_LIMITS:Readonly<{messageBytes:number;headerBytes:number;sessionBytes:number;messages:number;pendingRequests:number;queuedWrites:number;timeoutMs:number;documents:number;documentBytes:number;stderrBytes:number}>; +export interface LspCapabilityMatrix {readonly schemaVersion:1;readonly protocol:'3.17';readonly transport:'stdio';readonly positionEncoding:'utf-16';readonly features:readonly {readonly sdkFeature:LanguageFeature;readonly method:string;readonly supported:boolean;readonly reason:'advertised'|'not-advertised'}[];readonly unsupported:readonly string[];} +export interface LspFrameDecoder {push(input:Uint8Array):readonly JsonValue[];finish():void;inspect():Readonly<{bufferedBytes:number;totalBytes:number;messages:number;failed:boolean}>;} +export interface LspInspection {readonly state:string;readonly generation:number;readonly documents:number;readonly requests:number;readonly diagnostics:number;readonly unversionedDropped:number;readonly staleDropped:number;readonly rejectedNotifications:number;readonly ignoredNotifications:number;readonly transport:Readonly>;readonly process:{readonly pid:number|null;readonly closed:boolean};} +export interface LspCloseReceipt {readonly schemaVersion:1;readonly graceful:boolean;readonly reason:string|null;readonly ownedProcessClosed:boolean;readonly pendingRequests:number;} +export interface LspBridge {capabilities():LspCapabilityMatrix;inspect():LspInspection;syncDocument(snapshot:DocumentSnapshot):Promise;closeDocument(uri:string):Promise;request(request:LanguageRequest,options?:{readonly signal?:AbortSignal}):Promise>;provider(kind:K):LanguageProvider;close():Promise;} +export function encodeLspMessage(message:JsonValue):Uint8Array; +export function createLspFrameDecoder():LspFrameDecoder; +export function lspCapabilityMatrix(capabilities:JsonValue):LspCapabilityMatrix; +export function parseLspCapabilityMatrix(input:unknown):LspCapabilityMatrix; +export function createLspBridge(options:{readonly process:TrustedProcessDefinition;readonly workspaceRoot:string;readonly scope:Scope;readonly authorize:(context:{readonly operation:'start'|'read'|'edit'|'diagnostics';readonly scope:Scope;readonly path:string|null;readonly serverId:string;readonly serverVersion:string})=>boolean|Promise;readonly onDiagnostics?:(result:DiagnosticsResult)=>void|Promise;readonly readDocument?:(path:string,options:{readonly signal:AbortSignal})=>DocumentSnapshot|Promise;readonly configuration?:JsonValue;readonly initializationOptions?:JsonValue;readonly expectedServerName?:string;readonly requestTimeoutMs?:number;readonly signal?:AbortSignal;readonly secrets?:readonly string[]}):Promise; diff --git a/src/lsp-node.mjs b/src/lsp-node.mjs new file mode 100644 index 0000000..4654aa4 --- /dev/null +++ b/src/lsp-node.mjs @@ -0,0 +1,248 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {randomUUID} from 'node:crypto'; +import {parseScope,parseWorkspacePath,parseDocumentSnapshot,parseLanguageRequest,createLanguageResult,createDiagnosticsResult} from './contracts.mjs'; +import {parseWorkspaceEdit,editHash} from './workspace-edit-contracts.mjs'; +import {textIndex,compareLanguagePosition} from './language-values.mjs'; +import {parseSemanticLegend} from './language-display.mjs'; +import {CODE_ACTION_KINDS} from './language-editing.mjs'; +import {verifyTrustedProcess,realToolWorkspace,checkToolWorkspace,spawnTrustedProcess,probeTrustedProcess} from './trusted-process-node.mjs'; +import {createLspRpc,LSP_LIMITS} from './lsp-transport-node.mjs'; +import {canonical,clone,digest,fail,fields,integer,immutable,jsonCopy,deadline,interruptible,authorized,errorCode} from './workflow-internals.mjs'; + +export {LSP_LIMITS,encodeLspMessage,createLspFrameDecoder} from './lsp-transport-node.mjs'; +const mapping=[ + ['completion','textDocument/completion','completionProvider'],['hover','textDocument/hover','hoverProvider'],['definition','textDocument/definition','definitionProvider'],['references','textDocument/references','referencesProvider'],['document-symbols','textDocument/documentSymbol','documentSymbolProvider'], + ['signature-help','textDocument/signatureHelp','signatureHelpProvider'],['prepare-rename','textDocument/prepareRename','renameProvider'],['rename','textDocument/rename','renameProvider'],['format-document','textDocument/formatting','documentFormattingProvider'],['format-range','textDocument/rangeFormatting','documentRangeFormattingProvider'],['code-actions','textDocument/codeAction','codeActionProvider'],['semantic-tokens','textDocument/semanticTokens/full','semanticTokensProvider'],['folding-ranges','textDocument/foldingRange','foldingRangeProvider'],['inlay-hints','textDocument/inlayHint','inlayHintProvider'],['document-symbol-tree','textDocument/documentSymbol','documentSymbolProvider'], +]; +const unsupported=['completion-resolve','completion-snippets','code-action-resolve','semantic-tokens-delta','server-commands','dynamic-capabilities','resource-operations','sockets','unversioned-diagnostics']; +export function lspCapabilityMatrix(input){ + const capabilities=jsonCopy(input,65_536);if(!capabilities||typeof capabilities!=='object'||Array.isArray(capabilities))fail('INVALID','Invalid server capabilities'); + if(capabilities.positionEncoding!==undefined&&capabilities.positionEncoding!=='utf-16')fail('UNSUPPORTED','Only negotiated UTF-16 positions are supported'); + const features=mapping.map(([sdkFeature,method,cap])=>{ + const provided=capabilities[cap];let supported=provided===true||!!provided&&typeof provided==='object'&&!Array.isArray(provided); + if(sdkFeature==='prepare-rename')supported=!!provided?.prepareProvider; + if(sdkFeature==='semantic-tokens')supported=!!provided?.full&&!!provided?.legend; + return {sdkFeature,method,supported,reason:supported?'advertised':'not-advertised'}; + }); + return immutable({schemaVersion:1,protocol:'3.17',transport:'stdio',positionEncoding:'utf-16',features,unsupported:[...unsupported]}); +} +export function parseLspCapabilityMatrix(input){ + const value=jsonCopy(input,16_384);fields(value,['schemaVersion','protocol','transport','positionEncoding','features','unsupported']); + if(value.schemaVersion!==1||value.protocol!=='3.17'||value.transport!=='stdio'||value.positionEncoding!=='utf-16'||!Array.isArray(value.features)||value.features.length!==mapping.length||canonical(value.unsupported)!==canonical(unsupported))fail('INVALID','Unsupported capability matrix'); + for(let i=0;i{if(!Array.isArray(value)||value.length>max)fail('LIMIT','LSP result item limit');return value;}; +const object=value=>{if(!value||typeof value!=='object'||Array.isArray(value))fail('INVALID','Expected LSP object');return value;}; +const string=(value,max=16_384)=>{if(typeof value!=='string'||!value.isWellFormed()||value.length>max)fail('INVALID','Invalid LSP text');return value;}; +const scalarText=value=>typeof value==='string'?value:value&&typeof value==='object'&&typeof value.value==='string'?value.value:fail('INVALID','Unsupported LSP markup'); +const markup=value=>value===undefined?'':Array.isArray(value)?limitList(value,16).map(scalarText).join('\n'):scalarText(value); +const symbolKind={1:'module',2:'module',3:'namespace',4:'module',5:'class',6:'method',7:'property',8:'property',9:'method',10:'type',11:'interface',12:'function',13:'variable',14:'constant',15:'variable',16:'variable',17:'variable',18:'variable',19:'property',20:'property',21:'constant',22:'type',23:'type',24:'type',25:'variable',26:'type'}; +const severity={1:'error',2:'warning',3:'info',4:'hint'}; +const clientCapabilities={general:{positionEncodings:['utf-16'],staleRequestSupport:{cancel:true,retryOnContentModified:[]}},workspace:{applyEdit:false,configuration:true,workspaceFolders:false,workspaceEdit:{documentChanges:true,resourceOperations:[],failureHandling:'abort',normalizesLineEndings:false}},textDocument:{synchronization:{dynamicRegistration:false,didSave:false,willSave:false},completion:{dynamicRegistration:false,completionItem:{snippetSupport:false,commitCharactersSupport:false,documentationFormat:['plaintext'],deprecatedSupport:false,preselectSupport:false,insertReplaceSupport:false}},hover:{dynamicRegistration:false,contentFormat:['plaintext']},definition:{dynamicRegistration:false,linkSupport:true},references:{dynamicRegistration:false},documentSymbol:{dynamicRegistration:false,hierarchicalDocumentSymbolSupport:true},signatureHelp:{dynamicRegistration:false,signatureInformation:{documentationFormat:['plaintext'],parameterInformation:{labelOffsetSupport:true},activeParameterSupport:true},contextSupport:true},rename:{dynamicRegistration:false,prepareSupport:true},formatting:{dynamicRegistration:false},rangeFormatting:{dynamicRegistration:false},codeAction:{dynamicRegistration:false,codeActionLiteralSupport:{codeActionKind:{valueSet:CODE_ACTION_KINDS}},disabledSupport:true,isPreferredSupport:true,dataSupport:false},publishDiagnostics:{relatedInformation:false,versionSupport:true},semanticTokens:{dynamicRegistration:false,requests:{range:false,full:true},tokenTypes:['namespace','type','class','enum','interface','struct','typeParameter','parameter','variable','property','enumMember','event','function','method','macro','keyword','modifier','comment','string','number','regexp','operator','decorator'],tokenModifiers:['declaration','definition','readonly','static','deprecated','abstract','async','modification','documentation','defaultLibrary'],formats:['relative'],overlappingTokenSupport:false,multilineTokenSupport:false},foldingRange:{dynamicRegistration:false,rangeLimit:1000,lineFoldingOnly:false},inlayHint:{dynamicRegistration:false}},window:{workDoneProgress:false}}; + +/** Explicitly approved stdio server. Every file read/edit result remains host-authorized and data-only. */ +export async function createLspBridge({process:processDefinition,workspaceRoot,scope,authorize,onDiagnostics=async()=>{},readDocument,configuration={},initializationOptions={},expectedServerName,requestTimeoutMs=5000,signal,secrets=[]}={}){ + scope=parseScope(scope);if(typeof authorize!=='function'||typeof onDiagnostics!=='function'||readDocument!==undefined&&typeof readDocument!=='function')fail('INVALID','LSP owner ports required');integer(requestTimeoutMs,1,LSP_LIMITS.timeoutMs); + configuration=jsonCopy(configuration,65_536);initializationOptions=jsonCopy(initializationOptions,65_536); + const lifetime=new AbortController(),abort=()=>lifetime.abort(); + const documents=new Map(),requests=new Map(),metrics={diagnostics:0,unversionedDropped:0,staleDropped:0,rejectedNotifications:0,ignoredNotifications:0}; + let proc,rpc,workspace,server,matrix,legend=null,state='initializing',generation=0,docTail=Promise.resolve(),closePromise; + if(!Array.isArray(secrets)||secrets.length>32)fail('INVALID','Invalid LSP masks');const masks=secrets.map(v=>{string(v,4096);if(!v)fail('INVALID','Empty mask');return v;}); + signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort(); + const redact=text=>[...masks,workspace?.root??''].filter(Boolean).reduce((value,mask)=>value.split(mask).join('[redacted]'),text); + const clean=(v,max=16_384)=>redact(string(v,max)); + const access=(operation,file,requestSignal=lifetime.signal)=>authorized(authorize,{operation,scope,path:file??null,serverId:server?.definition.id??processDefinition?.id,serverVersion:server?.definition.version??processDefinition?.version},requestSignal); + async function localUri(uri){ + const url=new URL(string(uri,2048));if(url.protocol!=='file:'||url.host||url.username||url.password||url.search||url.hash)fail('DENIED','Only local workspace file URIs are supported'); + const absolute=fileURLToPath(url),relative=path.relative(workspace.root,absolute);if(!relative||relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))fail('DENIED','URI outside workspace'); + const name=parseWorkspacePath(relative.split(path.sep).join('/'));await checkToolWorkspace(workspace); + let current=workspace.root,canonicalFile;const parts=name.split('/'); + for(let i=0;ireadDocument(local.path,{signal:requestSignal})),requestSignal));if((await localUri(snapshot.uri)).uri!==local.uri)fail('CONFLICT','Document port returned another URI');document={path:local.path,snapshot};} + if(!document)fail('UNSUPPORTED','Open the target document or provide an authorized readDocument port');return document; + } + const invalidate=()=>{generation++;for(const c of requests.values())c.abort();}; + const serial=action=>{const p=docTail.then(action);docTail=p.catch(()=>{});return p;}; + const open=()=>{if(state!=='ready'||rpc.inspect().closed)fail('DISPOSED','LSP bridge is not ready');}; + function current(snapshot,epoch){const doc=documents.get(snapshot.uri);if(epoch!==generation||!doc||canonical(doc.snapshot)!==canonical(snapshot))fail('STALE','Document changed while the server was working');} + async function diagnostics(params){ + try{ + object(params);const local=await localUri(params.uri),doc=documents.get(local.uri); + if(!doc){metrics.staleDropped++;return;} + if(params.version===undefined){metrics.unversionedDropped++;return;} + if(params.version!==doc.snapshot.modelVersion){metrics.staleDropped++;return;} + const epoch=generation,clock=deadline(lifetime.signal,requestTimeoutMs); + try{ + await access('diagnostics',local.path,clock.signal);const index=textIndex(doc.snapshot.text); + const values=limitList(params.diagnostics).map(d=>{object(d);index.range(d.range);if(d.severity!==undefined&&!severity[d.severity])fail('INVALID','Invalid diagnostic severity');return {range:d.range,severity:severity[d.severity??1],message:clean(d.message),...(d.code!==undefined?{code:clean(String(d.code),128)}:{}),...(d.source!==undefined?{source:clean(d.source,128)}:{})};}); + current(doc.snapshot,epoch);const result=createDiagnosticsResult({protocolVersion:1,requestId:randomUUID(),scope,snapshot:doc.snapshot},values); + await interruptible(Promise.resolve().then(()=>onDiagnostics(result)),clock.signal);metrics.diagnostics++; + }finally{clock.close();} + }catch{metrics.rejectedNotifications++;} + } + async function serverRequest(method,params){ + if(method==='workspace/applyEdit')return {applied:false,failureReason:'Use an explicitly reviewed SDK workspace edit proposal'}; + if(method==='window/showMessageRequest')return null; + if(method==='workspace/configuration'){ + object(params);const clock=deadline(lifetime.signal,requestTimeoutMs); + try{const result=[];for(const item of limitList(params.items,32)){object(item);if(item.scopeUri){const local=await localUri(item.scopeUri);await access('read',local.path,clock.signal);}const section=string(item.section??'',128);result.push(Object.hasOwn(configuration,section)?configuration[section]:null);}return result;}finally{clock.close();} + } + fail('UNSUPPORTED','Server request not supported'); + } + try{ + const clock=deadline(lifetime.signal,Math.max(requestTimeoutMs,10_000)); + try{ + await access('start',null,clock.signal);workspace=await realToolWorkspace(workspaceRoot);server=await interruptible(verifyTrustedProcess(processDefinition),clock.signal); + if(server.definition.args.some(arg=>typeof arg!=='string'))fail('INVALID','LSP arguments must be operator-fixed'); + await probeTrustedProcess(server.definition,workspace,clock.signal);await access('start',null,clock.signal); + proc=spawnTrustedProcess(server.definition,server.definition.args,workspace,lifetime.signal); + rpc=createLspRpc(proc,{signal:lifetime.signal,timeoutMs:requestTimeoutMs,onNotification:async(method,params)=>{if(method==='textDocument/publishDiagnostics')await diagnostics(params);else metrics.ignoredNotifications++;},onRequest:serverRequest}); + const initialized=await rpc.request('initialize',{processId:null,clientInfo:{name:'DDS public Plugin SDK',version:'1.2'},rootUri:pathToFileURL(workspace.root+path.sep).href,capabilities:clientCapabilities,initializationOptions,workspaceFolders:null},{signal:clock.signal,timeoutMs:Math.max(requestTimeoutMs,10_000)}); + object(initialized);object(initialized.capabilities); + if(expectedServerName!==undefined&&initialized.serverInfo?.name!==expectedServerName)fail('UNSUPPORTED','Unexpected language server identity'); + if(initialized.serverInfo?.version!==undefined&&initialized.serverInfo.version!==server.definition.version)fail('UNSUPPORTED','Language server version disagrees with its pin'); + matrix=lspCapabilityMatrix(initialized.capabilities); + const sync=initialized.capabilities.textDocumentSync,change=typeof sync==='number'?sync:sync?.change; + if(![1,2].includes(change)||typeof sync==='object'&&sync.openClose===false)fail('UNSUPPORTED','Server lacks supported document synchronization'); + if(matrix.features.find(f=>f.sdkFeature==='semantic-tokens').supported){ + const raw=initialized.capabilities.semanticTokensProvider.legend; + const style=name=>['namespace','type','class','enum','interface','struct','typeParameter'].includes(name)?'type':['function','method'].includes(name)?'function':['variable','parameter','property','enumMember'].includes(name)?'variable':['keyword','comment','string','number','operator'].includes(name)?name:'plain'; + legend=parseSemanticLegend({tokenTypes:limitList(raw.tokenTypes,64).map(name=>({name,style:style(name)})),tokenModifiers:limitList(raw.tokenModifiers,16)}); + } + await rpc.notify('initialized',{});state='ready'; + }finally{clock.close();} + }catch(error){state='closed';lifetime.abort();signal?.removeEventListener('abort',abort);await rpc?.close();await proc?.stop();throw error;} + + async function edits(value,title,requestSignal){ + if(value===null)return null;object(value);if(value.changeAnnotations!==undefined)fail('UNSUPPORTED','Edit annotations are not supported'); + let changes; + if(value.documentChanges!==undefined)changes=limitList(value.documentChanges,32).map(c=>{object(c);if(c.kind!==undefined||!c.textDocument)fail('UNSUPPORTED','LSP file operations are not supported');return {uri:c.textDocument.uri,version:c.textDocument.version,edits:c.edits};}); + else if(value.changes!==undefined)changes=Object.entries(object(value.changes)).map(([uri,items])=>({uri,version:null,edits:items})); + else fail('INVALID','Missing LSP edit changes'); + if(changes.length>32)fail('LIMIT','Workspace edit file count');const result=[]; + for(const c of changes){ + const doc=await documentFor(c.uri,'edit',requestSignal);if(c.version!==null&&c.version!==undefined&&c.version!==doc.snapshot.modelVersion)fail('STALE','Workspace edit version changed'); + const index=textIndex(doc.snapshot.text),items=limitList(c.edits,500).map(e=>{fields(e,['range','newText']);index.range(e.range);return {range:e.range,text:string(e.newText,262_144)};}); + if(items.length)result.push({kind:'edit',path:doc.path,baseRevision:editHash(doc.snapshot.text),edits:items}); + } + return result.length?parseWorkspaceEdit({formatVersion:1,id:randomUUID(),title,changes:result}):null; + } + function symbols(raw,snapshot,tree){ + const index=textIndex(snapshot.text);let count=0; + const visit=(values,depth)=>{if(depth>16)fail('LIMIT','Symbol depth');return limitList(values,1000).map(value=>{ + if(++count>1000)fail('LIMIT','Symbol count');object(value);const range=value.range??value.location?.range;if(value.location&&value.location.uri!==snapshot.uri)fail('DENIED','Document symbol belongs to another URI'); + index.range(range);const selectionRange=value.selectionRange??range;index.range(selectionRange);if(!symbolKind[value.kind])fail('INVALID','Unknown symbol kind'); + return {name:clean(value.name,256),kind:symbolKind[value.kind],range,selectionRange,...(value.detail?{detail:clean(value.detail,2048)}:{}),...(value.children?{children:visit(value.children,depth+1)}:{})}; + }).sort((a,b)=>compareLanguagePosition(a.range.start,b.range.start)||compareLanguagePosition(b.range.end,a.range.end));}; + const values=visit(raw??[],0);if(tree)return values;const flat=[];const flatten=items=>{for(const value of items){const {children,...item}=value;flat.push(item);if(children)flatten(children);}};flatten(values);return flat; + } + async function transform(request,raw,requestSignal){ + const kind=request.kind,doc=documents.get(request.snapshot.uri),index=textIndex(request.snapshot.text); + if(kind==='completion'){ + if(raw===null)return [];const list=Array.isArray(raw)?raw:object(raw).items;if(!Array.isArray(raw)&&raw.itemDefaults!==undefined)fail('UNSUPPORTED','Completion list defaults are not supported'); + return limitList(list).map(value=>{ + object(value);if(value.command||value.insertTextFormat===2)fail('UNSUPPORTED','Completion commands and snippets are not supported'); + const edit=value.textEdit;if(edit&&(edit.insert||edit.replace))fail('UNSUPPORTED','Insert/replace completion edits are not supported'); + if(edit)index.range(edit.range); + const result={label:clean(value.label,256),insertText:string(edit?.newText??value.insertText??value.label),...(edit?{range:edit.range}:{}),...(value.detail?{detail:clean(value.detail,2048)}:{}),...(value.documentation?{documentation:clean(markup(value.documentation))}:{})}; + if(value.additionalTextEdits)result.additionalTextEdits=limitList(value.additionalTextEdits,32).map(e=>{fields(e,['range','newText']);index.range(e.range);return {range:e.range,text:string(e.newText)};});return result; + }); + } + if(kind==='hover'){if(raw===null)return null;object(raw);if(raw.range)index.range(raw.range);const text=clean(markup(raw.contents));return text?{text,...(raw.range?{range:raw.range}:{})}:null;} + if(kind==='definition'||kind==='references'){ + const result=[];for(const value of limitList(raw===null?[]:Array.isArray(raw)?raw:[raw])){object(value);const target=await documentFor(value.targetUri??value.uri,'read',requestSignal),range=value.targetSelectionRange??value.range;textIndex(target.snapshot.text).range(range);result.push({path:target.path,range});}return result; + } + if(kind==='document-symbols'||kind==='document-symbol-tree')return symbols(raw,request.snapshot,kind==='document-symbol-tree'); + if(kind==='signature-help'){ + if(raw===null)return null;object(raw);const signatures=limitList(raw.signatures,16).map(value=>{object(value);const label=clean(value.label,2048);let after=0;return {label,...(value.documentation?{documentation:clean(markup(value.documentation))}:{}),parameters:limitList(value.parameters??[],64).map(p=>{object(p);let range=p.label;if(typeof range==='string'){const start=label.indexOf(range,after);if(start<0)fail('INVALID','Parameter is not in the signature label');range=[start,start+range.length];after=range[1];}return {label:range,...(p.documentation?{documentation:clean(markup(p.documentation))}:{})};})};}); + if(!signatures.length)return null;const activeSignature=raw.activeSignature??0,selected=signatures[activeSignature];if(!selected)fail('INVALID','Signature index outside result');const parameter=raw.signatures[activeSignature]?.activeParameter??raw.activeParameter??null; + return {signatures,activeSignature,activeParameter:parameter!==null&¶meterb.length-a.length).find(k=>action.kind===k||action.kind?.startsWith(k+'.'))??'quickfix';const base={title:clean(action.title,256),kind,...(action.isPreferred!==undefined?{isPreferred:action.isPreferred}:{})}; + if(action.disabled){result.push({...base,disabled:{reason:clean(action.disabled.reason,2048)}});continue;} + if(action.command||!action.edit){result.push({...base,disabled:{reason:action.command?'Server command execution is unsupported':'Code action resolution is unsupported'}});continue;} + const edit=await edits(action.edit,'LSP code action proposal',requestSignal);if(edit)result.push({...base,edit}); + }return result; + } + if(kind==='semantic-tokens'){if(raw!==null)object(raw);const data=raw?.data??[];return {resultId:raw?.resultId??digest(canonical(data)),legend,data,updateKind:'full'};} + if(kind==='folding-ranges'){ + const lines=request.snapshot.text.split(/\r\n|\r|\n/);return limitList(raw??[],1000).map(f=>{object(f);const range={start:{line:f.startLine,character:f.startCharacter??0},end:{line:f.endLine,character:f.endCharacter??lines[f.endLine]?.length}};index.range(range);return {range,...(f.kind?{kind:f.kind}:{}),...(f.collapsedText?{collapsedText:clean(f.collapsedText,256)}:{})};}).sort((a,b)=>compareLanguagePosition(a.range.start,b.range.start)||compareLanguagePosition(b.range.end,a.range.end)); + } + if(kind==='inlay-hints')return limitList(raw??[],1000).map(h=>{object(h);index.offset(h.position);const label=typeof h.label==='string'?h.label:limitList(h.label,32).map(p=>string(p.value,1024)).join('');return {position:h.position,label:clean(label,1024),...(h.kind===1?{kind:'type'}:h.kind===2?{kind:'parameter'}:{}),...(h.paddingLeft!==undefined?{paddingLeft:h.paddingLeft}:{}),...(h.paddingRight!==undefined?{paddingRight:h.paddingRight}:{}),...(h.tooltip?{tooltip:clean(markup(h.tooltip),4096)}:{})};}).sort((a,b)=>compareLanguagePosition(a.position,b.position)); + fail('UNSUPPORTED','Language feature is not mapped'); + } + const api={ + capabilities(){return matrix;}, + inspect(){return {state,generation,documents:documents.size,requests:requests.size,...metrics,transport:rpc.inspect(),process:proc.inspect()};}, + syncDocument(input){ + const snapshot=parseDocumentSnapshot(input);return serial(async()=>{ + open();const clock=deadline(lifetime.signal,requestTimeoutMs); + try{ + const local=await localUri(snapshot.uri);if(local.uri!==snapshot.uri)fail('INVALID','Document URI must be canonical');await access('read',local.path,clock.signal); + const old=documents.get(local.uri);if(old&&canonical(old.snapshot)===canonical(snapshot))return snapshot; + if(old&&(snapshot.modelVersion=LSP_LIMITS.documents)fail('LIMIT','Open document count'); + const total=[...documents.values()].reduce((n,d)=>n+Buffer.byteLength(d.snapshot.text),0)-(old?Buffer.byteLength(old.snapshot.text):0)+Buffer.byteLength(snapshot.text);if(total>LSP_LIMITS.documentBytes)fail('LIMIT','Open document byte budget'); + invalidate();documents.set(local.uri,{path:local.path,snapshot}); + if(!old)await rpc.notify('textDocument/didOpen',{textDocument:{uri:local.uri,languageId:snapshot.languageId,version:snapshot.modelVersion,text:snapshot.text}}); + else if(snapshot.modelVersion!==old.snapshot.modelVersion)await rpc.notify('textDocument/didChange',{textDocument:{uri:local.uri,version:snapshot.modelVersion},contentChanges:[{text:snapshot.text}]}); + return snapshot; + }finally{clock.close();} + }); + }, + closeDocument(uri){return serial(async()=>{open();const local=await localUri(uri);if(!documents.has(local.uri))return;invalidate();documents.delete(local.uri);await rpc.notify('textDocument/didClose',{textDocument:{uri:local.uri}});});}, + async request(input,{signal:callerSignal}={}){ + open();const request=parseLanguageRequest(input);if(canonical(request.scope)!==canonical(scope))fail('DENIED','Language request scope differs'); + const feature=matrix.features.find(f=>f.sdkFeature===request.kind);if(!feature?.supported)fail('UNSUPPORTED','Language feature was not negotiated'); + if(requests.has(request.requestId))fail('CONFLICT','Duplicate active SDK request ID');if(requests.size>=16)fail('LIMIT','Bridge request concurrency'); + await api.syncDocument(request.snapshot);const doc=documents.get(request.snapshot.uri);if(request.path!==undefined&&request.path!==doc.path)fail('DENIED','Request path differs from document URI'); + // Recheck after synchronization because concurrent callers can share a request ID. + if(requests.has(request.requestId))fail('CONFLICT','Duplicate active SDK request ID'); + const epoch=generation,controller=new AbortController(),cancel=()=>controller.abort();callerSignal?.addEventListener('abort',cancel,{once:true});if(callerSignal?.aborted)cancel();requests.set(request.requestId,controller); + const clock=deadline(controller.signal,requestTimeoutMs); + try{ + await access('read',doc.path,clock.signal);current(request.snapshot,epoch); + const params={textDocument:{uri:request.snapshot.uri},...(request.position?{position:request.position}:{})}; + if(request.kind==='references')params.context={includeDeclaration:request.includeDeclaration??false}; + if(request.kind==='completion'&&request.context)params.context={triggerKind:{invoked:1,character:2,incomplete:3}[request.context.triggerKind],...(request.context.triggerCharacter?{triggerCharacter:request.context.triggerCharacter}:{})}; + if(request.kind==='signature-help'&&request.context)params.context={triggerKind:{invoked:1,character:2,'content-change':3}[request.context.triggerKind],isRetrigger:request.context.isRetrigger,...(request.context.triggerCharacter?{triggerCharacter:request.context.triggerCharacter}:{})}; + if(request.kind==='rename')params.newName=request.newName; + if(request.range)params.range=request.range; + if(request.formatOptions){const {endOfLine,...options}=request.formatOptions;params.options=options;} + if(request.kind==='code-actions')params.context={diagnostics:request.diagnosticContext.diagnostics.map(d=>({range:d.range,severity:Object.keys(severity).find(k=>severity[k]===d.severity)*1,message:d.message,...(d.code?{code:d.code}:{})})),triggerKind:request.context.triggerKind==='automatic'?2:1,...(request.context.only?{only:request.context.only}:{})}; + const raw=await rpc.request(feature.method,params,{signal:clock.signal,timeoutMs:requestTimeoutMs});current(request.snapshot,epoch); + const data=await transform(request,raw,clock.signal);await access('read',doc.path,clock.signal);current(request.snapshot,epoch);return createLanguageResult(request,data); + }catch(error){if(epoch!==generation)fail('STALE','Document changed while the server was working');throw error;} + finally{clock.close();requests.delete(request.requestId);callerSignal?.removeEventListener('abort',cancel);} + }, + provider(kind){const feature=matrix.features.find(f=>f.sdkFeature===kind);if(!feature?.supported)fail('UNSUPPORTED','Provider capability unavailable');return Object.freeze({provide:(request,options)=>{if(request.kind!==kind)fail('INVALID','Wrong provider feature');return api.request(request,options);}});}, + close(){if(closePromise)return closePromise;state='closing';invalidate();return closePromise=(async()=>{ + let graceful=false,reason=null; + try{ + await docTail; + if(!rpc.inspect().closed){for(const doc of documents.values())await rpc.notify('textDocument/didClose',{textDocument:{uri:doc.snapshot.uri}});await rpc.request('shutdown',undefined,{timeoutMs:Math.min(requestTimeoutMs,1000)});await rpc.notify('exit');graceful=true; + let timer;try{await Promise.race([proc.finished,new Promise(r=>{timer=setTimeout(r,500);})]);}finally{clearTimeout(timer);}} + else reason=rpc.inspect().failure; + }catch(error){reason=errorCode(error);} + finally{documents.clear();signal?.removeEventListener('abort',abort);await rpc.close();lifetime.abort();state='closed';} + return immutable({schemaVersion:1,graceful,reason,ownedProcessClosed:proc.inspect().closed,pendingRequests:rpc.inspect().pending}); + })();}, + }; + return Object.freeze(api); +} diff --git a/src/lsp-transport-node.mjs b/src/lsp-transport-node.mjs new file mode 100644 index 0000000..6639992 --- /dev/null +++ b/src/lsp-transport-node.mjs @@ -0,0 +1,80 @@ +import {canonical,fail,fields,integer,jsonCopy,deadline,aborted,errorCode} from './workflow-internals.mjs'; + +export const LSP_LIMITS=Object.freeze({messageBytes:1_048_576,headerBytes:8192,sessionBytes:67_108_864,messages:20_000,pendingRequests:16,queuedWrites:16,timeoutMs:30_000,documents:32,documentBytes:4_194_304,stderrBytes:65_536}); +export function encodeLspMessage(message){const body=Buffer.from(canonical(message,LSP_LIMITS.messageBytes));return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`,'ascii'),body]);} +export function createLspFrameDecoder(){ + let buffer=Buffer.alloc(0),expected=null,total=0,messages=0,failed=false; + return Object.freeze({ + push(input){ + if(failed)fail('DISPOSED','Decoder failed');if(!(input instanceof Uint8Array)||input.byteLength>65_536)fail('LIMIT','LSP input chunk limit'); + try{ + total+=input.byteLength;if(total>LSP_LIMITS.sessionBytes)fail('LIMIT','LSP session byte budget');buffer=Buffer.concat([buffer,Buffer.from(input)]);const output=[]; + while(true){ + if(expected===null){ + const end=buffer.indexOf('\r\n\r\n');if(end<0){if(buffer.length>LSP_LIMITS.headerBytes)fail('LIMIT','LSP header budget');break;} + if(end>LSP_LIMITS.headerBytes)fail('LIMIT','LSP header budget');const raw=buffer.subarray(0,end);if([...raw].some(b=>b!==13&&b!==10&&(b<32||b>126)))fail('INVALID','LSP headers must be ASCII'); + const headers=new Map();for(const line of raw.toString('ascii').split('\r\n')){const split=line.indexOf(':');if(split<1)fail('INVALID','Malformed LSP header');const key=line.slice(0,split).toLowerCase(),value=line.slice(split+1).trim();if(headers.has(key)||!['content-length','content-type'].includes(key))fail('INVALID','Duplicate or unknown LSP header');headers.set(key,value);} + const length=headers.get('content-length');if(!/^(0|[1-9]\d*)$/.test(length??''))fail('INVALID','Invalid LSP content length');expected=Number(length);integer(expected,2,LSP_LIMITS.messageBytes); + if(headers.has('content-type')&&!/^application\/vscode-jsonrpc(?:\s*;\s*charset\s*=\s*(?:utf-8|utf8))?$/i.test(headers.get('content-type')))fail('UNSUPPORTED','LSP encoding is not UTF-8'); + buffer=buffer.subarray(end+4); + } + if(buffer.lengthLSP_LIMITS.messages)fail('LIMIT','LSP message budget');output.push(message);buffer=buffer.subarray(expected);expected=null; + } + return output; + }catch(e){failed=true;buffer=Buffer.alloc(0);throw e;} + }, + finish(){if(buffer.length||expected!==null)fail('INVALID','Incomplete LSP frame');}, + inspect(){return {bufferedBytes:buffer.length,totalBytes:total,messages,failed};}, + }); +} + +export function createLspRpc(proc,{signal,onNotification=async()=>{},onRequest=async()=>{throw Object.assign(new Error('Unsupported server request'),{code:'UNSUPPORTED'});},timeoutMs=5000}={}){ + integer(timeoutMs,1,LSP_LIMITS.timeoutMs);const decoder=createLspFrameDecoder(),pending=new Map();let nextId=0,closed=false,fatal=null,tail=Promise.resolve(),queued=0,stderrBytes=0,lateResponses=0,unsupportedRequests=0; + const stop=error=>{if(closed)return;closed=true;fatal=error;for(const p of pending.values())p.reject(error);pending.clear();proc.stop().catch(()=>{});}; + function send(value,skip=()=>false){ + if(closed)return Promise.reject(fatal??Object.assign(new Error('LSP closed'),{code:'DISPOSED'}));if(queued>=16)return Promise.reject(Object.assign(new Error('LSP write queue full'),{code:'LIMIT'})); + const bytes=encodeLspMessage(value);queued++;const p=tail.then(()=>{if(skip())return;if(closed)throw fatal??Object.assign(new Error('LSP closed'),{code:'DISPOSED'});return new Promise((resolve,reject)=>proc.child.stdin.write(bytes,error=>error?reject(Object.assign(new Error('LSP write failed'),{code:'SERVER_EXIT'})):resolve()));}); + tail=p.catch(error=>{stop(error);}).finally(()=>queued--);return p; + } + const notify=(method,params)=>send({jsonrpc:'2.0',method,...(params===undefined?{}:{params})}); + async function dispatch(message){ + if(typeof message.method==='string'){ + fields(message,['jsonrpc','method'],['id','params']);if(message.method.length>256)fail('INVALID','RPC method too long'); + if(Object.hasOwn(message,'id')){ + if(!(Number.isSafeInteger(message.id)||typeof message.id==='string'&&message.id.length<=128))fail('INVALID','Invalid RPC request ID'); + try{const result=await onRequest(message.method,message.params);await send({jsonrpc:'2.0',id:message.id,result:result??null});} + catch{unsupportedRequests++;await send({jsonrpc:'2.0',id:message.id,error:{code:-32601,message:'Unsupported by the reviewed DDS LSP bridge'}});} + }else await onNotification(message.method,message.params); + return; + } + fields(message,['jsonrpc','id'],['result','error']);if(Object.hasOwn(message,'result')===Object.hasOwn(message,'error'))fail('INVALID','RPC response requires result or error'); + if(message.error!==undefined&&(!message.error||!Number.isSafeInteger(message.error.code)||typeof message.error.message!=='string'))fail('INVALID','Invalid RPC error'); + const waiting=pending.get(message.id);if(!waiting){lateResponses++;return;}pending.delete(message.id); + if(message.error!==undefined){waiting.reject(Object.assign(new Error('Language server rejected request'),{code:message.error.code===-32601?'UNSUPPORTED':'SERVER_ERROR',rpcCode:message.error.code}));} + else waiting.resolve(message.result); + } + const reads=(async()=>{try{for await(const chunk of proc.child.stdout){for(const message of decoder.push(chunk))await dispatch(message);}decoder.finish();if(!closed)stop(Object.assign(new Error('Language server closed stdout'),{code:'SERVER_EXIT'}));}catch(error){stop(error);}})(); + const errors=(async()=>{try{for await(const chunk of proc.child.stderr){stderrBytes+=chunk.length;if(stderrBytes>LSP_LIMITS.stderrBytes)fail('LIMIT','Language server stderr budget');}}catch(error){stop(error);}})(); + const abort=()=>stop(Object.assign(new Error('LSP lifecycle cancelled'),{code:'CANCELLED'}));signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort(); + proc.finished.then(()=>{if(!closed)stop(Object.assign(new Error('Language server exited'),{code:'SERVER_EXIT'}));}); + return Object.freeze({ + notify, + async request(method,params,{signal:requestSignal,timeoutMs:limit=timeoutMs}={}){ + if(closed)throw fatal??Object.assign(new Error('LSP closed'),{code:'DISPOSED'});if(pending.size>=16)fail('LIMIT','LSP request concurrency');integer(limit,1,LSP_LIMITS.timeoutMs); + const clock=deadline(requestSignal,limit);const id=++nextId;let cancel; + try{ + aborted(clock.signal); + return await new Promise((resolve,reject)=>{ + pending.set(id,{resolve,reject});cancel=()=>{if(!pending.delete(id))return;reject(Object.assign(new Error('LSP request stopped'),{code:clock.signal.reason?.code==='TIMEOUT'?'TIMEOUT':'CANCELLED'}));if(!closed)notify('$/cancelRequest',{id}).catch(()=>{});}; + clock.signal.addEventListener('abort',cancel,{once:true});if(clock.signal.aborted){cancel();return;} + send({jsonrpc:'2.0',id,method,...(params===undefined?{}:{params})},()=>!pending.has(id)).catch(error=>{pending.delete(id);reject(error);}); + }); + }finally{if(cancel)clock.signal.removeEventListener('abort',cancel);clock.close();pending.delete(id);} + }, + inspect(){return {closed,pending:pending.size,queued,stderrBytes,lateResponses,unsupportedRequests,failure:fatal?errorCode(fatal):null,...decoder.inspect()};}, + async close(){closed=true;signal?.removeEventListener('abort',abort);for(const p of pending.values())p.reject(Object.assign(new Error('LSP closed'),{code:'DISPOSED'}));pending.clear();await proc.stop();await Promise.allSettled([reads,errors,tail]);}, + }); +} diff --git a/src/owned-json-store-node.mjs b/src/owned-json-store-node.mjs new file mode 100644 index 0000000..d8746c7 --- /dev/null +++ b/src/owned-json-store-node.mjs @@ -0,0 +1,104 @@ +import * as fs from 'node:fs/promises'; +import {constants} from 'node:fs'; +import path from 'node:path'; +import {hostname} from 'node:os'; +import {randomUUID} from 'node:crypto'; +import {canonical, digest, fail, fields, identifier, integer, scope, jsonCopy} from './workflow-internals.mjs'; + +const inside=(root,target)=>{const r=path.relative(root,target);return r==='' || r!=='..' && !r.startsWith('..'+path.sep) && !path.isAbsolute(r);}; +const same=(a,b)=>a.dev===b.dev && a.ino===b.ino; +async function directory(value) { + if(typeof value!=='string' || !path.isAbsolute(value)) fail('INVALID','Absolute owner directory required'); + const s=await fs.lstat(value); if(!s.isDirectory() || s.isSymbolicLink()) fail('INVALID','Real directory required'); + return {path:await fs.realpath(value),stat:s}; +} +async function bytes(file,max) { + const s=await fs.lstat(file); if(!s.isFile() || s.isSymbolicLink() || s.nlink!==1 || s.size>max) fail('INVALID','Expected bounded regular file'); + const h=await fs.open(file,constants.O_RDONLY|(constants.O_NOFOLLOW ?? 0)); + try { + const opened=await h.stat(); if(!same(s,opened) || opened.nlink!==1 || opened.size>max) fail('CONFLICT','File changed'); + const data=Buffer.alloc(opened.size); let offset=0; + while(offsetJSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)); +const dead=owner=>{if(owner.host!==hostname()||owner.pid===process.pid)return false;try{process.kill(owner.pid,0);return false;}catch(e){return e.code==='ESRCH';}}; +function lockOwner(value){fields(value,['pid','host','token']);integer(value.pid,1,Number.MAX_SAFE_INTEGER);identifier(value.token);if(typeof value.host!=='string'||value.host.length>256)fail('INVALID','Bad owner');return value;} + +/** Internal single-writer storage; never scans or removes anything outside its exact owned directory. */ +export async function openOwnedJsonStore({directory:location,workspaceRoot,scope:binding,kind,maxEntries,maxBytes,recordBytes,recoverStaleLock=false}) { + binding=jsonCopy(scope(binding)); identifier(kind); integer(maxEntries,1,512); integer(maxBytes,1024,67_108_864); integer(recordBytes,1024,4_194_304); + if(typeof recoverStaleLock!=='boolean')fail('INVALID','Invalid recovery policy'); + const workspace=await directory(workspaceRoot),root=await directory(location); + if(inside(workspace.path,root.path)||inside(root.path,workspace.path))fail('INVALID','Store and workspace must be disjoint'); + const marker={schemaVersion:1,kind,scope:binding,workspace:digest(canonical({path:workspace.path,dev:workspace.stat.dev,ino:workspace.stat.ino}))}; + const owner={pid:process.pid,host:hostname(),token:randomUUID()},lock=path.join(root.path,'.writer.lock'); + let closed=false,closing=false,tail=Promise.resolve(),queued=0,closePromise; + const entries=new Map(),problems=new Map(),orphans=new Set(),unknown=[]; + async function checkRoot(){const r=await directory(root.path),w=await directory(workspace.path);if(!same(r.stat,root.stat)||!same(w.stat,workspace.stat)||r.path!==root.path||w.path!==workspace.path)fail('CONFLICT','Store or workspace changed');} + async function owned(){if(closed)fail('DISPOSED','Store closed');await checkRoot();const value=lockOwner(decode(await bytes(lock,2048)));if(value.token!==owner.token)fail('CONFLICT','Store ownership changed');} + async function exclusive(file,data){const h=await fs.open(file,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|(constants.O_NOFOLLOW ?? 0),0o600);try{await h.writeFile(data);await h.sync();}finally{await h.close();}} + async function acquire(){ + const guard=path.join(root.path,'.recovery.lock'); + try{await fs.lstat(guard);fail('CONFLICT','Recovery in progress');}catch(e){if(e.code!=='ENOENT')throw e;} + try{await exclusive(lock,canonical(owner));} + catch(e){ + if(e.code!=='EEXIST')throw e; + if(!recoverStaleLock)fail('CONFLICT','Writer lock exists; explicit stale recovery is required'); + const old=lockOwner(decode(await bytes(lock,2048)));if(!dead(old))fail('CONFLICT','Writer is not proven dead'); + await exclusive(guard,canonical(owner)); + try{await checkRoot();const current=lockOwner(decode(await bytes(lock,2048)));if(current.token!==old.token||!dead(current))fail('CONFLICT','Writer changed');await fs.unlink(lock);await exclusive(lock,canonical(owner));} + finally{const g=lockOwner(decode(await bytes(guard,2048)));if(g.token===owner.token)await fs.unlink(guard);} + } + try{await fs.lstat(guard);fail('CONFLICT','Recovery raced writer');}catch(e){if(e.code!=='ENOENT')throw e;} + } + async function atomic(file,data){ + await owned();const temporary=path.join(root.path,`.pending-${owner.token}-${randomUUID()}`);let created=false; + try{ + await exclusive(temporary,data);created=true;await owned(); + try{await bytes(file,recordBytes+4096);}catch(e){if(e.code!=='ENOENT')throw e;} + await fs.rename(temporary,file);created=false; + if(process.platform!=='win32'){const h=await fs.open(root.path,constants.O_RDONLY);try{await h.sync();}finally{await h.close();}} + await owned(); + }finally{if(created){await owned();await bytes(temporary,recordBytes+4096);await fs.unlink(temporary);}} + } + const recordFile=id=>path.join(root.path,id+'.json'); + const envelope=value=>canonical({schemaVersion:1,sha256:digest(canonical(value,recordBytes)),value},recordBytes+4096); + async function readOne(id){ + const raw=await bytes(recordFile(id),recordBytes+4096),e=decode(raw);fields(e,['schemaVersion','sha256','value']); + if(e.schemaVersion!==1||e.sha256!==digest(canonical(e.value,recordBytes)))fail('CORRUPT','Stored digest mismatch'); + return {value:jsonCopy(e.value,recordBytes),bytes:raw.length}; + } + try{ + const before=await fs.readdir(root.path);if(before.length && !before.includes('.dds-store.json'))fail('CONFLICT','Nonempty directory is not this store'); + await acquire(); + try{if(canonical(decode(await bytes(path.join(root.path,'.dds-store.json'),4096)))!==canonical(marker))fail('CONFLICT','Store binding changed');} + catch(e){if(e.code!=='ENOENT')throw e;await exclusive(path.join(root.path,'.dds-store.json'),canonical(marker));} + const files=await fs.readdir(root.path);if(files.length>maxEntries+128)fail('LIMIT','Store entry budget'); + for(const name of files){ + if(['.dds-store.json','.writer.lock'].includes(name))continue; + if(/^\.pending-[a-f0-9-]{36}-[a-f0-9-]{36}$/.test(name)){orphans.add(name);continue;} + if(!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\.json$/.test(name)){unknown.push(name);continue;} + const id=name.slice(0,-5); + try{entries.set(id,await readOne(id));}catch(e){problems.set(id,e.code ?? 'CORRUPT');} + } + if(entries.size+problems.size>maxEntries||[...entries.values()].reduce((n,e)=>n+e.bytes,0)>maxBytes)fail('LIMIT','Stored quota exceeded'); + }catch(e){try{const o=lockOwner(decode(await bytes(lock,2048)));if(o.token===owner.token)await fs.unlink(lock);}catch{}throw e;} + function enqueue(action){if(closed||closing)fail('DISPOSED','Store closed');if(queued>=64)fail('LIMIT','Store queue full');queued++;const p=tail.then(action);tail=p.catch(()=>{}).finally(()=>{queued--;});return p;} + return Object.freeze({ + async get(id){identifier(id);return enqueue(async()=>{await owned();if(problems.has(id))return null;if(!entries.has(id))return null;try{const r=await readOne(id);entries.set(id,r);return r.value;}catch(e){problems.set(id,e.code ?? 'CORRUPT');entries.delete(id);return null;}});}, + async list(){return enqueue(async()=>{await owned();return [...entries.keys()];});}, + async set(id,value){identifier(id);const data=envelope(value);return enqueue(async()=>{ + await owned();const old=entries.get(id);if(!old&&!problems.has(id)&&entries.size+problems.size>=maxEntries)fail('LIMIT','Store entry quota'); + const size=Buffer.byteLength(data);if([...entries.values()].reduce((n,e)=>n+e.bytes,0)-(old?.bytes??0)+size>maxBytes)fail('LIMIT','Store byte quota'); + await atomic(recordFile(id),data);entries.set(id,{value:jsonCopy(value,recordBytes),bytes:size});problems.delete(id); + });}, + async remove(id){identifier(id);return enqueue(async()=>{await owned();if(!entries.has(id)&&!problems.has(id))return false;await bytes(recordFile(id),recordBytes+4096);await fs.unlink(recordFile(id));entries.delete(id);problems.delete(id);return true;});}, + async cleanup(){return enqueue(async()=>{await owned();const removed=[],preserved=[...unknown];for(const name of [...orphans,...[...problems.keys()].map(id=>id+'.json')]){try{await bytes(path.join(root.path,name),recordBytes+4096);await fs.unlink(path.join(root.path,name));removed.push(name);orphans.delete(name);problems.delete(name.slice(0,-5));}catch{preserved.push(name);}}return {removed,preserved};});}, + inspect(){return {entries:entries.size,bytes:[...entries.values()].reduce((n,e)=>n+e.bytes,0),problems:[...problems.keys()],orphans:orphans.size,unknown:unknown.length,queued,closed,directorySynced:process.platform!=='win32'};}, + close(){if(closePromise)return closePromise;closing=true;return closePromise=(async()=>{await tail;await owned();await fs.unlink(lock);closed=true;})();}, + }); +} diff --git a/src/schemas.d.mts b/src/schemas.d.mts index 506b68d..3ad6d09 100644 --- a/src/schemas.d.mts +++ b/src/schemas.d.mts @@ -1,2 +1,2 @@ // Generated by npm run schemas:write. -export const SCHEMAS: Readonly>>>; +export const SCHEMAS: Readonly>>>; diff --git a/src/schemas.mjs b/src/schemas.mjs index c094b39..6636bd2 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -24,6 +24,7 @@ import {LOCALIZATION_SCHEMAS} from './localization-schemas.mjs'; import {DEVELOPMENT_SCHEMAS} from './development-schemas.mjs'; import {CONFORMANCE_SCHEMAS} from './conformance-schemas.mjs'; import {RELEASE_SCHEMAS} from './release-schemas.mjs'; +import {WORKFLOW_SCHEMAS} from './workflow-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -123,6 +124,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(WORKFLOW_SCHEMAS).map(([name,body])=>[name,define(name,body)])), ...Object.fromEntries(Object.entries(CONFORMANCE_SCHEMAS).map(([name,body])=>[name,define(name,body)])), ...Object.fromEntries(Object.entries(RELEASE_SCHEMAS).map(([name,body])=>[name,define(name,body)])), ...Object.fromEntries(Object.entries(DEVELOPMENT_SCHEMAS).map(([name,body])=>[name,define(name,body)])), diff --git a/src/tool-streams-node.d.mts b/src/tool-streams-node.d.mts new file mode 100644 index 0000000..c0510a9 --- /dev/null +++ b/src/tool-streams-node.d.mts @@ -0,0 +1,12 @@ +import type {JsonValue} from './index.mjs'; +import type {DataSchema} from './data-schema.mjs'; +import type {ToolStreamEvent,ToolStreamStats,ToolStreamParser} from './tool-streams.mjs'; +export const TOOL_RUNNER_LIMITS:Readonly<{tools:number;concurrency:number;timeoutMs:number}>; +/** Operator-owned process configuration; never derive it from plugin input. */ +export interface TrustedProcessDefinition {readonly id:string;readonly version:string;readonly approved:true;readonly executable:string;readonly executableSha256:string;readonly files:readonly {readonly path:string;readonly sha256:string}[];readonly args:readonly (string|{readonly input:string})[];readonly versionArgs:readonly string[];readonly expectedVersionOutput:string;readonly environment:Readonly>;} +export interface ToolRegistration {readonly process:TrustedProcessDefinition;readonly backendId:string;readonly inputSchema:DataSchema;readonly pathInputs:readonly string[];readonly format:'jsonl'|'text';readonly parser:'plain'|'typescript';} +export interface ToolRunReceipt {readonly schemaVersion:1;readonly toolId:string;readonly toolVersion:string;readonly jobId:string;readonly commandId:string;readonly state:'succeeded'|'failed'|'cancelled'|'timed_out';readonly reason:string|null;readonly exitCode:number|null;readonly terminationSignal:string|null;readonly stream:ToolStreamStats|ReturnType|null;} +export interface ToolRunner {inspect():Readonly<{active:number;pending:number;closed:boolean}>;run(options:{readonly toolId:string;readonly version:string;readonly input:JsonValue;readonly jobId:string;readonly commandId:string;readonly onEvent:(event:ToolStreamEvent)=>void|Promise;readonly signal?:AbortSignal;readonly timeoutMs?:number}):Promise;close():Promise;} +export function hashToolFile(file:string):Promise; +export function parseToolRegistration(input:unknown):ToolRegistration; +export function createToolRunner(options:{readonly tools:readonly ToolRegistration[];readonly workspaceRoot:string;readonly authorize:(context:{readonly toolId:string;readonly version:string;readonly backendId:string;readonly jobId:string;readonly commandId:string;readonly input:JsonValue;readonly phase:'before'|'start'|'event'|'after'})=>boolean|Promise;readonly maxConcurrent?:number;readonly secrets?:readonly string[];readonly sensitivePaths?:readonly string[]}):ToolRunner; diff --git a/src/tool-streams-node.mjs b/src/tool-streams-node.mjs new file mode 100644 index 0000000..1d1992b --- /dev/null +++ b/src/tool-streams-node.mjs @@ -0,0 +1,55 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import {parseWorkspacePath} from './contracts.mjs'; +import {parseDataSchema,validateDataValue} from './data-schema.mjs'; +import {createToolStreamParser} from './tool-streams.mjs'; +import {verifyTrustedProcess,parseTrustedProcessDefinition,hashToolFile,realToolWorkspace,checkToolWorkspace,spawnTrustedProcess,probeTrustedProcess} from './trusted-process-node.mjs'; +import {canonical,clone,fail,fields,identifier,integer,immutable,jsonCopy,deadline,interruptible,authorized,errorCode} from './workflow-internals.mjs'; + +export {hashToolFile}; +export const TOOL_RUNNER_LIMITS=Object.freeze({tools:32,concurrency:4,timeoutMs:600_000}); +export function parseToolRegistration(input){ + const value=jsonCopy(input,131_072);fields(value,['process','backendId','inputSchema','pathInputs','format','parser']);parseTrustedProcessDefinition(value.process);identifier(value.backendId); + const schema=parseDataSchema(value.inputSchema);if(schema.schema.type!=='object'||schema.schema.format)fail('INVALID','Tool input must be a closed object'); + if(!Array.isArray(value.pathInputs)||new Set(value.pathInputs).size!==value.pathInputs.length||value.pathInputs.length>64)fail('INVALID','Invalid path slots'); + for(const name of value.pathInputs){identifier(name);if(schema.schema.properties[name]?.type!=='string')fail('INVALID','Path input must be a declared string');} + if(!['jsonl','text'].includes(value.format)||!['plain','typescript'].includes(value.parser))fail('INVALID','Tool parser not supported'); + for(const arg of value.process.args)if(typeof arg!=='string'){const property=schema.schema.properties[arg.input];if(!property||!['string','integer','number','boolean'].includes(property.type)||!schema.schema.required?.includes(arg.input))fail('INVALID','Argument input must be a required scalar');} + return value; +} +async function inputPath(root,value){ + parseWorkspacePath(value);let candidate=root; + for(const component of value.split('/')){candidate=path.join(candidate,component);const info=await fs.lstat(candidate);if(info.isSymbolicLink())fail('INVALID','Path input cannot traverse links');} + const resolved=await fs.realpath(candidate),relative=path.relative(root,resolved);if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)||!(await fs.lstat(candidate)).isFile())fail('INVALID','Path input must be a workspace file'); +} +export function createToolRunner({tools,workspaceRoot,authorize,maxConcurrent=2,secrets=[],sensitivePaths=[]}={}){ + if(!Array.isArray(tools)||!tools.length||tools.length>32||typeof authorize!=='function')fail('INVALID','Operator registrations and authorization required');integer(maxConcurrent,1,4); + const registrations=new Map(tools.map(t=>{const r=parseToolRegistration(t);return [r.process.id,r];}));if(registrations.size!==tools.length)fail('INVALID','Duplicate registered tool'); + const masks=jsonCopy({secrets,sensitivePaths}),active=new Set(),controllers=new Set(),completions=new Set();let pending=0,closed=false; + return Object.freeze({ + inspect(){return {active:active.size,pending,closed};}, + async run({toolId,version,input,jobId,commandId,onEvent,signal,timeoutMs=60_000}={}){ + if(closed)fail('DISPOSED','Tool runner closed');if(pending>=maxConcurrent)fail('LIMIT','Tool concurrency limit');if(typeof onEvent!=='function')fail('INVALID','Event sink required');integer(timeoutMs,1,TOOL_RUNNER_LIMITS.timeoutMs);identifier(jobId);identifier(commandId); + const registration=registrations.get(toolId);if(!registration||version!==registration.process.version)fail('UNSUPPORTED','Tool or version is not registered'); + const parsed=validateDataValue(registration.inputSchema,input),clock=deadline(signal,timeoutMs);let proc,parser,stats=null,state='failed',reason=null,exit={code:null,signal:null,error:null};pending++; + controllers.add(clock.controller);let complete;const completed=new Promise(r=>{complete=r;});completions.add(completed); + const context={toolId,version,backendId:registration.backendId,jobId,commandId,input:parsed}; + try{ + await authorized(authorize,{...context,phase:'before'},clock.signal); + const workspace=await realToolWorkspace(workspaceRoot),verified=await interruptible(verifyTrustedProcess(registration.process),clock.signal); + for(const name of registration.pathInputs)await inputPath(workspace.root,parsed[name]); + const args=verified.definition.args.map(arg=>{if(typeof arg==='string')return arg;const value=String(parsed[arg.input]);if(value.startsWith('-')||value.includes('\0')||/[\r\n]/.test(value))fail('INVALID','Argument values cannot inject flags or lines');return value;}); + await probeTrustedProcess(verified.definition,workspace,clock.signal);await authorized(authorize,{...context,phase:'start'},clock.signal);await checkToolWorkspace(workspace); + parser=createToolStreamParser({format:registration.format,parser:registration.parser,jobId,commandId,toolId,toolVersion:version,...masks,sensitivePaths:[...masks.sensitivePaths,workspace.root],signal:clock.signal,onEvent:async event=>{await authorized(authorize,{...context,phase:'event'},clock.signal);await onEvent(event);}}); + proc=spawnTrustedProcess(verified.definition,args,workspace,clock.signal);active.add(proc);proc.child.stdin.end(); + const consume=async(stream,name)=>{for await(const chunk of stream)await parser.write(chunk,name);}; + const reads=Promise.all([consume(proc.child.stdout,'stdout'),consume(proc.child.stderr,'stderr')]); + [exit]=await interruptible(Promise.all([proc.finished,reads]),clock.signal);stats=await parser.finish();await authorized(authorize,{...context,phase:'after'},clock.signal);await checkToolWorkspace(workspace); + state=exit.code===0&&!exit.error?'succeeded':'failed';reason=exit.error??(exit.code===0?null:'NONZERO_EXIT'); + }catch(error){reason=errorCode(error);state=reason==='CANCELLED'?'cancelled':reason==='TIMEOUT'?'timed_out':'failed';clock.controller.abort(error);} + finally{parser?.dispose();try{if(proc)exit=await proc.stop();}finally{if(proc)active.delete(proc);clock.close();pending--;controllers.delete(clock.controller);complete();completions.delete(completed);}} + return immutable({schemaVersion:1,toolId,toolVersion:version,jobId,commandId,state,reason,exitCode:exit.code,terminationSignal:exit.signal,stream:stats??parser?.inspect()??null}); + }, + async close(){closed=true;for(const controller of controllers)controller.abort();await Promise.allSettled([...completions]);}, + }); +} diff --git a/src/tool-streams.d.mts b/src/tool-streams.d.mts new file mode 100644 index 0000000..5768f30 --- /dev/null +++ b/src/tool-streams.d.mts @@ -0,0 +1,11 @@ +import type {Diagnostic} from './index.mjs'; +import type {JobReporter} from './jobs.mjs'; +export const TOOL_STREAM_LIMITS:Readonly<{chunkBytes:number;lineBytes:number;totalBytes:number;events:number;queuedWrites:number;eventTimeoutMs:number}>; +export type ToolStreamData = {readonly kind:'log';readonly level:'debug'|'info'|'warning'|'error';readonly message:string} | {readonly kind:'progress';readonly completed:number;readonly total:number;readonly message?:string} | ({readonly kind:'diagnostic';readonly path:string}&Diagnostic) | {readonly kind:'unknown';readonly reason:string;readonly message:string}; +export interface ToolStreamEvent {readonly schemaVersion:1;readonly sequence:number;readonly jobId:string;readonly commandId:string;readonly toolId:string;readonly toolVersion:string;readonly stream:'stdout'|'stderr';readonly data:ToolStreamData;} +export interface ToolStreamStats {readonly bytes:number;readonly events:number;readonly unknown:number;readonly peakQueued:number;} +export interface ToolStreamParser {write(input:Uint8Array,stream?:'stdout'|'stderr'):Promise;finish():Promise;dispose():void;inspect():ToolStreamStats & {readonly queued:number;readonly closed:boolean;readonly failed:boolean};} +export function parseToolStreamEvent(input:unknown):ToolStreamEvent; +export function parseTypeScriptDiagnosticLine(line:string):Extract|null; +export function createToolStreamParser(options:{readonly format?:'jsonl'|'text';readonly parser?:'plain'|'typescript';readonly jobId:string;readonly commandId:string;readonly toolId:string;readonly toolVersion:string;readonly onEvent:(event:ToolStreamEvent)=>void|Promise;readonly secrets?:readonly string[];readonly sensitivePaths?:readonly string[];readonly signal?:AbortSignal;readonly maxLineBytes?:number;readonly maxTotalBytes?:number;readonly maxEvents?:number;readonly eventTimeoutMs?:number}):ToolStreamParser; +export function createJobToolEventSink(job:Pick,options?:{readonly onDiagnostic?:(event:ToolStreamEvent)=>void|Promise}):(event:ToolStreamEvent)=>Promise; diff --git a/src/tool-streams.mjs b/src/tool-streams.mjs new file mode 100644 index 0000000..2fac76e --- /dev/null +++ b/src/tool-streams.mjs @@ -0,0 +1,98 @@ +import {parseJsonValue,parseWorkspacePath} from './contracts.mjs'; + +export const TOOL_STREAM_LIMITS=Object.freeze({chunkBytes:65_536,lineBytes:16_384,totalBytes:2_097_152,events:4096,queuedWrites:8,eventTimeoutMs:5000}); +const failure=(code,message)=>{throw Object.assign(new Error(message),{code});}; +const object=(value,required,optional=[])=>{if(!value||typeof value!=='object'||Array.isArray(value)||required.some(k=>!Object.hasOwn(value,k))||Object.keys(value).some(k=>![...required,...optional].includes(k)))failure('INVALID','Invalid tool stream contract');}; +const text=(v,max=16_384)=>{if(typeof v!=='string'||v.length>max||!v.isWellFormed()||/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(v))failure('INVALID','Invalid stream text');return v;}; +const id=v=>{if(typeof v!=='string'||!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(v))failure('INVALID','Invalid correlation identifier');return v;}; +const integer=(v,max=Number.MAX_SAFE_INTEGER)=>{if(!Number.isSafeInteger(v)||v<0||v>max)failure('INVALID','Invalid stream number');return v;}; +const freeze=value=>{if(value&&typeof value==='object'){Object.values(value).forEach(freeze);Object.freeze(value);}return value;}; +const copy=value=>JSON.parse(JSON.stringify(parseJsonValue(value))); +const bytes=value=>new TextEncoder().encode(value).byteLength; +function range(value){object(value,['start','end']);for(const p of [value.start,value.end]){object(p,['line','character']);integer(p.line,10_000_000);integer(p.character,10_000_000);}if(value.end.linevalue.total)failure('INVALID','Invalid progress');if(value.message!==undefined)text(value.message,2048);} + else if(value.kind==='diagnostic'){object(value,['kind','path','range','severity','message'],['code','source']);parseWorkspacePath(value.path);range(value.range);if(!['error','warning','info','hint'].includes(value.severity))failure('INVALID','Invalid diagnostic severity');text(value.message);if(value.code!==undefined)text(value.code,128);if(value.source!==undefined)text(value.source,128);} + else if(value.kind==='unknown'){object(value,['kind','reason','message']);id(value.reason);text(value.message,1024);} + else failure('UNSUPPORTED','Unsupported tool message'); + return value; +} +export function parseToolStreamEvent(input){ + const value=copy(input);object(value,['schemaVersion','sequence','jobId','commandId','toolId','toolVersion','stream','data']); + if(value.schemaVersion!==1||!['stdout','stderr'].includes(value.stream))failure('INVALID','Invalid event');integer(value.sequence,TOOL_STREAM_LIMITS.events);if(value.sequence<1)failure('INVALID','Invalid sequence'); + id(value.jobId);id(value.commandId);id(value.toolId);text(value.toolVersion,96);message(value.data);return freeze(value); +} +export function parseTypeScriptDiagnosticLine(line){ + text(line);const match=/^(.+)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/.exec(line); + if(!match)return null;const row=Number(match[2]),column=Number(match[3]);if(row<1||column<1)return null; + const position={line:row-1,character:column-1};const value={kind:'diagnostic',path:match[1].replaceAll('\\','/'),range:{start:position,end:{...position}},severity:match[4],code:match[5],message:match[6],source:'typescript'}; + return freeze(message(value)); +} + +/** Bounded, portable decoder. Callers await write() and finish(); no process or network authority. */ +export function createToolStreamParser({format='jsonl',parser='plain',jobId,commandId,toolId,toolVersion,onEvent,secrets=[],sensitivePaths=[],signal,maxLineBytes=TOOL_STREAM_LIMITS.lineBytes,maxTotalBytes=TOOL_STREAM_LIMITS.totalBytes,maxEvents=TOOL_STREAM_LIMITS.events,eventTimeoutMs=TOOL_STREAM_LIMITS.eventTimeoutMs}={}){ + if(!['jsonl','text'].includes(format)||!['plain','typescript'].includes(parser)||typeof onEvent!=='function')failure('INVALID','Invalid stream parser'); + id(jobId);id(commandId);id(toolId);text(toolVersion,96); + for(const [v,max] of [[maxLineBytes,TOOL_STREAM_LIMITS.lineBytes],[maxTotalBytes,TOOL_STREAM_LIMITS.totalBytes],[maxEvents,TOOL_STREAM_LIMITS.events],[eventTimeoutMs,TOOL_STREAM_LIMITS.eventTimeoutMs]]){integer(v,max);if(v<1)failure('INVALID','Invalid stream limit');} + if(!Array.isArray(secrets)||!Array.isArray(sensitivePaths)||secrets.length>32||sensitivePaths.length>32)failure('LIMIT','Masking input limit'); + const masks=[...secrets,...sensitivePaths].map(v=>{text(v,4096);if(!v||/[\r\n]/.test(v))failure('INVALID','Masks must be nonempty single-line text');return v;}).sort((a,b)=>b.length-a.length); + const redact=value=>masks.reduce((s,mask)=>s.split(mask).join('[redacted]'),value); + const streams={stdout:{decoder:new TextDecoder('utf-8',{fatal:true}),pending:''},stderr:{decoder:new TextDecoder('utf-8',{fatal:true}),pending:''}}; + let tail=Promise.resolve(),queued=0,total=0,sequence=0,unknown=0,closed=false,finishing=false,fatal=null,peakQueued=0; + const check=()=>{if(fatal)throw fatal;if(closed)failure('DISPOSED','Parser closed');if(signal?.aborted)failure('CANCELLED','Stream cancelled');}; + async function emit(data,stream){ + check();if(sequence>=maxEvents)failure('LIMIT','Tool event budget');sequence++; + for(const key of ['message','source','code'])if(typeof data[key]==='string')data[key]=redact(data[key]); + // Relative diagnostic locations are data, and are not emitted when a mask would make them invalid. + if(data.kind==='diagnostic'&&redact(data.path)!==data.path)data={kind:'unknown',reason:'masked-path',message:redact(data.message).slice(0,1024)}; + const event=parseToolStreamEvent({schemaVersion:1,sequence,jobId,commandId,toolId,toolVersion,stream,data}); + if(event.data.kind==='unknown')unknown++; + let timer,cancel; + try{await Promise.race([Promise.resolve().then(()=>onEvent(event)),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Object.assign(new Error('Event sink deadline'),{code:'TIMEOUT'})),eventTimeoutMs);cancel=()=>reject(Object.assign(new Error('Stream cancelled'),{code:'CANCELLED'}));signal?.addEventListener('abort',cancel,{once:true});if(signal?.aborted)cancel();})]);} + finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);} + check(); + } + async function line(value,stream){ + if(value.endsWith('\r'))value=value.slice(0,-1);if(bytes(value)>maxLineBytes)failure('LIMIT','Tool line budget');if(!value)return; + let data; + try{ + if(format==='jsonl')data=message(JSON.parse(value)); + else data=parser==='typescript'?parseTypeScriptDiagnosticLine(value):null; + if(!data)data={kind:'log',level:stream==='stderr'?'warning':'info',message:value}; + }catch{data={kind:'unknown',reason:format==='jsonl'?'invalid-jsonl':'invalid-diagnostic',message:redact(value).slice(0,1024)};} + await emit({...data},stream); + } + async function consume(chunk,stream,final=false){ + check();const state=streams[stream];let decoded; + try{decoded=state.decoder.decode(chunk,{stream:!final});}catch{failure('INVALID','Malformed UTF-8 output');} + let start=0; + for(let at=decoded.indexOf('\n');at!==-1;at=decoded.indexOf('\n',start)){ + const part=state.pending+decoded.slice(start,at);state.pending='';await line(part,stream);start=at+1; + } + state.pending+=decoded.slice(start);if(bytes(state.pending)>maxLineBytes)failure('LIMIT','Unterminated tool line budget'); + if(final&&state.pending){const last=state.pending;state.pending='';await line(last,stream);} + } + function enqueue(action){if(queued>=TOOL_STREAM_LIMITS.queuedWrites)failure('LIMIT','Await stream backpressure');queued++;peakQueued=Math.max(peakQueued,queued);const p=tail.then(action);tail=p.catch(e=>{fatal=e;}).finally(()=>queued--);return p;} + return Object.freeze({ + write(input,stream='stdout'){ + check();if(finishing)failure('DISPOSED','Parser finishing');if(!Object.hasOwn(streams,stream)||!(input instanceof Uint8Array))failure('INVALID','Expected output bytes and stream'); + if(input.byteLength>TOOL_STREAM_LIMITS.chunkBytes||total+input.byteLength>maxTotalBytes)failure('LIMIT','Tool output byte budget');const chunk=Uint8Array.from(input);total+=chunk.length; + return enqueue(()=>consume(chunk,stream)); + }, + async finish(){check();if(finishing)failure('DISPOSED','Parser finishing');finishing=true;await enqueue(async()=>{await consume(new Uint8Array(),'stdout',true);await consume(new Uint8Array(),'stderr',true);});closed=true;return {bytes:total,events:sequence,unknown,peakQueued};}, + dispose(){closed=true;for(const state of Object.values(streams))state.pending='';}, + inspect(){return {bytes:total,events:sequence,unknown,queued,peakQueued,closed,failed:!!fatal};}, + }); +} + +/** Connect tool progress/logs to an existing JobReporter; diagnostics stay a caller-owned stream. */ +export function createJobToolEventSink(job,{onDiagnostic=()=>{}}={}){ + if(!job||typeof job.reportProgress!=='function'||typeof job.log!=='function'||typeof onDiagnostic!=='function')failure('INVALID','Job reporter required'); + return async input=>{const event=parseToolStreamEvent(input),data=event.data; + if(data.kind==='progress'){const {kind,...progress}=data;job.reportProgress(progress);} + else if(data.kind==='log')job.log(data.level,data.message); + else if(data.kind==='diagnostic')await onDiagnostic(event); + else job.log('warning',`Unrecognized tool message (${data.reason})`); + }; +} diff --git a/src/trusted-process-node.mjs b/src/trusted-process-node.mjs new file mode 100644 index 0000000..0afc09b --- /dev/null +++ b/src/trusted-process-node.mjs @@ -0,0 +1,62 @@ +import * as fs from 'node:fs/promises'; +import {constants} from 'node:fs'; +import {spawn} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import path from 'node:path'; +import {canonical,clone,digest,fail,fields,identifier,integer,string,sha,immutable,aborted,interruptible,deadline} from './workflow-internals.mjs'; + +export async function hashToolFile(file){ + if(typeof file!=='string'||!path.isAbsolute(file))fail('INVALID','Absolute operator-owned file required'); + const before=await fs.lstat(file);if(!before.isFile()||before.isSymbolicLink()||before.size>268_435_456)fail('INVALID','Tool file is not a bounded regular file'); + const h=await fs.open(file,constants.O_RDONLY|(constants.O_NOFOLLOW ?? 0)); + try{ + const s=await h.stat();if(s.dev!==before.dev||s.ino!==before.ino)fail('CONFLICT','Tool file changed'); + const hasher=createHash('sha256'),buffer=Buffer.alloc(65_536);let offset=0; + while(offset64)fail('LIMIT','Pinned file count'); + for(const file of value.files){fields(file,['path','sha256']);if(!path.isAbsolute(file.path))fail('INVALID','Absolute tool artifact required');sha(file.sha256);} + for(const args of [value.args,value.versionArgs]){if(!Array.isArray(args)||args.length>64)fail('LIMIT','Argument count');for(const arg of args){if(typeof arg==='string')string(arg,'argument',4096);else{fields(arg,['input']);identifier(arg.input);}}} + if(value.versionArgs.some(arg=>typeof arg!=='string'))fail('INVALID','Version probe arguments must be fixed'); + string(value.expectedVersionOutput,'version output',256); + if(!value.environment||typeof value.environment!=='object'||Array.isArray(value.environment)||Object.keys(value.environment).length>32)fail('INVALID','Explicit process environment required'); + for(const [key,entry]of Object.entries(value.environment)){if(!/^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/.test(key)||typeof entry!=='string'||entry.length>4096||entry.includes('\0'))fail('INVALID','Invalid environment');} + return immutable(value); +} +export async function verifyTrustedProcess(input){const definition=parseTrustedProcessDefinition(input);for(const file of [{path:definition.executable,sha256:definition.executableSha256},...definition.files]){if(await hashToolFile(file.path)!==file.sha256)fail('CONFLICT','Configured tool digest changed');}return {definition,sha256:digest(canonical(definition))};} +export async function realToolWorkspace(root){if(typeof root!=='string'||!path.isAbsolute(root))fail('INVALID','Absolute workspace root required');const s=await fs.lstat(root);if(!s.isDirectory()||s.isSymbolicLink())fail('INVALID','Real workspace root required');return {root:await fs.realpath(root),dev:s.dev,ino:s.ino};} +export async function checkToolWorkspace(identity){const now=await realToolWorkspace(identity.root);if(now.root!==identity.root||now.dev!==identity.dev||now.ino!==identity.ino)fail('CONFLICT','Workspace changed');} + +/** Owns only the direct child and its pipes. It is not an OS sandbox or a process-tree manager. */ +export function spawnTrustedProcess(definition,args,workspace,signal){ + aborted(signal);const child=spawn(definition.executable,args,{cwd:workspace.root,env:{...definition.environment},windowsHide:true,shell:false,stdio:['pipe','pipe','pipe']}); + let ended=false,stopPromise,error=null; + const finished=new Promise(resolve=>{child.once('error',e=>{error=e;});child.once('close',(code,terminationSignal)=>{ended=true;resolve({code,signal:terminationSignal,error:error?'PROCESS_START_FAILED':null});});}); + async function stop(){if(stopPromise)return stopPromise;return stopPromise=(async()=>{ + if(ended)return finished;child.stdin.destroy();child.kill();let timer; + try{await Promise.race([finished,new Promise(r=>{timer=setTimeout(r,250);})]);}finally{clearTimeout(timer);} + if(!ended){child.kill('SIGKILL');child.stdout.destroy();child.stderr.destroy();let last;try{await Promise.race([finished,new Promise(r=>{last=setTimeout(r,1000);})]);}finally{clearTimeout(last);}} + if(!ended)fail('PROCESS_UNSETTLED','Owned child did not report close');return finished; + })();} + const abort=()=>{stop().catch(()=>{});};signal?.addEventListener('abort',abort,{once:true});finished.then(()=>signal?.removeEventListener('abort',abort));if(signal?.aborted)abort(); + return {child,finished,stop,inspect:()=>({pid:child.pid??null,closed:ended})}; +} +export async function probeTrustedProcess(definition,workspace,parentSignal){ + const clock=deadline(parentSignal,5000);let proc; + try{ + proc=spawnTrustedProcess(definition,definition.versionArgs,workspace,clock.signal);proc.child.stdin.end(); + let total=0,output='';const read=async(stream,stdout)=>{const decoder=new TextDecoder('utf-8',{fatal:true});for await(const chunk of stream){total+=chunk.length;if(total>16_384)fail('LIMIT','Version probe output limit');if(stdout)output+=decoder.decode(chunk,{stream:true});}if(stdout)output+=decoder.decode();}; + const reads=Promise.all([read(proc.child.stdout,true),read(proc.child.stderr,false)]); + const [exit]=await interruptible(Promise.all([proc.finished,reads]),clock.signal); + if(exit.code!==0||exit.error||output.trim()!==definition.expectedVersionOutput)fail('UNSUPPORTED','Configured tool version probe differs'); + return definition.version; + }finally{clock.close();await proc?.stop();} +} diff --git a/src/version.mjs b/src/version.mjs index 3bd3a09..cdbecf5 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -1,3 +1,3 @@ // The release checks compare this value with the package metadata. -export const SDK_VERSION = '1.1.0'; +export const SDK_VERSION = '1.2.0'; export const SDK_PLUGIN_PEER_RANGE = '>=1.0.0 <2.0.0'; diff --git a/src/workflow-cache-node.d.mts b/src/workflow-cache-node.d.mts new file mode 100644 index 0000000..76de07e --- /dev/null +++ b/src/workflow-cache-node.d.mts @@ -0,0 +1,4 @@ +import type {WorkflowCacheStore} from './workflow-cache.mjs'; +import type {OwnedWorkflowStoreOptions,OwnedWorkflowStoreInspection,OwnedStoreCleanup} from './workflows-node.mjs'; +export interface NodeWorkflowCacheStore extends WorkflowCacheStore {cleanup():Promise;inspect():OwnedWorkflowStoreInspection;close():Promise;} +export function createNodeWorkflowCacheStore(options:OwnedWorkflowStoreOptions):Promise; diff --git a/src/workflow-cache-node.mjs b/src/workflow-cache-node.mjs new file mode 100644 index 0000000..23fd468 --- /dev/null +++ b/src/workflow-cache-node.mjs @@ -0,0 +1,13 @@ +import {openOwnedJsonStore} from './owned-json-store-node.mjs'; +import {parseWorkflowCacheEntry} from './workflow-cache.mjs'; +import {canonical,fail,sha,jsonCopy} from './workflow-internals.mjs'; + +export async function createNodeWorkflowCacheStore(options) { + const binding=jsonCopy(options.scope); + const storage=await openOwnedJsonStore({...options,scope:binding,kind:'workflow-cache-v1',maxEntries:512,maxBytes:67_108_864,recordBytes:131_072}); + return Object.freeze({ + async get(key){const r=await storage.get(sha(key));return r?parseWorkflowCacheEntry(r):null;}, + async set(key,input){const r=parseWorkflowCacheEntry(input);if(r.key!==sha(key)||canonical(r.scope)!==canonical(binding))fail('DENIED','Cache scope or key changed');await storage.set(key,r);}, + list:()=>storage.list(),remove:key=>storage.remove(sha(key)),cleanup:()=>storage.cleanup(),inspect:()=>storage.inspect(),close:()=>storage.close(), + }); +} diff --git a/src/workflow-cache.d.mts b/src/workflow-cache.d.mts new file mode 100644 index 0000000..d09349e --- /dev/null +++ b/src/workflow-cache.d.mts @@ -0,0 +1,16 @@ +import type {JsonValue} from './index.mjs'; +import type {WorkflowScope} from './workflows.mjs'; +import type {OwnedStoreCleanup} from './workflows-node.mjs'; +export const WORKFLOW_CACHE_LIMITS:Readonly<{entries:number;bytes:number;valueBytes:number;files:number;fileBytes:number;inputBytes:number;ttlMs:number;concurrentFills:number;fillTimeoutMs:number}>; +export interface WorkflowCachePolicy {readonly optIn:boolean;readonly deterministic:boolean;readonly declaredInputsComplete:boolean;readonly secretDependent:boolean;} +export interface WorkflowFingerprintInput {readonly scope:WorkflowScope;readonly commandId:string;readonly pluginSha256:string;readonly toolSha256:string|null;readonly schema:JsonValue;readonly settings:JsonValue;readonly environment:{readonly declared:readonly string[];readonly values:Readonly>};readonly input:JsonValue;readonly files:readonly {readonly path:string;readonly bytes:Uint8Array}[];readonly grants:readonly string[];readonly policy:WorkflowCachePolicy;} +export interface WorkflowFingerprint {readonly schemaVersion:1;readonly key:string;readonly eligible:boolean;readonly reasons:readonly string[];readonly descriptor:{readonly schemaVersion:1;readonly sdkVersion:string;readonly scope:WorkflowScope;readonly commandId:string;readonly pluginSha256:string;readonly toolSha256:string|null;readonly schemaSha256:string;readonly settingsSha256:string;readonly inputSha256:string;readonly environmentSha256:string;readonly files:readonly {readonly path:string;readonly byteLength:number;readonly sha256:string}[];readonly grants:readonly string[];readonly policy:WorkflowCachePolicy};} +export interface WorkflowCacheEntry {readonly schemaVersion:1;readonly key:string;readonly scope:WorkflowScope;readonly grants:readonly string[];readonly value:JsonValue;readonly valueSha256:string;readonly createdAt:number;readonly expiresAt:number;readonly accessedAt:number;readonly byteLength:number;} +export interface WorkflowCacheStore {get(key:string):Promise;set(key:string,value:WorkflowCacheEntry):Promise;list():Promise;remove(key:string):Promise;cleanup?():Promise;} +export interface WorkflowCacheResult {readonly state:'hit'|'miss'|'shared'|'bypass';readonly reason:string;readonly value:JsonValue;} +export interface WorkflowCache {inspect():Readonly<{hits:number;misses:number;bypasses:number;shared:number;evicted:number;corrupt:number;writes:number;writeFailures:number;pendingFills:number;unsettled:number;closed:boolean}>;run(input:WorkflowFingerprint,options:{readonly compute:(context:{readonly signal:AbortSignal})=>JsonValue|Promise;readonly signal?:AbortSignal}):Promise;cleanup():Promise<{readonly removed:readonly string[];readonly recovery:OwnedStoreCleanup}>;close():Promise;} +export function fingerprintWorkflowInputs(input:WorkflowFingerprintInput):WorkflowFingerprint; +export function parseWorkflowFingerprint(input:unknown):WorkflowFingerprint; +export function parseWorkflowCacheEntry(input:unknown):WorkflowCacheEntry; +export function createMemoryWorkflowCacheStore():WorkflowCacheStore; +export function createWorkflowCache(options:{readonly store?:WorkflowCacheStore;readonly authorize:(context:{readonly scope:WorkflowScope;readonly commandId:string;readonly grants:readonly string[];readonly key:string;readonly phase:'lookup'|'fill'|'after-fill'|'hit'|'return'})=>boolean|Promise;readonly now?:()=>number;readonly ttlMs?:number;readonly maxEntries?:number;readonly maxBytes?:number;readonly fillTimeoutMs?:number}):WorkflowCache; diff --git a/src/workflow-cache.mjs b/src/workflow-cache.mjs new file mode 100644 index 0000000..303f3dc --- /dev/null +++ b/src/workflow-cache.mjs @@ -0,0 +1,101 @@ +import {SDK_VERSION} from './version.mjs'; +import {parseWorkspacePath} from './contracts.mjs'; +import {canonical,clone,digest,fail,fields,sha,identifier,integer,scope,strings,immutable,jsonCopy,aborted,deadline,interruptible,authorized} from './workflow-internals.mjs'; + +export const WORKFLOW_CACHE_LIMITS=Object.freeze({entries:512,bytes:16_777_216,valueBytes:65_536,files:64,fileBytes:4_194_304,inputBytes:16_777_216,ttlMs:86_400_000,concurrentFills:8,fillTimeoutMs:60_000}); +const hasReference=v=>!!v&&typeof v==='object'&&(v.kind==='dds-secret-reference'||Object.values(v).some(hasReference)); +export function fingerprintWorkflowInputs(input) { + fields(input,['scope','commandId','pluginSha256','toolSha256','schema','settings','environment','input','files','grants','policy']); + scope(input.scope);identifier(input.commandId);sha(input.pluginSha256);if(input.toolSha256!==null)sha(input.toolSha256);strings(input.grants); + fields(input.policy,['optIn','deterministic','declaredInputsComplete','secretDependent']);if(Object.values(input.policy).some(v=>typeof v!=='boolean'))fail('INVALID','Cache policy must be explicit'); + fields(input.environment,['declared','values']);strings(input.environment.declared); + const values=jsonCopy(input.environment.values,16_384);if(!values||typeof values!=='object'||Array.isArray(values)||Object.values(values).some(v=>typeof v!=='string'))fail('INVALID','Environment must contain declared string values'); + if(!Array.isArray(input.files)||input.files.length>WORKFLOW_CACHE_LIMITS.files)fail('LIMIT','Fingerprint file limit'); + let total=0;const seen=new Set(); + const files=input.files.map(file=>{ + fields(file,['path','bytes']);parseWorkspacePath(file.path);if(seen.has(file.path.toLowerCase()))fail('INVALID','Duplicate fingerprint path');seen.add(file.path.toLowerCase()); + if(!(file.bytes instanceof Uint8Array)||file.bytes.byteLength>WORKFLOW_CACHE_LIMITS.fileBytes)fail('LIMIT','Fingerprint file budget'); + const bytes=Buffer.from(file.bytes);total+=bytes.length;if(total>WORKFLOW_CACHE_LIMITS.inputBytes)fail('LIMIT','Fingerprint input budget'); + return {path:file.path,byteLength:bytes.length,sha256:digest(bytes)}; + }).sort((a,b)=>a.pathb.path?1:0); + const payload=jsonCopy({schema:input.schema,settings:input.settings,input:input.input}),reasons=[]; + if(!input.policy.optIn)reasons.push('not-opted-in');if(!input.policy.deterministic)reasons.push('non-deterministic');if(!input.policy.declaredInputsComplete)reasons.push('unknown-inputs'); + if(input.policy.secretDependent||hasReference(payload))reasons.push('secret-dependent'); + if(Object.keys(values).some(k=>!input.environment.declared.includes(k)))reasons.push('undeclared-environment'); + if(input.environment.declared.some(k=>!Object.hasOwn(values,k)))reasons.push('missing-environment'); + const descriptor={schemaVersion:1,sdkVersion:SDK_VERSION,scope:clone(input.scope),commandId:input.commandId,pluginSha256:input.pluginSha256,toolSha256:input.toolSha256,schemaSha256:digest(canonical(payload.schema)),settingsSha256:digest(canonical(payload.settings)),inputSha256:digest(canonical(payload.input)),environmentSha256:digest(canonical({declared:[...input.environment.declared].sort(),values})),files,grants:[...input.grants].sort(),policy:clone(input.policy)}; + return immutable({schemaVersion:1,key:digest(canonical(descriptor)),eligible:reasons.length===0,reasons,descriptor}); +} +export function parseWorkflowFingerprint(input) { + const value=jsonCopy(input,65_536);fields(value,['schemaVersion','key','eligible','reasons','descriptor']);sha(value.key); + const d=value.descriptor;fields(d,['schemaVersion','sdkVersion','scope','commandId','pluginSha256','toolSha256','schemaSha256','settingsSha256','inputSha256','environmentSha256','files','grants','policy']);scope(d.scope);identifier(d.commandId);strings(d.grants);strings(value.reasons,8); + if(value.schemaVersion!==1||d.schemaVersion!==1||typeof value.eligible!=='boolean'||value.eligible!==(value.reasons.length===0)||value.key!==digest(canonical(d)))fail('INVALID','Fingerprint mismatch'); + for(const k of ['pluginSha256','schemaSha256','settingsSha256','inputSha256','environmentSha256'])sha(d[k]);if(d.toolSha256!==null)sha(d.toolSha256); + if(d.sdkVersion!==SDK_VERSION||!Array.isArray(d.files)||d.files.length>64)fail('INVALID','Unsupported fingerprint'); + fields(d.policy,['optIn','deterministic','declaredInputsComplete','secretDependent']);if(Object.values(d.policy).some(x=>typeof x!=='boolean'))fail('INVALID','Invalid cache policy'); + if(value.eligible&&(!d.policy.optIn||!d.policy.deterministic||!d.policy.declaredInputsComplete||d.policy.secretDependent))fail('INVALID','Ineligible cache policy'); + const seen=new Set();for(const f of d.files){fields(f,['path','byteLength','sha256']);parseWorkspacePath(f.path);sha(f.sha256);integer(f.byteLength,0,WORKFLOW_CACHE_LIMITS.fileBytes);if(seen.has(f.path.toLowerCase()))fail('INVALID','Duplicate fingerprint path');seen.add(f.path.toLowerCase());} + return value; +} +export function parseWorkflowCacheEntry(input) { + const value=jsonCopy(input,131_072);fields(value,['schemaVersion','key','scope','grants','value','valueSha256','createdAt','expiresAt','accessedAt','byteLength']); + if(value.schemaVersion!==1)fail('INVALID','Cache schema');sha(value.key);scope(value.scope);strings(value.grants);sha(value.valueSha256); + for(const k of ['createdAt','expiresAt','accessedAt'])integer(value[k],0,Number.MAX_SAFE_INTEGER); + if(value.expiresAt<=value.createdAt||value.accessedAt=512)fail('LIMIT','Cache entry limit');data.set(id,parseWorkflowCacheEntry(value));},async list(){return [...data.keys()];},async remove(id){return data.delete(sha(id));},async cleanup(){return {removed:[],preserved:[]};}}); +} +export function createWorkflowCache({store=createMemoryWorkflowCacheStore(),authorize,now=Date.now,ttlMs=60_000,maxEntries=128,maxBytes=4_194_304,fillTimeoutMs=60_000}={}) { + if(typeof authorize!=='function'||typeof now!=='function')fail('INVALID','Host authorization required');integer(ttlMs,1,WORKFLOW_CACHE_LIMITS.ttlMs);integer(maxEntries,1,512);integer(maxBytes,1,WORKFLOW_CACHE_LIMITS.bytes);integer(fillTimeoutMs,1,WORKFLOW_CACHE_LIMITS.fillTimeoutMs); + const flights=new Map(),unsettled=new Set();let tail=Promise.resolve(),closed=false;const metrics={hits:0,misses:0,bypasses:0,shared:0,evicted:0,corrupt:0,writes:0,writeFailures:0}; + const serial=action=>{const p=tail.then(action);tail=p.catch(()=>{});return p;}; + const check=async(fp,phase,signal)=>authorized(authorize,{scope:fp.descriptor.scope,commandId:fp.descriptor.commandId,grants:fp.descriptor.grants,key:fp.key,phase},signal); + async function inspectEntry(key){try{const raw=await store.get(key);if(!raw)return null;const value=parseWorkflowCacheEntry(raw);if(value.key!==key)fail('CORRUPT','Cache key mismatch');return value;}catch{metrics.corrupt++;return null;}} + async function retain(entry){return serial(async()=>{ + const records=[];for(const key of await store.list()){const r=await inspectEntry(key);if(!r||r.expiresAt<=now()){await store.remove(key);metrics.evicted++;}else if(key!==entry.key)records.push(r);} + records.sort((a,b)=>a.accessedAt-b.accessedAt||a.key.localeCompare(b.key,'en')); + let bytes=records.reduce((n,r)=>n+r.byteLength,0); + while(records.length>=maxEntries||bytes+entry.byteLength>maxBytes){const old=records.shift();if(!old)fail('LIMIT','Value exceeds cache budget');await store.remove(old.key);bytes-=old.byteLength;metrics.evicted++;} + await store.set(entry.key,entry);metrics.writes++; + });} + async function produce(fp,compute,signal,retainResult){ + await check(fp,'fill',signal);const raw=Promise.resolve().then(()=>compute({signal}));unsettled.add(raw);raw.then(()=>unsettled.delete(raw),()=>unsettled.delete(raw)); + const value=jsonCopy(await interruptible(raw,signal),WORKFLOW_CACHE_LIMITS.valueBytes);await check(fp,'after-fill',signal);aborted(signal); + if(retainResult&&!hasReference(value)){ + const at=now(),text=canonical(value),entry={schemaVersion:1,key:fp.key,scope:fp.descriptor.scope,grants:fp.descriptor.grants,value,valueSha256:digest(text),createdAt:at,expiresAt:at+ttlMs,accessedAt:at,byteLength:Buffer.byteLength(text)}; + try{await retain(parseWorkflowCacheEntry(entry));}catch{metrics.writeFailures++;return {value,reason:'store-write-failed'};} + } + return {value,reason:hasReference(value)?'secret-result':'computed'}; + } + return Object.freeze({ + inspect(){return {...metrics,pendingFills:flights.size,unsettled:unsettled.size,closed};}, + async run(input,{compute,signal}={}){ + if(closed)fail('DISPOSED','Cache closed');if(typeof compute!=='function')fail('INVALID','Compute port required');const fp=parseWorkflowFingerprint(input),guard=deadline(signal,fillTimeoutMs);let flight,joined=false; + try{ + await check(fp,'lookup',guard.signal); + if(!fp.eligible){if(unsettled.size>=8)fail('LIMIT','Compute concurrency');metrics.bypasses++;const result=await produce(fp,compute,guard.signal,false);return immutable({state:'bypass',reason:fp.reasons.join(','),value:result.value});} + const entry=await inspectEntry(fp.key); + if(entry&&entry.expiresAt>now()&&canonical(entry.scope)===canonical(fp.descriptor.scope)&&canonical(entry.grants)===canonical(fp.descriptor.grants)){ + await check(fp,'hit',guard.signal);aborted(guard.signal);metrics.hits++; + try{await serial(async()=>{const latest=await inspectEntry(fp.key);if(latest&&latest.valueSha256===entry.valueSha256)await store.set(fp.key,{...latest,accessedAt:now()});});}catch{metrics.writeFailures++;} + await check(fp,'return',guard.signal);if(entry.expiresAt<=now())fail('EXPIRED','Cache value expired during authorization');return immutable({state:'hit',reason:'verified',value:entry.value}); + } + flight=flights.get(fp.key);joined=!!flight; + if(!flight){ + if(flights.size>=8||unsettled.size>=8)fail('LIMIT','Cache fill concurrency');metrics.misses++; + const timer=deadline(undefined,fillTimeoutMs);flight={waiters:0,timer,promise:null};flights.set(fp.key,flight); + flight.promise=produce(fp,compute,timer.signal,true).finally(()=>{timer.close();if(flights.get(fp.key)===flight)flights.delete(fp.key);}); + flight.promise.catch(()=>{}); + }else metrics.shared++; + flight.waiters++;const result=await interruptible(flight.promise,guard.signal);await check(fp,'return',guard.signal); + return immutable({state:joined?'shared':'miss',reason:result.reason,value:result.value}); + }finally{guard.close();if(flight){flight.waiters--;if(flight.waiters===0&&flights.get(fp.key)===flight)flight.timer.controller.abort();}} + }, + async cleanup(){return serial(async()=>{const removed=[];for(const key of await store.list()){const value=await inspectEntry(key);if(!value||value.expiresAt<=now()){if(await store.remove(key))removed.push(key);}} + const recovery=await store.cleanup?.()??{removed:[],preserved:[]};return immutable({removed,recovery});});}, + async close(){closed=true;for(const f of flights.values())f.timer.controller.abort();await Promise.allSettled([...flights.values()].map(f=>f.promise));await tail;}, + }); +} diff --git a/src/workflow-internals.mjs b/src/workflow-internals.mjs new file mode 100644 index 0000000..18a5050 --- /dev/null +++ b/src/workflow-internals.mjs @@ -0,0 +1,29 @@ +import {canonical, clone, digest, fail, fields, sha, string} from './release-internals.mjs'; +export {canonical, clone, digest, fail, fields, sha, string}; +export function integer(value, min, max) { if (!Number.isSafeInteger(value) || value < min || value > max) fail('INVALID', 'Integer outside supported range'); return value; } +export function identifier(value) { string(value, 'identifier', 128); if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) fail('INVALID', 'Invalid identifier'); return value; } +export function scope(value) { fields(value, ['workspaceId', 'securityScope']); identifier(value.workspaceId); identifier(value.securityScope); return value; } +export function strings(value, max = 64) { if (!Array.isArray(value) || value.length > max || new Set(value).size !== value.length) fail('INVALID', 'Expected unique bounded strings'); value.forEach(identifier); return value; } +export function immutable(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(immutable); Object.freeze(value); } return value; } +export function jsonCopy(value, bytes = 65_536) { return immutable(JSON.parse(canonical(value, bytes))); } +export function aborted(signal) { if (signal?.aborted) fail(signal.reason?.code === 'TIMEOUT' ? 'TIMEOUT' : 'CANCELLED', 'Operation stopped'); } +export function deadline(parent, timeoutMs) { + const controller = new AbortController(); + const onAbort = () => controller.abort(parent.reason); + if (parent?.aborted) onAbort(); else parent?.addEventListener('abort', onAbort, {once:true}); + const timer = setTimeout(() => controller.abort(Object.assign(new Error('Deadline exceeded'), {code:'TIMEOUT'})), timeoutMs); + return {controller, signal:controller.signal, close() {clearTimeout(timer); parent?.removeEventListener('abort', onAbort);}}; +} +export async function interruptible(promise, signal) { + aborted(signal); let cancel; + try { return await Promise.race([promise, new Promise((_, reject) => {cancel = () => reject(Object.assign(new Error('Operation stopped'), {code:signal.reason?.code === 'TIMEOUT' ? 'TIMEOUT' : 'CANCELLED'})); signal.addEventListener('abort', cancel, {once:true}); if (signal.aborted) cancel();})]); } + finally {signal.removeEventListener('abort', cancel);} +} +export async function authorized(authorize, context, signal) { aborted(signal); if (await interruptible(Promise.resolve().then(() => authorize(immutable(clone(context)))), signal) !== true) fail('DENIED', 'Current operator authorization required'); aborted(signal); } +export function errorCode(error) { return typeof error?.code === 'string' && /^[A-Z_]{1,40}$/.test(error.code) ? error.code : 'FAILED'; } +export const pause = (milliseconds, signal) => new Promise((resolve, reject) => { + const done = () => {clearTimeout(timer); signal?.removeEventListener('abort', cancel);}; + const cancel = () => {done(); reject(Object.assign(new Error('Stopped'), {code:'CANCELLED'}));}; + const timer = setTimeout(() => {done(); resolve();}, milliseconds); + signal?.addEventListener('abort', cancel, {once:true}); if (signal?.aborted) cancel(); +}); diff --git a/src/workflow-schemas.mjs b/src/workflow-schemas.mjs new file mode 100644 index 0000000..aa46f3a --- /dev/null +++ b/src/workflow-schemas.mjs @@ -0,0 +1,49 @@ +import {SDK_VERSION} from './version.mjs'; +import {DATA_SCHEMA_CONTRACT,DATA_SCHEMA_DEFINITIONS} from './data-schema-schemas.mjs'; + +const base=`https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; +const obj=(properties,required=Object.keys(properties))=>({type:'object',properties,required,additionalProperties:false}); +const text=(maxLength=128,minLength=1)=>({type:'string',minLength,maxLength}); +const integer=(maximum=Number.MAX_SAFE_INTEGER,minimum=0)=>({type:'integer',minimum,maximum}); +const list=(items,maxItems,minItems=0)=>({type:'array',items,maxItems,minItems}); +const nullable=value=>({oneOf:[{type:'null'},value]}); +const id={...text(),pattern:'^[a-zA-Z0-9][a-zA-Z0-9._-]*$'},hash={type:'string',pattern:'^[a-f0-9]{64}$'},bool={type:'boolean'},v1={const:1}; +const ids={...list(id,64),uniqueItems:true},scope=obj({workspaceId:id,securityScope:id}); +const json={$ref:base+'json-value.schema.json'}; +const filePath={...text(1024),$comment:'Runtime requires a relative workspace file path without traversal or links.'}; +const data=DATA_SCHEMA_CONTRACT; +const command=obj({id,pluginId:id,pluginSha256:hash,inputSchema:data,outputSchema:data,grants:ids}); +const binding={oneOf:[obj({value:json}),obj({step:id,path:list(id,8)})]}; +const step=obj({id,commandId:id,needs:ids,input:{type:'object',propertyNames:id,additionalProperties:binding,maxProperties:128},grants:ids}); +const definition=obj({schemaVersion:v1,id,scope,steps:list(step,64,1),policy:{enum:['fail-fast','continue']},concurrency:integer(8,1),timeoutMs:integer(600000,1),stepTimeoutMs:integer(600000,1),retentionMs:integer(86400000,1)}); +const cache=obj({state:{enum:['hit','miss','shared','bypass']},key:hash,reason:text(256)}); +const states={enum:['pending','running','succeeded','failed','cancelled','timed_out','blocked','interrupted']}; +const artifact=obj({id,path:filePath,revision:hash,byteLength:integer(262144),label:text(256)},['id','path','revision','byteLength']); +const recordStep=obj({id,commandId:id,state:states,jobId:nullable(id),sourceAttemptId:nullable(id),inputSha256:nullable(hash),output:json,outputSha256:nullable(hash),artifacts:list(artifact,32),cache:nullable(cache),reason:nullable(id),startedAt:nullable(integer()),finishedAt:nullable(integer())}); +const policy=obj({optIn:bool,deterministic:bool,declaredInputsComplete:bool,secretDependent:bool}); +const descriptor=obj({schemaVersion:v1,sdkVersion:{const:SDK_VERSION},scope,commandId:id,pluginSha256:hash,toolSha256:nullable(hash),schemaSha256:hash,settingsSha256:hash,inputSha256:hash,environmentSha256:hash,files:list(obj({path:filePath,byteLength:integer(4194304),sha256:hash}),64),grants:ids,policy}); +const position=obj({line:integer(10000000),character:integer(10000000)}),range=obj({start:position,end:position}); +const eventData={oneOf:[ + obj({kind:{const:'log'},level:{enum:['debug','info','warning','error']},message:text(16384,0)}), + obj({kind:{const:'progress'},completed:integer(),total:integer(Number.MAX_SAFE_INTEGER,1),message:text(2048,0)},['kind','completed','total']), + obj({kind:{const:'diagnostic'},path:filePath,range,severity:{enum:['error','warning','info','hint']},message:text(16384,0),code:text(128,0),source:text(128,0)},['kind','path','range','severity','message']), + obj({kind:{const:'unknown'},reason:id,message:text(1024,0)}), +]}; +const processDefinition=obj({id,version:text(96),approved:{const:true},executable:text(4096),executableSha256:hash,files:list(obj({path:text(4096),sha256:hash}),64),args:list({oneOf:[text(4096),obj({input:id})]},64),versionArgs:list(text(4096),64),expectedVersionOutput:text(256),environment:{type:'object',maxProperties:32,propertyNames:{type:'string',pattern:'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$'},additionalProperties:text(4096,0)}}); +const stats=obj({bytes:integer(2097152),events:integer(4096),unknown:integer(4096),peakQueued:integer(8),queued:integer(8),closed:bool,failed:bool},['bytes','events','unknown','peakQueued']); +const features=['completion','hover','definition','references','document-symbols','signature-help','prepare-rename','rename','format-document','format-range','code-actions','semantic-tokens','folding-ranges','inlay-hints','document-symbol-tree']; +const unsupported=['completion-resolve','completion-snippets','code-action-resolve','semantic-tokens-delta','server-commands','dynamic-capabilities','resource-operations','sockets','unversioned-diagnostics']; +const defs=body=>({...body,$defs:DATA_SCHEMA_DEFINITIONS}); +export const WORKFLOW_SCHEMAS=Object.freeze({ + 'workflow-definition':{...definition,'x-maxUtf8Bytes':1048576,$comment:'Runtime checks acyclic unique steps, existing dependencies, input bindings and stepTimeoutMs <= timeoutMs.'}, + 'workflow-plan':defs({...obj({schemaVersion:v1,definition,commands:list(command,64),sha256:hash}),'x-maxUtf8Bytes':1048576,$comment:'Runtime prepares the exact immutable plan, verifies closed input/output schemas and required output paths, rejects secret-reference schemas and hashes the canonical body. Digest approval never substitutes for host authorization.'}), + 'workflow-record':{...obj({schemaVersion:v1,workflowId:id,attemptId:id,parentAttemptId:nullable(id),parentJobId:nullable(id),planSha256:hash,scope,revision:integer(Number.MAX_SAFE_INTEGER,1),state:states,startedAt:integer(),updatedAt:integer(),expiresAt:integer(),steps:list(recordStep,64,1)}),'x-maxUtf8Bytes':2097152,$comment:'Runtime checks output hashes, success-only outputs/artifacts and time ordering. Host restart produces an interrupted view; only an explicitly approved new attempt executes.'}, + 'workflow-fingerprint':{...obj({schemaVersion:v1,key:hash,eligible:bool,reasons:{...list(id,8),uniqueItems:true},descriptor}),'x-maxUtf8Bytes':65536,$comment:'Fingerprint generation hashes actual supplied file bytes, all declared inputs and SDK/plugin/tool/schema/settings/environment/scope/grants. Runtime verifies the descriptor digest; completeness and determinism are operator attestations.'}, + 'workflow-cache-entry':{...obj({schemaVersion:v1,key:hash,scope,grants:ids,value:json,valueSha256:hash,createdAt:integer(),expiresAt:integer(),accessedAt:integer(),byteLength:integer(65536)}),'x-maxUtf8Bytes':131072,$comment:'Runtime verifies canonical value digest/UTF-8 length, TTL ordering and rejects secret-reference values. Every cache use rechecks current host authorization.'}, + 'tool-stream-event':{...obj({schemaVersion:v1,sequence:integer(4096,1),jobId:id,commandId:id,toolId:id,toolVersion:text(96,0),stream:{enum:['stdout','stderr']},data:eventData}),$comment:'Runtime checks ordered ranges, progress <= total and bounded well-formed text; the parser bounds UTF-8 bytes, events, queued writes and callback time.'}, + 'trusted-process-definition':{...processDefinition,$comment:'Operator-only configuration. Runtime requires absolute local executable/artifact paths, fixed version arguments, approved true, digest verification and exact bounded version-probe output. The process is trusted code, not sandboxed.'}, + 'tool-registration':defs({...obj({process:processDefinition,backendId:id,inputSchema:data,pathInputs:ids,format:{enum:['jsonl','text']},parser:{enum:['plain','typescript']}}),$comment:'Runtime checks required scalar argument slots and declared path inputs. Host authorization is checked before start, on events and after completion.'}), + 'tool-run-receipt':obj({schemaVersion:v1,toolId:id,toolVersion:text(96),jobId:id,commandId:id,state:{enum:['succeeded','failed','cancelled','timed_out']},reason:nullable(text(128)),exitCode:nullable({type:'integer'}),terminationSignal:nullable(text(128)),stream:nullable(stats)}), + 'lsp-capability-matrix':{...obj({schemaVersion:v1,protocol:{const:'3.17'},transport:{const:'stdio'},positionEncoding:{const:'utf-16'},features:list(obj({sdkFeature:{enum:features},method:text(128),supported:bool,reason:{enum:['advertised','not-advertised']}}),15,15),unsupported:{const:unsupported}}),$comment:'Runtime verifies the exact ordered feature/method pairs and reason consistent with support. Matrix support reports negotiation, not successful execution of every server feature.'}, + 'lsp-close-receipt':obj({schemaVersion:v1,graceful:bool,reason:nullable(text(128)),ownedProcessClosed:bool,pendingRequests:integer(16)}), +}); diff --git a/src/workflows-node.d.mts b/src/workflows-node.d.mts new file mode 100644 index 0000000..87685e0 --- /dev/null +++ b/src/workflows-node.d.mts @@ -0,0 +1,6 @@ +import type {WorkflowScope,WorkflowStore} from './workflows.mjs'; +export interface OwnedWorkflowStoreOptions {readonly directory:string;readonly workspaceRoot:string;readonly scope:WorkflowScope;readonly recoverStaleLock?:boolean;} +export interface OwnedWorkflowStoreInspection {readonly entries:number;readonly bytes:number;readonly problems:readonly string[];readonly orphans:number;readonly unknown:number;readonly queued:number;readonly closed:boolean;readonly directorySynced:boolean;} +export interface OwnedStoreCleanup {readonly removed:readonly string[];readonly preserved:readonly string[];} +export interface NodeWorkflowStore extends WorkflowStore {cleanup(options?:{readonly expiredBefore?:number}):Promise;inspect():OwnedWorkflowStoreInspection;close():Promise;} +export function createNodeWorkflowStore(options:OwnedWorkflowStoreOptions):Promise; diff --git a/src/workflows-node.mjs b/src/workflows-node.mjs new file mode 100644 index 0000000..4946293 --- /dev/null +++ b/src/workflows-node.mjs @@ -0,0 +1,30 @@ +import {openOwnedJsonStore} from './owned-json-store-node.mjs'; +import {parseWorkflowRecord,WORKFLOW_LIMITS} from './workflows.mjs'; +import {canonical,fail,integer,jsonCopy} from './workflow-internals.mjs'; + +export async function createNodeWorkflowStore(options) { + const binding=jsonCopy(options.scope); + const storage=await openOwnedJsonStore({...options,scope:binding,kind:'workflows-v1',maxEntries:WORKFLOW_LIMITS.records,maxBytes:33_554_432,recordBytes:WORKFLOW_LIMITS.recordBytes}); + let tail=Promise.resolve(); + return Object.freeze({ + async read(id){const value=await storage.get(id);return value?parseWorkflowRecord(value):null;}, + async list(){const result=[];for(const id of await storage.list()){const value=await storage.get(id);if(value)result.push(parseWorkflowRecord(value));}return Object.freeze(result);}, + async write(input,expectedRevision){ + const r=parseWorkflowRecord(input);integer(expectedRevision,0,Number.MAX_SAFE_INTEGER); + if(canonical(r.scope)!==canonical(binding))fail('DENIED','Workflow scope differs from store'); + const pending=tail.then(async()=>{const old=await storage.get(r.attemptId);if((old?.revision??0)!==expectedRevision||r.revision!==expectedRevision+1)fail('CONFLICT','Workflow checkpoint changed');await storage.set(r.attemptId,r);}); + tail=pending.catch(()=>{});return pending; + }, + async cleanup({expiredBefore=0}={}){ + integer(expiredBefore,0,Number.MAX_SAFE_INTEGER); + const pending=tail.then(async()=>{ + const removed=[]; + for(const id of await storage.list()){ + const value=await storage.get(id);if(!value)continue;const record=parseWorkflowRecord(value); + if(!['pending','running'].includes(record.state)&&record.expiresAt<=expiredBefore&&await storage.remove(id))removed.push(id+'.json'); + } + const recovery=await storage.cleanup();return {removed:[...removed,...recovery.removed],preserved:recovery.preserved}; + });tail=pending.catch(()=>{});return pending; + },inspect:()=>storage.inspect(),async close(){await tail;await storage.close();}, + }); +} diff --git a/src/workflows.d.mts b/src/workflows.d.mts new file mode 100644 index 0000000..57b7b78 --- /dev/null +++ b/src/workflows.d.mts @@ -0,0 +1,26 @@ +import type {JsonValue, PluginHost, Scope} from './index.mjs'; +import type {DataSchema} from './data-schema.mjs'; +import type {JobArtifact} from './jobs.mjs'; +export const WORKFLOW_LIMITS: Readonly<{steps:number;concurrency:number;planBytes:number;recordBytes:number;valueBytes:number;outputBytes:number;timeoutMs:number;retentionMs:number;records:number}>; +export interface WorkflowScope {readonly workspaceId:string;readonly securityScope:string;} +export interface WorkflowCommand {readonly id:string;readonly pluginId:string;readonly pluginSha256:string;readonly inputSchema:DataSchema;readonly outputSchema:DataSchema;readonly grants:readonly string[];} +export type WorkflowBinding = {readonly value:JsonValue;readonly step?:never;readonly path?:never} | {readonly step:string;readonly path:readonly string[];readonly value?:never}; +export interface WorkflowStep {readonly id:string;readonly commandId:string;readonly needs:readonly string[];readonly input:Readonly>;readonly grants:readonly string[];} +export interface WorkflowDefinition {readonly schemaVersion:1;readonly id:string;readonly scope:WorkflowScope;readonly steps:readonly WorkflowStep[];readonly policy:'fail-fast'|'continue';readonly concurrency:number;readonly timeoutMs:number;readonly stepTimeoutMs:number;readonly retentionMs:number;} +export interface WorkflowPlan {readonly schemaVersion:1;readonly definition:WorkflowDefinition;readonly commands:readonly WorkflowCommand[];readonly sha256:string;} +export type WorkflowState = 'pending'|'running'|'succeeded'|'failed'|'cancelled'|'timed_out'|'blocked'|'interrupted'; +export interface WorkflowCacheEvidence {readonly state:'hit'|'miss'|'shared'|'bypass';readonly key:string;readonly reason:string;} +export interface WorkflowStepRecord {readonly id:string;readonly commandId:string;readonly state:WorkflowState;readonly jobId:string|null;readonly sourceAttemptId:string|null;readonly inputSha256:string|null;readonly output:JsonValue;readonly outputSha256:string|null;readonly artifacts:readonly JobArtifact[];readonly cache:WorkflowCacheEvidence|null;readonly reason:string|null;readonly startedAt:number|null;readonly finishedAt:number|null;} +export interface WorkflowRecord {readonly schemaVersion:1;readonly workflowId:string;readonly attemptId:string;readonly parentAttemptId:string|null;readonly parentJobId:string|null;readonly planSha256:string;readonly scope:WorkflowScope;readonly revision:number;readonly state:WorkflowState;readonly startedAt:number;readonly updatedAt:number;readonly expiresAt:number;readonly steps:readonly WorkflowStepRecord[];} +export interface WorkflowStore {read(id:string):Promise;write(record:WorkflowRecord,expectedRevision:number):Promise;list():Promise;} +export interface WorkflowApproval {readonly approved:true;readonly planSha256:string;readonly previousAttemptId?:string;readonly retrySteps?:readonly string[];} +export interface WorkflowAuthorization {readonly scope:WorkflowScope;readonly command:WorkflowCommand;readonly grants:readonly string[];readonly stepId:string;readonly phase:'before'|'after'|'reuse';} +export type WorkflowExecutor = (step:WorkflowStep,command:WorkflowCommand,input:JsonValue,context:{readonly signal:AbortSignal;readonly jobId:string;readonly attemptId:string;readonly scope:WorkflowScope}) => {readonly output:JsonValue;readonly artifacts?:readonly JobArtifact[];readonly cache?:WorkflowCacheEvidence} | Promise<{readonly output:JsonValue;readonly artifacts?:readonly JobArtifact[];readonly cache?:WorkflowCacheEvidence}>; +export interface WorkflowRunner {inspect():Readonly<{running:boolean;unsettled:number}>;recover(attemptId:string):Promise;run(plan:WorkflowPlan,options:{readonly approval:WorkflowApproval;readonly previous?:WorkflowRecord|null;readonly parentJobId?:string|null;readonly signal?:AbortSignal}):Promise;} +export function parseWorkflowDefinition(input:unknown):WorkflowDefinition; +export function prepareWorkflowPlan(input:WorkflowDefinition,commands:readonly WorkflowCommand[]):WorkflowPlan; +export function parseWorkflowRecord(input:unknown):WorkflowRecord; +export function recoverWorkflowRecord(input:WorkflowRecord):WorkflowRecord; +export function createMemoryWorkflowStore(options?:{readonly maxRecords?:number}):WorkflowStore; +export function createWorkflowRunner(options:{readonly commands:readonly WorkflowCommand[];readonly execute:WorkflowExecutor;readonly authorize:(context:WorkflowAuthorization)=>boolean|Promise;readonly store?:WorkflowStore;readonly now?:()=>number}):WorkflowRunner; +export function createCommandJobExecutor(options:{readonly resolveHost:(context:{readonly step:WorkflowStep;readonly command:WorkflowCommand;readonly grants:readonly string[];readonly scope:WorkflowScope})=>{readonly host:PluginHost;readonly scope:Scope;readonly grants:readonly string[];readonly pluginSha256:string}|Promise<{readonly host:PluginHost;readonly scope:Scope;readonly grants:readonly string[];readonly pluginSha256:string}>;readonly pollMs?:number}):WorkflowExecutor; diff --git a/src/workflows.mjs b/src/workflows.mjs new file mode 100644 index 0000000..2c7b10e --- /dev/null +++ b/src/workflows.mjs @@ -0,0 +1,201 @@ +import {randomUUID} from 'node:crypto'; +import {parseDataSchema, validateDataValue} from './data-schema.mjs'; +import {parseJobSnapshot, parseJobArtifact} from './jobs.mjs'; +import {canonical, clone, digest, fail, fields, sha, string, identifier, integer, scope, strings, immutable, jsonCopy, aborted, deadline, interruptible, authorized, errorCode, pause} from './workflow-internals.mjs'; + +export const WORKFLOW_LIMITS = Object.freeze({steps:64, concurrency:8, planBytes:1_048_576, recordBytes:2_097_152, valueBytes:65_536, outputBytes:1_048_576, timeoutMs:600_000, retentionMs:86_400_000, records:64}); +const secret = node => node.format === 'dds-secret-reference' || Object.values(node.properties ?? {}).some(secret) || !!node.items && secret(node.items); +function schema(value) {const result = parseDataSchema(value); if (secret(result.schema)) fail('UNSUPPORTED', 'Workflow persistence does not accept secret-reference fields'); return result;} +function command(value) { + fields(value, ['id','pluginId','pluginSha256','inputSchema','outputSchema','grants']); identifier(value.id); identifier(value.pluginId); sha(value.pluginSha256); strings(value.grants); + schema(value.inputSchema); schema(value.outputSchema); + if (value.inputSchema.schema.type !== 'object' || value.inputSchema.schema.format) fail('INVALID', 'Workflow command inputs must be closed objects'); + return value; +} +export function parseWorkflowDefinition(input) { + const value = jsonCopy(input, WORKFLOW_LIMITS.planBytes); + fields(value, ['schemaVersion','id','scope','steps','policy','concurrency','timeoutMs','stepTimeoutMs','retentionMs']); + if (value.schemaVersion !== 1 || !['fail-fast','continue'].includes(value.policy)) fail('INVALID', 'Unsupported workflow contract'); + identifier(value.id); scope(value.scope); integer(value.concurrency,1,WORKFLOW_LIMITS.concurrency); integer(value.timeoutMs,1,WORKFLOW_LIMITS.timeoutMs); integer(value.stepTimeoutMs,1,value.timeoutMs); integer(value.retentionMs,1,WORKFLOW_LIMITS.retentionMs); + if (!Array.isArray(value.steps) || !value.steps.length || value.steps.length > WORKFLOW_LIMITS.steps) fail('LIMIT', 'Workflow step limit'); + const ids = new Set(); + for (const step of value.steps) { + fields(step, ['id','commandId','needs','input','grants']); identifier(step.id); identifier(step.commandId); strings(step.needs); strings(step.grants); + if (ids.has(step.id)) fail('INVALID', 'Duplicate workflow step'); ids.add(step.id); + if (!step.input || typeof step.input !== 'object' || Array.isArray(step.input)) fail('INVALID', 'Invalid bindings'); + for (const [name,binding] of Object.entries(step.input)) { + identifier(name); + if (Object.hasOwn(binding ?? {},'value')) {fields(binding,['value']); jsonCopy(binding.value);} + else {fields(binding,['step','path']); identifier(binding.step); if (!Array.isArray(binding.path) || binding.path.length > 8) fail('INVALID','Invalid output path'); binding.path.forEach(identifier);} + } + } + const visiting = new Set(), visited = new Set(), byId = new Map(value.steps.map(s => [s.id,s])); + function visit(id) {if (visiting.has(id)) fail('INVALID','Workflow cycle'); if (visited.has(id)) return; const step = byId.get(id); if (!step) fail('INVALID','Missing dependency'); visiting.add(id); step.needs.forEach(visit); visiting.delete(id); visited.add(id);} + value.steps.forEach(s => visit(s.id)); return value; +} +export function prepareWorkflowPlan(input, definitions) { + const definition = parseWorkflowDefinition(input), commands = jsonCopy(definitions, WORKFLOW_LIMITS.planBytes); + if (!Array.isArray(commands) || commands.length > WORKFLOW_LIMITS.steps) fail('LIMIT','Command limit'); commands.forEach(command); + const byCommand = new Map(commands.map(c => [c.id,c])); if (byCommand.size !== commands.length) fail('INVALID','Duplicate command'); + const byStep = new Map(definition.steps.map(s => [s.id,s])); + const used = [...new Set(definition.steps.map(s => s.commandId))].sort().map(id => {const c = byCommand.get(id); if (!c) fail('UNSUPPORTED','Command not registered'); return c;}); + for (const step of definition.steps) { + const cmd = byCommand.get(step.commandId), shape = cmd.inputSchema.schema; + if (cmd.grants.some(g => !step.grants.includes(g))) fail('DENIED','Step did not explicitly grant command requirements'); + for (const required of shape.required ?? []) if (!Object.hasOwn(step.input, required)) fail('INVALID','Missing input binding'); + for (const [name,binding] of Object.entries(step.input)) { + const target = shape.properties[name]; if (!target) fail('INVALID','Unknown command input'); + if (Object.hasOwn(binding,'value')) validateDataValue({schemaVersion:1,schema:target},binding.value); + else { + if (!step.needs.includes(binding.step)) fail('INVALID','Output binding must name an explicit dependency'); + let source = byCommand.get(byStep.get(binding.step)?.commandId)?.outputSchema.schema; + for (const component of binding.path) {if (!source || source.type !== 'object' || !source.required?.includes(component)) fail('INVALID','Output path must be required'); source = source.properties[component];} + // Conservative assignability: constraints, including nested required fields, must match exactly. + if (!source || canonical(source) !== canonical(target)) fail('INVALID','Input and output schemas differ'); + } + } + } + const body = {schemaVersion:1,definition,commands:used}; return immutable({...body,sha256:digest(canonical(body,WORKFLOW_LIMITS.planBytes))}); +} +const states = ['pending','running','succeeded','failed','cancelled','timed_out','blocked','interrupted']; +function cacheEvidence(value){fields(value,['state','key','reason']);if(!['hit','miss','shared','bypass'].includes(value.state))fail('INVALID','Invalid cache evidence');sha(value.key);string(value.reason,'cache reason',256);return value;} +export function parseWorkflowRecord(input) { + const value = jsonCopy(input, WORKFLOW_LIMITS.recordBytes); + fields(value,['schemaVersion','workflowId','attemptId','parentAttemptId','parentJobId','planSha256','scope','revision','state','startedAt','updatedAt','expiresAt','steps']); + if (value.schemaVersion !== 1 || !states.includes(value.state)) fail('INVALID','Invalid workflow state'); + identifier(value.workflowId); identifier(value.attemptId); if (value.parentAttemptId !== null) identifier(value.parentAttemptId); if (value.parentJobId !== null) identifier(value.parentJobId); sha(value.planSha256); scope(value.scope); + integer(value.revision,1,Number.MAX_SAFE_INTEGER); for (const name of ['startedAt','updatedAt','expiresAt']) integer(value[name],0,Number.MAX_SAFE_INTEGER); + if (value.updatedAt < value.startedAt || value.expiresAt < value.startedAt || !Array.isArray(value.steps) || value.steps.length < 1 || value.steps.length > 64) fail('INVALID','Invalid workflow record'); + const ids = new Set(); + for (const s of value.steps) { + fields(s,['id','commandId','state','jobId','sourceAttemptId','inputSha256','output','outputSha256','artifacts','cache','reason','startedAt','finishedAt']); + identifier(s.id); identifier(s.commandId); if (ids.has(s.id)) fail('INVALID','Duplicate record step'); ids.add(s.id); + if (!states.includes(s.state)) fail('INVALID','Invalid step state'); + for (const k of ['jobId','sourceAttemptId']) if (s[k] !== null) identifier(s[k]); + for (const k of ['inputSha256','outputSha256']) if (s[k] !== null) sha(s[k]); + for (const k of ['startedAt','finishedAt']) if (s[k] !== null) integer(s[k],0,Number.MAX_SAFE_INTEGER); + if (s.reason !== null) identifier(s.reason); + if (s.cache !== null) cacheEvidence(s.cache); + if (!Array.isArray(s.artifacts) || s.artifacts.length > 32) fail('LIMIT','Artifact limit'); s.artifacts.forEach(parseJobArtifact); + if (s.state === 'succeeded') {jsonCopy(s.output); if (s.outputSha256 !== digest(canonical(s.output))) fail('INVALID','Output digest mismatch');} + else if (s.output !== null || s.outputSha256 !== null || s.artifacts.length) fail('INVALID','Non-successful result'); + } + return value; +} +export function recoverWorkflowRecord(input) { + const value = clone(parseWorkflowRecord(input)); + if (value.state === 'running' || value.state === 'pending') {value.state = 'interrupted'; for (const s of value.steps) if (s.state === 'running' || s.state === 'pending') {s.state='interrupted'; s.reason='HOST_RESTART';}} + return parseWorkflowRecord(value); +} +export function createMemoryWorkflowStore({maxRecords=64} = {}) { + integer(maxRecords,1,64); const records = new Map(); + return Object.freeze({async read(id) {identifier(id); return records.get(id) ?? null;}, async write(input,expectedRevision) {const r=parseWorkflowRecord(input), old=records.get(r.attemptId); if ((old?.revision ?? 0)!==expectedRevision || r.revision!==expectedRevision+1) fail('CONFLICT','Workflow checkpoint changed'); if (!old && records.size>=maxRecords) fail('LIMIT','Workflow store full'); records.set(r.attemptId,r);}, async list() {return Object.freeze([...records.values()]);}}); +} +export function createWorkflowRunner({commands,execute,authorize,store=createMemoryWorkflowStore(),now=Date.now} = {}) { + if (typeof execute !== 'function' || typeof authorize !== 'function' || typeof now !== 'function') fail('INVALID','Host executor and authorization required'); + commands = jsonCopy(commands, WORKFLOW_LIMITS.planBytes); commands.forEach(command); + let busy = false; const unsettled = new Set(); + const api = { + inspect() {return Object.freeze({running:busy,unsettled:unsettled.size});}, + async recover(attemptId) {const value=await store.read(identifier(attemptId)); return value ? recoverWorkflowRecord(value) : null;}, + async run(plan, {approval,previous=null,parentJobId=null,signal} = {}) { + if (busy || unsettled.size) fail('CONFLICT','Previous workflow is still active'); + fields(plan,['schemaVersion','definition','commands','sha256']); + const current=prepareWorkflowPlan(plan.definition,commands); if (canonical(current)!==canonical(plan)) fail('CONFLICT','Plan or command schema changed'); + fields(approval,['approved','planSha256'],['previousAttemptId','retrySteps']); + if (approval.approved!==true || approval.planSha256!==current.sha256) fail('DENIED','Exact reviewed workflow approval required'); + if (parentJobId!==null) identifier(parentJobId); + if (previous!==null) { + previous=recoverWorkflowRecord(previous); + const stored=await store.read(previous.attemptId); + if (!stored || canonical(recoverWorkflowRecord(stored))!==canonical(previous)) fail('CONFLICT','Resume requires the stored checkpoint'); + if (previous.planSha256!==current.sha256 || approval.previousAttemptId!==previous.attemptId || canonical(previous.scope)!==canonical(current.definition.scope) || previous.workflowId!==current.definition.id) fail('CONFLICT','Resume scope, input or schema changed'); + } else if (approval.previousAttemptId!==undefined || approval.retrySteps!==undefined) fail('INVALID','No previous attempt to resume'); + const definition=current.definition, byCommand=new Map(current.commands.map(c=>[c.id,c])); + const retry=new Set(previous ? strings(approval.retrySteps ?? previous.steps.filter(s=>s.state!=='succeeded').map(s=>s.id)) : []); + if ([...retry].some(id=>!definition.steps.some(s=>s.id===id))) fail('INVALID','Unknown retry step'); + for (const s of definition.steps) if (s.needs.some(id=>retry.has(id))) retry.add(s.id); + // Propagate independent of declaration order. + for (let i=0;iretry.has(id))) retry.add(s.id); + const startedAt=integer(now(),0,Number.MAX_SAFE_INTEGER), oldById=new Map((previous?.steps ?? []).map(s=>[s.id,s])); + const record={schemaVersion:1,workflowId:definition.id,attemptId:randomUUID(),parentAttemptId:previous?.attemptId ?? null,parentJobId,planSha256:current.sha256,scope:clone(definition.scope),revision:0,state:'running',startedAt,updatedAt:startedAt,expiresAt:startedAt+definition.retentionMs,steps:definition.steps.map(s=>({id:s.id,commandId:s.commandId,state:'pending',jobId:null,sourceAttemptId:null,inputSha256:null,output:null,outputSha256:null,artifacts:[],cache:null,reason:null,startedAt:null,finishedAt:null}))}; + const byStep=new Map(record.steps.map(s=>[s.id,s])), clock=deadline(signal,definition.timeoutMs); let journalError=null, tail=Promise.resolve(); + if(busy||unsettled.size){clock.close();fail('CONFLICT','Previous workflow is still active');}busy=true; + const checkpoint=()=>{ + const save=async()=>{record.revision++; record.updatedAt=now(); const copy=parseWorkflowRecord(record); await store.write(copy,copy.revision-1);}; + const pending=tail.then(save); tail=pending.catch(error=>{journalError=error;clock.controller.abort(error);}); return pending; + }; + const access=(step,phase)=>authorized(authorize,{scope:definition.scope,command:byCommand.get(step.commandId),grants:step.grants,stepId:step.id,phase},clock.signal); + const inputs=step=>validateDataValue(byCommand.get(step.commandId).inputSchema,Object.fromEntries(Object.entries(step.input).map(([k,b])=>[k,Object.hasOwn(b,'value') ? b.value : b.path.reduce((v,p)=>v[p],byStep.get(b.step).output)]))); + let usedBytes=0, failed=false; + const executeStep=async step=>{ + const target=byStep.get(step.id), cmd=byCommand.get(step.commandId); let timer; + try { + await access(step,'before'); const input=inputs(step); target.inputSha256=digest(canonical(input)); + const prior=oldById.get(step.id); + if (prior?.state==='succeeded' && !retry.has(step.id)) { + if (previous.expiresAt<=now() || prior.inputSha256!==target.inputSha256) fail('EXPIRED','Partial result needs an explicit retry selection'); + const output=validateDataValue(cmd.outputSchema,prior.output); await access(step,'reuse'); + usedBytes+=Buffer.byteLength(canonical(output)); if(usedBytes>WORKFLOW_LIMITS.outputBytes) fail('LIMIT','Workflow output budget'); + Object.assign(target,{...clone(prior),sourceAttemptId:prior.sourceAttemptId ?? previous.attemptId,reason:'REUSED'}); return; + } + target.state='running'; target.startedAt=now(); target.jobId=randomUUID(); await checkpoint(); aborted(clock.signal); + timer=deadline(clock.signal,definition.stepTimeoutMs); + const pending=Promise.resolve().then(()=>execute(immutable(clone(step)),cmd,input,{signal:timer.signal,jobId:target.jobId,attemptId:record.attemptId,scope:definition.scope})); + unsettled.add(pending); pending.then(()=>unsettled.delete(pending),()=>unsettled.delete(pending)); + const result=await interruptible(pending,timer.signal); fields(result,['output'],['artifacts','cache']); + const output=validateDataValue(cmd.outputSchema,result.output), artifacts=(result.artifacts ?? []).map(parseJobArtifact); + if(artifacts.length>32) fail('LIMIT','Artifact count'); await access(step,'after'); aborted(timer.signal); + usedBytes+=Buffer.byteLength(canonical(output)); if(usedBytes>WORKFLOW_LIMITS.outputBytes) fail('LIMIT','Workflow output budget'); + const cache=result.cache===undefined?null:cacheEvidence(result.cache),reused=cache&&['hit','shared'].includes(cache.state); + if(reused&&artifacts.length)fail('INVALID','Cached values cannot claim fresh mutable job artifacts'); + Object.assign(target,{state:'succeeded',output:clone(output),outputSha256:digest(canonical(output)),artifacts:clone(artifacts),cache:clone(cache),jobId:reused?null:target.jobId,reason:reused?'CACHE_'+cache.state.toUpperCase():'EXECUTED'}); + } catch(error) { + const code=errorCode(error); Object.assign(target,{state:code==='TIMEOUT'?'timed_out':code==='CANCELLED'?'cancelled':'failed',reason:code,output:null,outputSha256:null,artifacts:[]}); + if(code!=='CANCELLED' && !(code==='TIMEOUT' && clock.signal.aborted)) failed=true; + if(definition.policy==='fail-fast' && !clock.signal.aborted) clock.controller.abort(Object.assign(new Error('Dependent execution stopped'),{code:'CANCELLED'})); + } finally {timer?.close(); target.finishedAt=now(); await checkpoint();} + }; + try { + await checkpoint(); const running=new Map(); + while(record.steps.some(s=>s.state==='pending') || running.size) { + let progressed=false; + for(const step of definition.steps) { + const s=byStep.get(step.id); if(s.state!=='pending') continue; + if(clock.signal.aborted) {s.state=clock.signal.reason?.code==='TIMEOUT'?'timed_out':'cancelled';s.reason=clock.signal.reason?.code==='TIMEOUT'?'TIMEOUT':'CANCELLED';progressed=true;continue;} + if(step.needs.some(id=>['failed','cancelled','timed_out','blocked'].includes(byStep.get(id).state))) {s.state='blocked';s.reason='DEPENDENCY_FAILED';failed=true;progressed=true;continue;} + if(running.sizebyStep.get(id).state==='succeeded')) { + // Mark before the first awaited authorization so another scheduler pass cannot launch twice. + s.state='running'; const p=executeStep(step).finally(()=>running.delete(step.id)); running.set(step.id,p);progressed=true; + } + } + if(running.size) await Promise.race(running.values()); + else if(!progressed && record.steps.some(s=>s.state==='pending')) fail('INVALID','Unschedulable workflow'); + } + record.state=failed?'failed':clock.signal.aborted ? (clock.signal.reason?.code==='TIMEOUT'?'timed_out':'cancelled') : 'succeeded'; + await checkpoint(); if(journalError) throw journalError; return parseWorkflowRecord(record); + } finally {clock.close();busy=false;await tail;} + }, + }; return Object.freeze(api); +} + +/** resolveHost must select an isolated, currently approved host for exactly these step grants. */ +export function createCommandJobExecutor({resolveHost,pollMs=10}={}) { + if(typeof resolveHost!=='function') fail('INVALID','Host resolver required'); integer(pollMs,1,1000); + return async(step,command,input,{signal,jobId,scope:workflowScope})=>{ + const resolved=await resolveHost({step,command,grants:step.grants,scope:workflowScope});aborted(signal); + fields(resolved,['host','scope','grants','pluginSha256']); + if(canonical(resolved.grants)!==canonical(step.grants)||resolved.pluginSha256!==command.pluginSha256)fail('DENIED','Host grants or plugin pin differ from reviewed step'); + const host=resolved.host; + let job; + const check=()=>{if(job.jobId!==jobId||job.pluginId!==command.pluginId||job.commandId!==command.id||canonical(job.scope)!==canonical(resolved.scope))fail('CONFLICT','Job identity mismatch');}; + const cancel=()=>{try{Promise.resolve(host.cancelJob(jobId)).catch(()=>{});}catch{}}; + signal.addEventListener('abort',cancel,{once:true}); if(signal.aborted) cancel(); + try { + aborted(signal);job=parseJobSnapshot(await host.startCommandJob(command.pluginId,command.id,input,{jobId}));check();aborted(signal); + while(job.state==='running'){await pause(pollMs,signal);job=parseJobSnapshot(await host.getJob(jobId));check();} + aborted(signal); if(job.state!=='succeeded') fail(job.state==='timed_out'?'TIMEOUT':job.state==='cancelled'?'CANCELLED':'FAILED','Command job did not succeed'); + return {output:job.result,artifacts:job.artifacts}; + } catch(error) {cancel();throw error;} finally {signal.removeEventListener('abort',cancel);} + }; +} diff --git a/tests/lsp.test.mjs b/tests/lsp.test.mjs new file mode 100644 index 0000000..5d5cc13 --- /dev/null +++ b/tests/lsp.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,writeFile,realpath,rm,readFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {createPluginHost,definePlugin} from '../src/index.mjs'; +import {createLspBridge,createLspFrameDecoder,encodeLspMessage,LSP_LIMITS} from '../src/lsp-node.mjs'; +import {hashToolFile} from '../src/tool-streams-node.mjs'; + +const source='const value = 1;\nfunction add(a: number, b: number) {\n return a + b;\n}\nadd(value, 2);\n'; +const fixture=fileURLToPath(new URL('../examples/lsp/synthetic-server.mjs',import.meta.url)); +const scope={projectId:'workspace',sessionId:'operator'},pos={line:0,character:7},span={start:{line:0,character:0},end:{line:4,character:14}}; +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +let counter=0; +const request=(snapshot,kind,extra={})=>({protocolVersion:1,requestId:'request-'+(++counter),scope,snapshot,kind,...(['document-symbols','document-symbol-tree','semantic-tokens','folding-ranges','inlay-hints','code-actions','format-document','format-range'].includes(kind)?{}:{position:pos}),...extra}); +async function temporary(fn){const root=await realpath(await mkdtemp(path.join(tmpdir(),'dds-lsp-test-')));try{const file=path.join(root,'sample.ts');await writeFile(file,source);const snapshot={uri:pathToFileURL(file).href,languageId:'typescript',modelVersion:1,workspaceRevision:'initial',text:source};await fn(root,snapshot);}finally{assert.equal(path.dirname(root),await realpath(tmpdir()));assert.ok(path.basename(root).startsWith('dds-lsp-test-'));await rm(root,{recursive:true});}} +async function pinned(script,version,args,versionArgs,expectedVersionOutput,files=[]){return {id:'language-server',version,approved:true,executable:process.execPath,executableSha256:await hashToolFile(process.execPath),files:await Promise.all([script,...files].map(async file=>({path:file,sha256:await hashToolFile(file)}))),args:[script,...args],versionArgs:[script,...versionArgs],expectedVersionOutput,environment:{}};} +async function synthetic(root,mode='normal',extra={}){return createLspBridge({process:await pinned(fixture,'1.0.0',[mode],['--version'],'DDS LSP fixture 1.0.0'),workspaceRoot:root,scope,authorize:()=>true,expectedServerName:'DDS LSP fixture',requestTimeoutMs:2000,...extra});} + +test('independent LSP framing respects byte lengths, fragmented UTF-8, batched frames, malformed/truncated headers and hard budgets',()=>{ + const message={jsonrpc:'2.0',id:1,result:{text:'한글😀'}},frame=encodeLspMessage(message),decoder=createLspFrameDecoder(),values=[]; + for(let i=0;icreateLspFrameDecoder().push(Buffer.from(text))); + const incomplete=createLspFrameDecoder();incomplete.push(frame.subarray(0,-1));assert.throws(()=>incomplete.finish());assert.throws(()=>createLspFrameDecoder().push(Buffer.alloc(65_537)));assert.equal(LSP_LIMITS.pendingRequests,16); +}); + +test('synthetic LSP maps every negotiated core and 0.11 feature to existing SDK contracts without applying edits or commands',async()=>temporary(async(root,snapshot)=>{ + const notifications=[],bridge=await synthetic(root,'normal',{onDiagnostics:d=>notifications.push(d)}); + try{ + const checks=[['completion',{}],['hover',{}],['definition',{}],['references',{includeDeclaration:true}],['document-symbols',{}],['signature-help',{}],['prepare-rename',{}],['rename',{newName:'renamed'}],['format-document',{path:'sample.ts',formatOptions:{tabSize:2,insertSpaces:true}}],['format-range',{path:'sample.ts',range:{start:{line:0,character:0},end:{line:0,character:16}},formatOptions:{tabSize:2,insertSpaces:true}}],['code-actions',{path:'sample.ts',range:span,diagnosticContext:{revision:1,diagnostics:[]}}],['semantic-tokens',{}],['folding-ranges',{}],['inlay-hints',{range:span}],['document-symbol-tree',{}]]; + for(const [kind,extra]of checks){const result=await bridge.request(request(snapshot,kind,extra));assert.equal(result.kind,kind);assert.notEqual(result.data,null,kind);if(kind==='code-actions'){assert.ok(result.data[0].edit);assert.match(result.data[1].disabled.reason,/unsupported/);}} + assert.ok(notifications.length>=1);assert.equal(notifications[0].snapshot.modelVersion,1);assert.equal(await readFile(path.join(root,'sample.ts'),'utf8'),source); + assert.equal(bridge.capabilities().features.filter(f=>f.supported).length,15);assert.ok(bridge.capabilities().unsupported.includes('server-commands')); + }finally{const close=await bridge.close();assert.equal(close.graceful,true);assert.equal(close.ownedProcessClosed,true);assert.equal(close.pendingRequests,0);} +})); + +test('LSP providers work inside the actual independent PluginHost registry',async()=>temporary(async(root,snapshot)=>{ + const bridge=await synthetic(root),host=createPluginHost({scope,grants:['document.read','language.provide']}); + try{ + const plugin=definePlugin({manifestVersion:2,id:'lsp-example',name:'LSP Example',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./run.mjs',runtime:'workspace',capabilities:['completion','hover','definition','references','document-symbols'],permissions:['document.read','language.provide'],supportedHosts:['test-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}},context=>{for(const kind of ['completion','hover','definition','references','document-symbols'])context.registerLanguageProvider(kind,{languages:['typescript']},bridge.provider(kind));}); + await host.activate(plugin);host.setDocument(snapshot);assert.equal((await host.requestLanguage('completion',{position:pos})).data[0].label,'value');assert.match((await host.requestLanguage('hover',{position:pos})).data.text,/number/); + }finally{host.dispose();await bridge.close();} +})); + +test('LSP initialize mismatches, unsupported features, URI escapes, invalid ranges and unversioned diagnostics are rejected explicitly',async()=>temporary(async(root,snapshot)=>{ + for(const mode of ['encoding','frame'])await assert.rejects(synthetic(root,mode)); + for(const [mode,kind,extra]of [['unsupported','hover',{}],['external','definition',{}],['outside-edit','rename',{newName:'newValue'}],['invalid-range','hover',{}]]){ + const bridge=await synthetic(root,mode);try{await assert.rejects(bridge.request(request(snapshot,kind,extra)));}finally{await bridge.close();} + } + const seen=[],bridge=await synthetic(root,'unversioned',{onDiagnostics:value=>seen.push(value)});try{await bridge.request(request(snapshot,'hover'));assert.equal(seen.length,0);assert.equal(bridge.inspect().unversionedDropped,1);}finally{await bridge.close();} +})); + +test('LSP request cancellation, stale replies, duplicate IDs, revoked grants and crash/restart do not replay user operations',async()=>temporary(async(root,snapshot)=>{ + let allowed=true;const bridge=await synthetic(root,'late',{authorize:()=>allowed}); + try{ + const abort=new AbortController(),first=request(snapshot,'hover');const running=bridge.request(first,{signal:abort.signal});const rejected=assert.rejects(running,e=>e.code==='CANCELLED');await delay(20);await assert.rejects(bridge.request(first),e=>e.code==='CONFLICT');abort.abort();await rejected;await delay(120);assert.ok(bridge.inspect().transport.lateResponses>=1); + const old=bridge.request(request(snapshot,'hover'));const stale=assert.rejects(old,e=>e.code==='STALE');await delay(15);await bridge.syncDocument({...snapshot,modelVersion:2,workspaceRevision:'new',text:source.replace('= 1','= 2')});await stale; + allowed=false;await assert.rejects(bridge.request(request({...snapshot,modelVersion:2,workspaceRevision:'new',text:source.replace('= 1','= 2')},'hover')),e=>e.code==='DENIED'); + }finally{await bridge.close();} + const crashed=await synthetic(root,'crash');await assert.rejects(crashed.request(request(snapshot,'hover')),e=>e.code==='SERVER_EXIT');assert.equal(crashed.inspect().transport.closed,true);await crashed.close(); + const restarted=await synthetic(root);try{assert.equal(restarted.inspect().documents,0);assert.match((await restarted.request(request(snapshot,'hover'))).data.text,/number/);}finally{await restarted.close();} +})); + +test('LSP timeout, duplicate late response and refused shutdown release owned process and pending requests',async()=>temporary(async(root,snapshot)=>{ + let bridge=await synthetic(root,'timeout',{requestTimeoutMs:1000});try{await assert.rejects(bridge.request(request(snapshot,'hover')),e=>e.code==='TIMEOUT');}finally{await bridge.close();} + bridge=await synthetic(root,'duplicate');await bridge.request(request(snapshot,'hover'));await delay(10);assert.ok(bridge.inspect().transport.lateResponses>=1);await bridge.close(); + bridge=await synthetic(root,'shutdown');const close=await bridge.close();assert.equal(close.graceful,false);assert.equal(close.ownedProcessClosed,true);assert.equal(close.pendingRequests,0); +})); + +test('actual TypeScript language server 5.3.0 and TypeScript 5.9.3 interoperate through the published LSP subset',async()=>temporary(async(root)=>{ + const script=fileURLToPath(new URL('../node_modules/typescript-language-server/lib/cli.mjs',import.meta.url)),tsserver=fileURLToPath(new URL('../node_modules/typescript/lib/tsserver.js',import.meta.url)),typescript=fileURLToPath(new URL('../node_modules/typescript/lib/typescript.js',import.meta.url)); + const text='export const sample = { alpha: 1, beta: "two" };\nexport function add(a: number, b: number) { return a + b; }\nconst total = add(sample.alpha, 2);\nsample.alpha;\n'; + const file=path.join(root,'sample.ts');await writeFile(file,text);const snapshot={uri:pathToFileURL(file).href,languageId:'typescript',modelVersion:1,workspaceRevision:'typescript',text}; + // This release omits optional initialize.serverInfo; the exact file pins and --version probe identify it. + const bridge=await createLspBridge({process:await pinned(script,'5.3.0',['--stdio'],['--version'],'5.3.0',[tsserver,typescript]),workspaceRoot:root,scope,authorize:()=>true,requestTimeoutMs:15_000,initializationOptions:{hostInfo:'DDS Plugin SDK example',disableAutomaticTypingAcquisition:true,tsserver:{path:tsserver}}}); + try{ + const completion=await bridge.request(request(snapshot,'completion',{position:{line:3,character:7}}));assert.ok(completion.data.some(item=>item.label==='alpha'));assert.ok(completion.data.some(item=>item.label==='beta')); + const hover=await bridge.request(request(snapshot,'hover',{position:{line:2,character:26}}));assert.match(hover.data.text,/number/); + const definition=await bridge.request(request(snapshot,'definition',{position:{line:2,character:26}}));assert.ok(definition.data.some(item=>item.path==='sample.ts'&&item.range.start.line===0)); + const references=await bridge.request(request(snapshot,'references',{position:{line:0,character:14},includeDeclaration:true}));assert.ok(references.data.length>=2); + const symbols=await bridge.request(request(snapshot,'document-symbols'));assert.ok(symbols.data.some(item=>item.name==='sample')); + const signature=await bridge.request(request(snapshot,'signature-help',{position:{line:2,character:31}}));assert.ok(signature.data.signatures[0].label.includes('add')); + const format=await bridge.request(request(snapshot,'format-document',{path:'sample.ts',formatOptions:{tabSize:2,insertSpaces:true}}));assert.equal(format.kind,'format-document'); + const semantic=await bridge.request(request(snapshot,'semantic-tokens'));assert.ok(semantic.data.data.length>0); + assert.equal(await readFile(file,'utf8'),text); + }finally{const close=await bridge.close();assert.equal(close.ownedProcessClosed,true);assert.equal(close.pendingRequests,0);} +})); diff --git a/tests/tool-streams.test.mjs b/tests/tool-streams.test.mjs new file mode 100644 index 0000000..b7574e1 --- /dev/null +++ b/tests/tool-streams.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,writeFile,realpath,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createToolStreamParser,parseTypeScriptDiagnosticLine,createJobToolEventSink} from '../src/tool-streams.mjs'; +import {createToolRunner,hashToolFile} from '../src/tool-streams-node.mjs'; + +const encode=text=>new TextEncoder().encode(text),delay=ms=>new Promise(r=>setTimeout(r,ms)); +const context={jobId:'job-1',commandId:'build',toolId:'compiler',toolVersion:'1.0.0'}; +const fixture=fileURLToPath(new URL('../examples/workflow-tools/synthetic-tool.mjs',import.meta.url)); +const code=c=>e=>e.code===c; +async function temporary(fn){const root=await realpath(await mkdtemp(path.join(tmpdir(),'dds-tool-test-')));try{await fn(root);}finally{assert.equal(path.dirname(root),await realpath(tmpdir()));assert.ok(path.basename(root).startsWith('dds-tool-test-'));await rm(root,{recursive:true});}} +async function fixtureRegistration(){return {process:{id:'synthetic',version:'1.0.0',approved:true,executable:process.execPath,executableSha256:await hashToolFile(process.execPath),files:[{path:fixture,sha256:await hashToolFile(fixture)}],args:[fixture,{input:'mode'}],versionArgs:[fixture,'--version'],expectedVersionOutput:'DDS synthetic tool 1.0.0',environment:{}},backendId:'analyze',inputSchema:{schemaVersion:1,schema:{type:'object',properties:{mode:{type:'string',enum:['normal','slow','crash','long','flood']}},required:['mode'],additionalProperties:false}},pathInputs:[],format:'jsonl',parser:'plain'};} + +test('portable stream parser preserves split UTF-8 and trailing output, validates diagnostics, redacts literal secrets across chunks, and awaits sinks',async()=>{ + const events=[];let sinks=0,peak=0;const parser=createToolStreamParser({...context,secrets:['mask-demo-value'],sensitivePaths:['/operator/private'],onEvent:async event=>{sinks++;peak=Math.max(peak,sinks);await delay(1);events.push(event);sinks--;}}); + const payload=encode(JSON.stringify({kind:'log',level:'info',message:'한글 mask-demo-value /operator/private'})+'\n'+JSON.stringify({kind:'progress',completed:1,total:2})+'\n'+JSON.stringify({kind:'diagnostic',path:'../outside',range:{},severity:'error',message:'bad'})+'\n'+JSON.stringify({kind:'log',level:'info',message:'trailer'})); + for(let i=0;i{ + let parser=createToolStreamParser({...context,onEvent:()=>{}});await assert.rejects(parser.write(encode('x'.repeat(20_000))),code('LIMIT')); + parser=createToolStreamParser({...context,onEvent:()=>{}});await assert.rejects(parser.write(Uint8Array.of(0xff)),code('INVALID')); + parser=createToolStreamParser({...context,onEvent:()=>{},maxTotalBytes:5});assert.throws(()=>parser.write(encode('123456')),code('LIMIT')); + parser=createToolStreamParser({...context,onEvent:()=>{},maxEvents:1});await assert.rejects(parser.write(encode('{"kind":"log","level":"info","message":"one"}\n{"kind":"log","level":"info","message":"two"}\n')),code('LIMIT')); + parser=createToolStreamParser({...context,onEvent:()=>new Promise(()=>{}),eventTimeoutMs:5});await assert.rejects(parser.write(encode('{"kind":"log","level":"info","message":"one"}\n')),code('TIMEOUT')); + const abort=new AbortController();parser=createToolStreamParser({...context,signal:abort.signal,onEvent:()=>{}});abort.abort();assert.throws(()=>parser.write(encode('x')),code('CANCELLED')); + parser=createToolStreamParser({...context,onEvent:()=>{}});const pending=Array.from({length:8},()=>parser.write(encode('x')));assert.throws(()=>parser.write(encode('x')),code('LIMIT'));await Promise.all(pending);await parser.finish(); +}); + +test('TypeScript diagnostic and JobReporter adapters preserve correlation and validated zero-based positions',async()=>{ + const parsed=parseTypeScriptDiagnosticLine('source.ts(2,8): error TS2322: Type mismatch');assert.equal(parsed.range.start.line,1);assert.equal(parsed.range.start.character,7);assert.equal(parsed.code,'TS2322');assert.throws(()=>parseTypeScriptDiagnosticLine('/outside/source.ts(1,1): error TS1: bad')); + const progress=[],logs=[],diagnostics=[];const sink=createJobToolEventSink({reportProgress:p=>progress.push(p),log:(...l)=>logs.push(l)},{onDiagnostic:e=>diagnostics.push(e)}); + const parser=createToolStreamParser({...context,format:'text',parser:'typescript',onEvent:sink});await parser.write(encode('source.ts(2,8): error TS2322: Type mismatch\ncompiler note\n'));await parser.finish();assert.equal(diagnostics[0].jobId,'job-1');assert.equal(logs.length,1); +}); + +test('fixed registered Node tool emits output after exit and handles nonzero, flooding, cancellation, revocation, concurrency and unsupported versions',async()=>temporary(async root=>{ + const registration=await fixtureRegistration(),events=[];let allowed=true; + const runner=createToolRunner({tools:[registration],workspaceRoot:root,maxConcurrent:1,authorize:()=>allowed,secrets:['mask-demo-value']}); + const invocation={toolId:'synthetic',version:'1.0.0',input:{mode:'normal'},jobId:'job',commandId:'analyze',onEvent:e=>events.push(e)}; + try{ + const ok=await runner.run(invocation);assert.equal(ok.state,'succeeded');assert.equal(ok.exitCode,0);assert.equal(events.at(-1).data.message,'last partial line');assert.ok(events.some(e=>e.data.message==='[redacted] [redacted]'));assert.ok(events.some(e=>e.data.message==='한글 output')); + assert.equal((await runner.run({...invocation,input:{mode:'crash'}})).exitCode,7); + assert.equal((await runner.run({...invocation,input:{mode:'long'}})).reason,'LIMIT'); + assert.equal((await runner.run({...invocation,input:{mode:'flood'}})).reason,'LIMIT'); + await assert.rejects(runner.run({...invocation,version:'2.0.0'}),code('UNSUPPORTED')); + const abort=new AbortController();let entered;const ready=new Promise(r=>{entered=r;}); + const running=runner.run({...invocation,input:{mode:'slow'},signal:abort.signal,onEvent:()=>entered()});await ready; + await assert.rejects(runner.run(invocation),code('LIMIT'));abort.abort();assert.equal((await running).state,'cancelled');assert.equal(runner.inspect().active,0); + const denied=await runner.run({...invocation,onEvent:()=>{allowed=false;}});assert.equal(denied.state,'failed');assert.equal(denied.reason,'DENIED'); + }finally{await runner.close();} +})); + +test('operator pins and exact version probes are enforced before registered tool invocation',async()=>temporary(async root=>{ + const registration=await fixtureRegistration();registration.process.files[0].sha256='0'.repeat(64); + let runner=createToolRunner({tools:[registration],workspaceRoot:root,authorize:()=>true}); + const request={toolId:'synthetic',version:'1.0.0',input:{mode:'normal'},jobId:'j',commandId:'c',onEvent:()=>{}}; + assert.equal((await runner.run(request)).reason,'CONFLICT');await runner.close(); + registration.process.files[0].sha256=await hashToolFile(fixture);registration.process.expectedVersionOutput='different';runner=createToolRunner({tools:[registration],workspaceRoot:root,authorize:()=>true});assert.equal((await runner.run(request)).reason,'UNSUPPORTED');await runner.close(); +})); + +test('actual pinned TypeScript 5.9.3 compiler produces standard SDK diagnostics',async()=>temporary(async root=>{ + const tsc=fileURLToPath(new URL('../node_modules/typescript/bin/tsc',import.meta.url)),implementation=fileURLToPath(new URL('../node_modules/typescript/lib/_tsc.js',import.meta.url)); + await writeFile(path.join(root,'sample.ts'),'interface Array {length:number; [n:number]:T} interface Boolean {} interface Function {} interface CallableFunction {} interface NewableFunction {} interface IArguments {} interface Number {} interface Object {} interface RegExp {} interface String {}\nconst count: number = "wrong";\n'); + const registration={process:{id:'typescript',version:'5.9.3',approved:true,executable:process.execPath,executableSha256:await hashToolFile(process.execPath),files:await Promise.all([tsc,implementation].map(async file=>({path:file,sha256:await hashToolFile(file)}))),args:[tsc,'--noEmit','--noLib','--pretty','false',{input:'source'}],versionArgs:[tsc,'--version'],expectedVersionOutput:'Version 5.9.3',environment:{}},backendId:'typecheck',inputSchema:{schemaVersion:1,schema:{type:'object',properties:{source:{type:'string',maxLength:180}},required:['source'],additionalProperties:false}},pathInputs:['source'],format:'text',parser:'typescript'}; + const runner=createToolRunner({tools:[registration],workspaceRoot:root,authorize:()=>true});const events=[]; + try{const receipt=await runner.run({toolId:'typescript',version:'5.9.3',input:{source:'sample.ts'},jobId:'compiler-job',commandId:'typecheck',onEvent:e=>events.push(e)});assert.equal(receipt.reason,'NONZERO_EXIT');assert.ok(events.some(e=>e.data.kind==='diagnostic'&&e.data.code==='TS2322'&&e.data.path==='sample.ts'),JSON.stringify(events));assert.ok(!JSON.stringify(events).includes(root));} + finally{await runner.close();} +})); diff --git a/tests/types/workflows.mts b/tests/types/workflows.mts new file mode 100644 index 0000000..8fe2d9b --- /dev/null +++ b/tests/types/workflows.mts @@ -0,0 +1,33 @@ +import {prepareWorkflowPlan,createWorkflowRunner,createCommandJobExecutor,type WorkflowCommand,type WorkflowDefinition,type WorkflowRecord} from '@altifigence/dds-plugin-sdk/workflows'; +import {createNodeWorkflowStore} from '@altifigence/dds-plugin-sdk/workflows-node'; +import {fingerprintWorkflowInputs,createWorkflowCache,type WorkflowFingerprintInput} from '@altifigence/dds-plugin-sdk/workflow-cache'; +import {createNodeWorkflowCacheStore} from '@altifigence/dds-plugin-sdk/workflow-cache-node'; +import {createToolStreamParser,createJobToolEventSink,type ToolStreamEvent} from '@altifigence/dds-plugin-sdk/tool-streams'; +import {createToolRunner,hashToolFile,type ToolRegistration,type TrustedProcessDefinition} from '@altifigence/dds-plugin-sdk/tool-streams-node'; +import {createLspBridge,createLspFrameDecoder,encodeLspMessage,type LspCapabilityMatrix} from '@altifigence/dds-plugin-sdk/lsp-node'; +import type {PluginHost,Scope,DocumentSnapshot,LanguageRequest,JobReporter,LanguageResult} from '@altifigence/dds-plugin-sdk'; +declare const commands:readonly WorkflowCommand[],definition:WorkflowDefinition,host:PluginHost,scope:Scope,fpInput:WorkflowFingerprintInput,tool:ToolRegistration,process:TrustedProcessDefinition,job:JobReporter,snapshot:DocumentSnapshot,request:LanguageRequest<'completion'>; +const options={directory:'/owner/state',workspaceRoot:'/workspace',scope:definition.scope}; +const store=await createNodeWorkflowStore(options); +const runner=createWorkflowRunner({commands,store,authorize:context=>context.grants.length===0,execute:createCommandJobExecutor({resolveHost:({grants,command})=>({host,scope,grants,pluginSha256:command.pluginSha256})})}); +const plan=prepareWorkflowPlan(definition,commands); +const record:WorkflowRecord=await runner.run(plan,{approval:{approved:true,planSha256:plan.sha256}}); +await runner.recover(record.attemptId);await store.cleanup();store.inspect().directorySynced;await store.close(); +const cacheStore=await createNodeWorkflowCacheStore(options),cache=createWorkflowCache({store:cacheStore,authorize:()=>true}); +await cache.run(fingerprintWorkflowInputs(fpInput),{compute:()=>({value:1})});await cache.cleanup();await cache.close();await cacheStore.close(); +const events:ToolStreamEvent[]=[]; +const parser=createToolStreamParser({jobId:'job',commandId:'command',toolId:'tool',toolVersion:'1',onEvent:event=>{events.push(event);}}); +await parser.write(new Uint8Array());await parser.finish();parser.dispose(); +const tools=createToolRunner({tools:[tool],workspaceRoot:'/workspace',authorize:()=>true}); +await tools.run({toolId:'tool',version:'1',input:{},jobId:'job',commandId:'command',onEvent:createJobToolEventSink(job)});await tools.close();await hashToolFile('/operator/tool'); +const bridge=await createLspBridge({process,scope,workspaceRoot:'/workspace',authorize:()=>true}); +const matrix:LspCapabilityMatrix=bridge.capabilities();matrix.features[0].supported;await bridge.syncDocument(snapshot); +const completion:LanguageResult<'completion'>=await bridge.request<'completion'>(request);completion.data[0].label; +bridge.provider('hover');await bridge.closeDocument(snapshot.uri);await bridge.close(); +createLspFrameDecoder().push(encodeLspMessage({jsonrpc:'2.0',id:1,result:null})); +// @ts-expect-error execution requires explicit approval +runner.run(plan,{approval:{approved:false,planSha256:plan.sha256}}); +// @ts-expect-error command bindings cannot be both a literal and an output binding +const ambiguous:WorkflowDefinition['steps'][number]['input']={value:{value:1,step:'build',path:[]}}; +// @ts-expect-error process approval must be exact true +const unapproved:TrustedProcessDefinition={...process,approved:false}; diff --git a/tests/workflow-cache.test.mjs b/tests/workflow-cache.test.mjs new file mode 100644 index 0000000..4dc974a --- /dev/null +++ b/tests/workflow-cache.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,readFile,writeFile,rm,realpath,utimes,stat} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {fingerprintWorkflowInputs,createWorkflowCache,createMemoryWorkflowCacheStore} from '../src/workflow-cache.mjs'; +import {createNodeWorkflowCacheStore} from '../src/workflow-cache-node.mjs'; +const binding={workspaceId:'workspace',securityScope:'operator'}; +const input=()=>({scope:binding,commandId:'build',pluginSha256:'a'.repeat(64),toolSha256:'b'.repeat(64),schema:{v:1},settings:{strict:true},environment:{declared:['MODE'],values:{MODE:'test'}},input:{value:1},files:[{path:'src/main.ts',bytes:new TextEncoder().encode('hello')}],grants:['workspace.read'],policy:{optIn:true,deterministic:true,declaredInputsComplete:true,secretDependent:false}}); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); + +test('cache keys include file content, tool/plugin/schema, settings, environment, input and scope, while unknown and secret dependencies bypass',async()=>{ + const base=fingerprintWorkflowInputs(input()); + for(const change of [i=>i.files[0].bytes[0]++,i=>i.toolSha256='c'.repeat(64),i=>i.pluginSha256='d'.repeat(64),i=>i.schema.v=2,i=>i.settings.strict=false,i=>i.environment.values.MODE='other',i=>i.input.value=2,i=>i.scope={...binding,workspaceId:'other'},i=>i.grants=[]]){const i=input();change(i);assert.notEqual(fingerprintWorkflowInputs(i).key,base.key);} + for(const change of [i=>i.policy.optIn=false,i=>i.policy.deterministic=false,i=>i.policy.declaredInputsComplete=false,i=>i.policy.secretDependent=true,i=>i.environment.values.EXTRA='x',i=>delete i.environment.values.MODE,i=>i.input={kind:'dds-secret-reference',id:'ref'}]){const i=input();change(i);const fp=fingerprintWorkflowInputs(i);assert.equal(fp.eligible,false);const c=createWorkflowCache({authorize:()=>true});assert.equal((await c.run(fp,{compute:async()=>1})).state,'bypass');await c.close();} +}); + +test('concurrent cache misses share one fill, hits recheck authorization, and revoked results are withheld',async()=>{ + let count=0,allowed=true;const cache=createWorkflowCache({authorize:()=>allowed});const fp=fingerprintWorkflowInputs(input()); + const results=await Promise.all(Array.from({length:6},()=>cache.run(fp,{compute:async()=>{count++;await delay(10);return {value:2};}}))); + assert.equal(count,1);assert.equal(results.filter(r=>r.state==='miss').length,1);assert.equal(results.filter(r=>r.state==='shared').length,5); + assert.equal((await cache.run(fp,{compute:async()=>{throw Error('must not run');}})).state,'hit');allowed=false;await assert.rejects(cache.run(fp,{compute:async()=>0}),e=>e.code==='DENIED');await cache.close(); + let grants=true;const revoked=createWorkflowCache({authorize:()=>grants});await assert.rejects(revoked.run(fp,{compute:async()=>{grants=false;return 2;}}),e=>e.code==='DENIED');await revoked.close(); +}); + +test('cache expiry, LRU/byte quota, cancelled follower, damaged entries and secret results do not expose stale output',async()=>{ + let now=1;const store=createMemoryWorkflowCacheStore(),cache=createWorkflowCache({store,authorize:()=>true,now:()=>now,ttlMs:10,maxEntries:2,maxBytes:20}); + const a=fingerprintWorkflowInputs(input()),b=fingerprintWorkflowInputs({...input(),input:{value:2}}),c=fingerprintWorkflowInputs({...input(),input:{value:3}}); + await cache.run(a,{compute:async()=>1});now++;await cache.run(b,{compute:async()=>2});now++;await cache.run(a,{compute:async()=>0});now++;await cache.run(c,{compute:async()=>3});assert.equal(await store.get(b.key),null); + now=20;const clean=await cache.cleanup();assert.equal(clean.removed.length,2);assert.equal((await cache.run(a,{compute:async()=>4})).state,'miss'); + const ref=await cache.run(b,{compute:async()=>({kind:'dds-secret-reference',id:'r'})});assert.equal(ref.reason,'secret-result');assert.equal(await store.get(b.key),null);await cache.close(); + const shared=createWorkflowCache({authorize:()=>true}),abort=new AbortController();let resolve;const gate=new Promise(r=>{resolve=r;}); + const leader=shared.run(a,{compute:async()=>gate});await delay(1);const follower=shared.run(a,{compute:async()=>99,signal:abort.signal});const rejected=assert.rejects(follower,e=>e.code==='CANCELLED');await delay(1);abort.abort();await rejected;resolve(7);assert.equal((await leader).value,7);await shared.close(); +}); + +test('node cache restarts, detects changed content with identical mtime and corrupted records, and preserves non-cache files',async()=>{ + const parent=await realpath(await mkdtemp(path.join(tmpdir(),'dds-cache-test-')));let store,cache; + try{ + await mkdir(path.join(parent,'workspace'));await mkdir(path.join(parent,'store'));const source=path.join(parent,'workspace','file.txt');await writeFile(source,'first');const times=await stat(source); + const opts={directory:path.join(parent,'store'),workspaceRoot:path.join(parent,'workspace'),scope:binding};store=await createNodeWorkflowCacheStore(opts);cache=createWorkflowCache({store,authorize:()=>true}); + const i=input();i.files[0].bytes=await readFile(source);const old=fingerprintWorkflowInputs(i);await cache.run(old,{compute:async()=>({result:1})});await cache.close();await store.close(); + store=await createNodeWorkflowCacheStore(opts);cache=createWorkflowCache({store,authorize:()=>true});assert.equal((await cache.run(old,{compute:async()=>0})).state,'hit'); + await writeFile(source,'other');await utimes(source,times.atime,times.mtime);i.files[0].bytes=await readFile(source);const fresh=fingerprintWorkflowInputs(i);assert.notEqual(old.key,fresh.key);assert.equal((await cache.run(fresh,{compute:async()=>({result:2})})).state,'miss'); + await cache.close();await store.close();await writeFile(path.join(parent,'store',old.key+'.json'),'invalid');await writeFile(path.join(parent,'store','do-not-remove.txt'),'keep'); + const orphan='.pending-00000000-0000-4000-8000-000000000001-00000000-0000-4000-8000-000000000002';await writeFile(path.join(parent,'store',orphan),'{"incomplete":'); + store=await createNodeWorkflowCacheStore(opts);cache=createWorkflowCache({store,authorize:()=>true});const receipt=await cache.cleanup();assert.ok(receipt.recovery.removed.includes(old.key+'.json'));assert.ok(receipt.recovery.removed.includes(orphan));assert.ok(receipt.recovery.preserved.includes('do-not-remove.txt'));assert.equal(await readFile(source,'utf8'),'other'); + }finally{await cache?.close();await store?.close();assert.equal(path.dirname(parent),await realpath(tmpdir()));assert.ok(path.basename(parent).startsWith('dds-cache-test-'));await rm(parent,{recursive:true});} +}); diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs new file mode 100644 index 0000000..c4c3dc6 --- /dev/null +++ b/tests/workflows.test.mjs @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,readFile,writeFile,rm,realpath} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createPluginHost,definePlugin} from '../src/index.mjs'; +import {prepareWorkflowPlan,createWorkflowRunner,createMemoryWorkflowStore,createCommandJobExecutor,recoverWorkflowRecord,parseWorkflowRecord} from '../src/workflows.mjs'; +import {createNodeWorkflowStore} from '../src/workflows-node.mjs'; + +const number={type:'integer',minimum:0,maximum:1000},data={schemaVersion:1,schema:{type:'object',properties:{value:number},required:['value'],additionalProperties:false}}; +const commands=[{id:'increment',pluginId:'workflow-example',pluginSha256:'a'.repeat(64),inputSchema:data,outputSchema:data,grants:[]}]; +const binding={workspaceId:'w',securityScope:'owner'}; +const step=(id,needs=[],value=1)=>({id,commandId:'increment',needs,input:{value:needs.length?{step:needs[0],path:['value']}:{value}},grants:[]}); +const definition=steps=>({schemaVersion:1,id:'build',scope:binding,steps,policy:'continue',concurrency:2,timeoutMs:1000,stepTimeoutMs:500,retentionMs:1000}); +const approve=plan=>({approved:true,planSha256:plan.sha256}); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +const code=expected=>e=>e.code===expected; +async function temporary(fn){const parent=await realpath(await mkdtemp(path.join(tmpdir(),'dds-workflow-test-')));try{await mkdir(path.join(parent,'workspace'));await mkdir(path.join(parent,'store'));await fn(parent);}finally{assert.equal(path.dirname(parent),await realpath(tmpdir()));assert.ok(path.basename(parent).startsWith('dds-workflow-test-'));await rm(parent,{recursive:true});}} + +test('workflow preflight rejects cycles, schema mismatches, unknown/duplicate commands, missing bindings and secret fields before execution',()=>{ + const valid=definition([step('a'),step('b',['a'])]);assert.ok(Object.isFrozen(prepareWorkflowPlan(valid,commands))); + const bads=[]; + for(const modify of [d=>d.steps[0].needs.push('b'),d=>d.steps[1].input.value.step='absent',d=>d.steps.push(step('a')),d=>d.steps[0].commandId='unknown',d=>delete d.steps[0].input.value,d=>d.steps[0].input.value.value='bad',d=>d.concurrency=9,d=>d.steps[1].input.value.path=['missing']]){const d=structuredClone(valid);modify(d);bads.push(d);} + bads.forEach(d=>assert.throws(()=>prepareWorkflowPlan(d,commands))); + assert.throws(()=>prepareWorkflowPlan(valid,[...commands,...commands])); + const changed=structuredClone(commands);changed[0].outputSchema.schema.properties.value.type='string';delete changed[0].outputSchema.schema.properties.value.minimum;delete changed[0].outputSchema.schema.properties.value.maximum;assert.throws(()=>prepareWorkflowPlan(valid,changed)); + const sensitive=structuredClone(commands);sensitive[0].inputSchema.schema.properties.value={type:'object',format:'dds-secret-reference'};assert.throws(()=>prepareWorkflowPlan(valid,sensitive),code('UNSUPPORTED')); +}); + +test('workflow schedules bounded branches and executes through the existing public command-job host',async()=>{ + let active=0,peak=0;const hostScope={projectId:'w',sessionId:'owner'},host=createPluginHost({jobs:true,grants:[],scope:hostScope}); + const plugin=definePlugin({manifestVersion:2,id:'workflow-example',name:'Workflow',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./run.mjs',runtime:'workspace',capabilities:['commands'],permissions:[],supportedHosts:['test-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}},context=>context.registerCommand({id:'increment',title:'Increment'},async(input,{job})=>{active++;peak=Math.max(peak,active);await delay(10);job.reportProgress({completed:1,total:1});active--;return {value:input.value+1};})); + try{ + await host.activate(plugin);const seen=[]; + const runner=createWorkflowRunner({commands,authorize:c=>{seen.push(c);return true;},execute:createCommandJobExecutor({resolveHost:async({grants})=>{assert.deepEqual(grants,[]);return {host,scope:hostScope,grants,pluginSha256:commands[0].pluginSha256};}})}); + const plan=prepareWorkflowPlan(definition([step('a'),step('b'),step('c',['a']),step('d',['b'])]),commands); + const result=await runner.run(plan,{approval:approve(plan),parentJobId:'parent-job'}); + assert.equal(result.state,'succeeded');assert.equal(result.parentJobId,'parent-job');assert.equal(result.steps[2].output.value,3);assert.equal(peak,2); + assert.ok(result.steps.every(s=>s.jobId&&s.reason==='EXECUTED'));assert.deepEqual(runner.inspect(),{running:false,unsettled:0});assert.ok(seen.every(s=>s.grants.length===0)); + }finally{host.dispose();} +}); + +test('workflow failure blocks only dependent branches; manual resume reuses approved successes in a new attempt',async()=>{ + const store=createMemoryWorkflowStore();let fail=true,calls=[]; + const runner=createWorkflowRunner({commands,store,authorize:()=>true,execute:async(s,_,input)=>{calls.push(s.id);if(s.id==='b'&&fail)throw Error('Synthetic failure');return {output:{value:input.value+1}};}}); + const plan=prepareWorkflowPlan(definition([step('a'),step('b',['a']),step('c',['b']),step('d')]),commands); + const first=await runner.run(plan,{approval:approve(plan)});assert.equal(first.state,'failed');assert.deepEqual(first.steps.map(s=>s.state),['succeeded','failed','blocked','succeeded']); + fail=false;calls=[]; + const second=await runner.run(plan,{approval:{...approve(plan),previousAttemptId:first.attemptId},previous:first}); + assert.equal(second.state,'succeeded');assert.notEqual(second.attemptId,first.attemptId);assert.equal(second.parentAttemptId,first.attemptId);assert.deepEqual(calls,['b','c']);assert.equal(second.steps[0].sourceAttemptId,first.attemptId); + const changed=structuredClone(commands);changed[0].pluginSha256='b'.repeat(64); + await assert.rejects(createWorkflowRunner({commands:changed,authorize:()=>true,execute:async()=>{throw Error('must not execute');}}).run(plan,{approval:approve(plan)}),code('CONFLICT')); + await assert.rejects(runner.run(plan,{approval:{...approve(plan),planSha256:'0'.repeat(64)}}),code('DENIED')); +}); + +test('workflow grants are checked at each stage, expired partial outputs require selected retries, and retry propagates through unordered DAG',async()=>{ + let now=1,allowed=true,calls=0;const store=createMemoryWorkflowStore(); + const runner=createWorkflowRunner({commands,store,now:()=>now,authorize:()=>allowed,execute:async(_,__,input)=>{calls++;return {output:{value:input.value+1}};}}); + const plan=prepareWorkflowPlan(definition([step('c',['b']),step('b',['a']),step('a')]),commands); + const first=await runner.run(plan,{approval:approve(plan)});assert.equal(calls,3);now=2000; + const expired=await runner.run(plan,{approval:{...approve(plan),previousAttemptId:first.attemptId},previous:first});assert.equal(expired.state,'failed');assert.equal(expired.steps[2].reason,'EXPIRED'); + const fresh=await runner.run(plan,{approval:{...approve(plan),previousAttemptId:first.attemptId,retrySteps:['a']},previous:first});assert.equal(fresh.state,'succeeded');assert.equal(calls,6); + allowed=false;const denied=await runner.run(plan,{approval:approve(plan)});assert.equal(denied.state,'failed');assert.equal(calls,6);assert.ok(denied.steps.every(s=>s.output===null)); +}); + +test('workflow cancel, step deadline, fail-fast and unsettled handlers retain truthful states and prevent overlapping retries',async()=>{ + let settle;const pending=new Promise(r=>{settle=r;});const controller=new AbortController();let entered;const ready=new Promise(r=>{entered=r;}); + const runner=createWorkflowRunner({commands,authorize:()=>true,execute:async()=>{entered();return pending;}}); + const plan=prepareWorkflowPlan(definition([step('a')]),commands); + const running=runner.run(plan,{approval:approve(plan),signal:controller.signal});await ready;controller.abort();const stopped=await running; + assert.equal(stopped.steps[0].state,'cancelled');assert.equal(stopped.state,'cancelled');assert.equal(runner.inspect().unsettled,1); + await assert.rejects(runner.run(plan,{approval:approve(plan)}),code('CONFLICT'));settle({output:{value:2}});await delay(0);assert.equal(runner.inspect().unsettled,0); + const timeoutPlan=prepareWorkflowPlan({...definition([step('a')]),stepTimeoutMs:5},commands); + const timeout=createWorkflowRunner({commands,authorize:()=>true,execute:async()=>{await delay(20);return {output:{value:2}};}}); + const timed=await timeout.run(timeoutPlan,{approval:approve(timeoutPlan)});assert.equal(timed.steps[0].state,'timed_out');await delay(25); + const failurePlan=prepareWorkflowPlan({...definition([step('a'),step('b',['a'])]),policy:'fail-fast'},commands); + const failed=await createWorkflowRunner({commands,authorize:()=>true,execute:async()=>{throw Error('failure');}}).run(failurePlan,{approval:approve(failurePlan)});assert.equal(failed.state,'failed');assert.equal(failed.steps[1].state,'cancelled'); +}); + +test('durable workflow checkpoints survive restart without automatic execution; CAS, corruption and owned cleanup preserve workspace files',async()=>temporary(async parent=>{ + const options={directory:path.join(parent,'store'),workspaceRoot:path.join(parent,'workspace'),scope:binding};let store=await createNodeWorkflowStore(options); + const plan=prepareWorkflowPlan(definition([step('a')]),commands);let count=0; + const runner=createWorkflowRunner({commands,store,authorize:()=>true,execute:async()=>{count++;return {output:{value:2}};}}); + const first=await runner.run(plan,{approval:approve(plan)});await store.close();store=await createNodeWorkflowStore(options); + assert.deepEqual(await store.read(first.attemptId),first);assert.equal(count,1); + const running=structuredClone(first);running.attemptId='interrupted';running.revision=1;running.state='running';Object.assign(running.steps[0],{state:'running',output:null,outputSha256:null,artifacts:[]});await store.write(running,0); + const recovered=recoverWorkflowRecord(await store.read('interrupted'));assert.equal(recovered.state,'interrupted');assert.equal(recovered.steps[0].state,'interrupted');assert.equal(count,1); + await assert.rejects(store.write(running,0),code('CONFLICT')); + const expired=structuredClone(first);expired.attemptId='expired';expired.revision=1;expired.expiresAt=expired.startedAt+1;await store.write(expired,0); + const retention=await store.cleanup({expiredBefore:expired.expiresAt});assert.ok(retention.removed.includes('expired.json'));assert.equal(await store.read('expired'),null);assert.ok(await store.read('interrupted')); + await writeFile(path.join(parent,'workspace','keep.txt'),'retain');await store.close(); + await writeFile(path.join(parent,'store','unknown.txt'),'retain');await writeFile(path.join(parent,'store',first.attemptId+'.json'),'broken'); + store=await createNodeWorkflowStore(options);assert.equal(await store.read(first.attemptId),null);const cleaned=await store.cleanup();assert.ok(cleaned.removed.includes(first.attemptId+'.json'));assert.ok(cleaned.preserved.includes('unknown.txt'));assert.equal(await readFile(path.join(parent,'workspace','keep.txt'),'utf8'),'retain');await store.close(); + assert.throws(()=>parseWorkflowRecord({...first,steps:[{...first.steps[0],outputSha256:'0'.repeat(64)}]})); +})); + +test('concurrent manual resumes cannot enter while another resume owns the runner',async()=>{ + const records=createMemoryWorkflowStore();let release,waiting=false; + const store={...records,async read(id){if(waiting)await new Promise(resolve=>{release=resolve;});return records.read(id);}}; + const runner=createWorkflowRunner({commands,store,authorize:()=>true,execute:async(_,__,input)=>({output:{value:input.value+1}})}); + const plan=prepareWorkflowPlan(definition([step('a')]),commands),previous=await runner.run(plan,{approval:approve(plan)}); + waiting=true;const resume=runner.run(plan,{previous,approval:{...approve(plan),previousAttemptId:previous.attemptId}}); + waiting=false;let entered;const ready=new Promise(resolve=>{entered=resolve;});let unblock; + const originalWrite=store.write;store.write=async(...args)=>{entered();await new Promise(resolve=>{unblock=resolve;});return originalWrite(...args);}; + const first=runner.run(plan,{approval:approve(plan)});await ready;release();await assert.rejects(resume,code('CONFLICT')); + store.write=originalWrite;unblock();assert.equal((await first).state,'succeeded'); +});