V0.38.2 library packages (#235)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
90
CHANGELOG.md
90
CHANGELOG.md
@@ -1,5 +1,95 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.38.2.0] — 2026-03-25
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Library Packages. Reusable Starlark libraries that extensions can declare
|
||||||
|
as dependencies and load at runtime via `lib.require()`. Libraries export
|
||||||
|
functions, run with their own permission context, and are protected from
|
||||||
|
uninstall while active consumers exist. Includes a `test-tool` admin
|
||||||
|
endpoint for invoking extension tool calls without the AI chat loop.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Schema:** `ext_dependencies` table with `(consumer_id, library_id)`
|
||||||
|
composite PK, `version_spec` and `resolved_ver` columns. SQLite
|
||||||
|
migration also rebuilds `packages` table to add `'library'` to the
|
||||||
|
type CHECK constraint (SQLite cannot ALTER constraints).
|
||||||
|
(`server/database/migrations/023_ext_dependencies.sql`,
|
||||||
|
`server/database/migrations/sqlite/023_ext_dependencies.sql`)
|
||||||
|
- **Model:** `ExtDependency` struct with `ConsumerID`, `LibraryID`,
|
||||||
|
`VersionSpec`, `ResolvedVer`.
|
||||||
|
(`server/models/ext_dependency.go`)
|
||||||
|
- **Store:** `DependencyStore` interface — `Create`, `DeleteByConsumer`,
|
||||||
|
`ListByConsumer`, `ListByLibrary`, `ListAll`. Postgres and SQLite
|
||||||
|
implementations.
|
||||||
|
(`server/store/interfaces.go`, `postgres/ext_dependency.go`,
|
||||||
|
`sqlite/ext_dependency.go`)
|
||||||
|
- **Handlers:** Dependency admin endpoints:
|
||||||
|
- `GET /admin/packages/:id/dependencies` — list consumer's deps
|
||||||
|
- `GET /admin/packages/:id/consumers` — list library's consumers
|
||||||
|
- `GET /admin/dependencies` — full dependency graph
|
||||||
|
(`server/handlers/dependencies.go`)
|
||||||
|
- **Test-tool endpoint:** `POST /admin/packages/:id/test-tool` — invokes
|
||||||
|
a starlark extension's `on_tool_call` entry point directly for testing.
|
||||||
|
(`server/handlers/packages.go`)
|
||||||
|
- **Starlark module:** `lib.require(id)` — loads a declared library
|
||||||
|
dependency, executes its script with library-scoped permissions,
|
||||||
|
extracts manifest exports, freezes into a cached struct. Per-invocation
|
||||||
|
cache and cycle detection.
|
||||||
|
(`server/sandbox/lib_module.go`)
|
||||||
|
- **Install validation:** Library packages require `exports` array,
|
||||||
|
must not have `tools`, `pipes`, or `route`. Consumer `dependencies`
|
||||||
|
map validated against installed active libraries. Dependency records
|
||||||
|
created on install, cleaned up on consumer uninstall. Libraries with
|
||||||
|
active consumers return 409 on delete.
|
||||||
|
(`server/handlers/packages.go`)
|
||||||
|
- **SDK:** `sw.api.admin.packages.dependencies(id)`,
|
||||||
|
`sw.api.admin.packages.consumers(id)` domain methods.
|
||||||
|
(`src/js/sw/sdk/api-domains.js`)
|
||||||
|
- **ICD:** Extension dependencies section with all endpoint specs.
|
||||||
|
(`docs/ICD/extensions.md`)
|
||||||
|
- **OpenAPI:** Dependency endpoint schemas and `test-tool` endpoint.
|
||||||
|
(`server/static/openapi.yaml`)
|
||||||
|
- **SDK test runner:** `dependencies` domain — 8 tests covering library
|
||||||
|
install, dependency creation, consumer listing, uninstall protection,
|
||||||
|
cleanup, and library deletion.
|
||||||
|
(`packages/sdk-test-runner/js/domains/dependencies.js`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Runner:** `ExecPackage` creates a `libContext` per invocation and
|
||||||
|
passes it through `buildModulesWithLibCtx`. The `lib` module is always
|
||||||
|
injected (no permission required). Multi-file `packageLoader` reused
|
||||||
|
for library scripts.
|
||||||
|
(`server/sandbox/runner.go`)
|
||||||
|
- **Packages handler:** Install flow extended with dependency resolution
|
||||||
|
and `ext_dependencies` record creation. Delete flow checks for active
|
||||||
|
consumers before allowing library removal.
|
||||||
|
(`server/handlers/packages.go`)
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
|
||||||
|
- `lib.require()` was originally `lib.load()` but `load` is a reserved
|
||||||
|
keyword in Starlark (used for `load("module.star", "symbol")`). Renamed
|
||||||
|
to `require` to avoid parser conflicts.
|
||||||
|
- Libraries run with their **own** permission context, not the consumer's.
|
||||||
|
A consumer without `api.http` can still call a library that uses
|
||||||
|
`http.get()` — the library's permissions are evaluated independently.
|
||||||
|
- The `test-tool` endpoint enables E2E testing of Starlark extensions
|
||||||
|
without a configured LLM provider.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- **8 created:** `lib_module.go`, `dependencies.go`, `ext_dependency.go`
|
||||||
|
(model), `ext_dependency.go` (pg), `ext_dependency.go` (sqlite),
|
||||||
|
`023_ext_dependencies.sql` (×2), `dependencies.js` (SDK runner)
|
||||||
|
- **11 modified:** `packages.go`, `runner.go`, `main.go`, `interfaces.go`,
|
||||||
|
`stores.go` (×2), `api-domains.js`, `openapi.yaml`, `extensions.md`,
|
||||||
|
`packages.js`, `main.js` (SDK runner)
|
||||||
|
- **+1,141 lines** (737 new files + 404 modified)
|
||||||
|
|
||||||
## [0.38.1.0] — 2026-03-25
|
## [0.38.1.0] — 2026-03-25
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -414,6 +414,50 @@ config fields with secrets decrypted.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Library Packages (v0.38.2)
|
||||||
|
|
||||||
|
Library packages (`type: "library"`) export Starlark functions for other
|
||||||
|
packages to consume. Libraries run with their own permission context,
|
||||||
|
isolating consumers from implementation details.
|
||||||
|
|
||||||
|
**Manifest fields:**
|
||||||
|
- `type: "library"` — required
|
||||||
|
- `tier: "starlark"` — required (libraries are always starlark-tier)
|
||||||
|
- `exports: ["fn_a", "fn_b"]` — required, names of globals to expose
|
||||||
|
- `permissions: [...]` — the library's own permissions (not inherited by consumers)
|
||||||
|
- `dependencies: {"other-lib": ">=1.0.0"}` — optional, libraries can depend on other libraries
|
||||||
|
|
||||||
|
**Restrictions:** Libraries cannot have `tools`, `pipes`, or a `route`.
|
||||||
|
|
||||||
|
**Consumer usage (Starlark):**
|
||||||
|
|
||||||
|
Consumers declare dependencies in their manifest:
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "gitea-client": ">=1.0.0" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load at runtime:
|
||||||
|
```python
|
||||||
|
gitea = lib.load("gitea-client")
|
||||||
|
repos = gitea.get_repos(conn)
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib.load()` validates the dependency record, loads the library's script
|
||||||
|
with the library's own permissions, extracts the declared exports, and
|
||||||
|
returns them as a frozen struct. Results are cached per invocation.
|
||||||
|
|
||||||
|
**Admin endpoints:**
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/v1/admin/packages/:id/dependencies` | Libraries this package depends on |
|
||||||
|
| GET | `/api/v1/admin/packages/:id/consumers` | Packages that depend on this library |
|
||||||
|
| GET | `/api/v1/admin/dependencies` | Full dependency graph |
|
||||||
|
|
||||||
|
**Uninstall protection:** Libraries with active consumers return 409 on delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Builtin Seeding
|
### Builtin Seeding
|
||||||
|
|
||||||
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ See design docs for full specifications.
|
|||||||
|---------|---------|------------|
|
|---------|---------|------------|
|
||||||
| v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
|
| v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
|
||||||
| v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
|
| v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
|
||||||
| v0.38.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. |
|
| v0.38.2 ✅ | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.require()` with per-library permission context, dependency resolution, uninstall protection, `test-tool` admin endpoint. 8+3 SDK runner tests. |
|
||||||
| v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). |
|
| v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). |
|
||||||
| v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. |
|
| v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. |
|
||||||
| v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. |
|
| v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. |
|
||||||
|
|||||||
219
packages/sdk-test-runner/js/domains/dependencies.js
Normal file
219
packages/sdk-test-runner/js/domains/dependencies.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: dependencies (v0.38.2)
|
||||||
|
*
|
||||||
|
* Tests library package install, dependency creation, uninstall
|
||||||
|
* protection, and admin dependency visibility endpoints.
|
||||||
|
* Requires admin role.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
// ── Minimal zip builder (same as packages.js) ───────────
|
||||||
|
function buildZip(entries) {
|
||||||
|
var localHeaders = [];
|
||||||
|
var centralHeaders = [];
|
||||||
|
var offset = 0;
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
var nameBytes = new TextEncoder().encode(entry.name);
|
||||||
|
var dataBytes = new TextEncoder().encode(entry.content);
|
||||||
|
var local = new Uint8Array(30 + nameBytes.length + dataBytes.length);
|
||||||
|
var dv = new DataView(local.buffer);
|
||||||
|
dv.setUint32(0, 0x04034b50, true);
|
||||||
|
dv.setUint16(4, 20, true);
|
||||||
|
dv.setUint16(8, 0, true);
|
||||||
|
dv.setUint16(10, 0, true);
|
||||||
|
dv.setUint16(12, 0, true);
|
||||||
|
dv.setUint32(14, crc32(dataBytes), true);
|
||||||
|
dv.setUint32(18, dataBytes.length, true);
|
||||||
|
dv.setUint32(22, dataBytes.length, true);
|
||||||
|
dv.setUint16(26, nameBytes.length, true);
|
||||||
|
dv.setUint16(28, 0, true);
|
||||||
|
local.set(nameBytes, 30);
|
||||||
|
local.set(dataBytes, 30 + nameBytes.length);
|
||||||
|
localHeaders.push(local);
|
||||||
|
var central = new Uint8Array(46 + nameBytes.length);
|
||||||
|
var cdv = new DataView(central.buffer);
|
||||||
|
cdv.setUint32(0, 0x02014b50, true);
|
||||||
|
cdv.setUint16(4, 20, true);
|
||||||
|
cdv.setUint16(6, 20, true);
|
||||||
|
cdv.setUint16(28, nameBytes.length, true);
|
||||||
|
cdv.setUint32(16, crc32(dataBytes), true);
|
||||||
|
cdv.setUint32(20, dataBytes.length, true);
|
||||||
|
cdv.setUint32(24, dataBytes.length, true);
|
||||||
|
cdv.setUint32(42, offset, true);
|
||||||
|
central.set(nameBytes, 46);
|
||||||
|
centralHeaders.push(central);
|
||||||
|
offset += local.length;
|
||||||
|
});
|
||||||
|
var centralSize = centralHeaders.reduce(function (s, c) { return s + c.length; }, 0);
|
||||||
|
var eocd = new Uint8Array(22);
|
||||||
|
var edv = new DataView(eocd.buffer);
|
||||||
|
edv.setUint32(0, 0x06054b50, true);
|
||||||
|
edv.setUint16(8, entries.length, true);
|
||||||
|
edv.setUint16(10, entries.length, true);
|
||||||
|
edv.setUint32(12, centralSize, true);
|
||||||
|
edv.setUint32(16, offset, true);
|
||||||
|
var result = new Uint8Array(offset + centralSize + 22);
|
||||||
|
var pos = 0;
|
||||||
|
localHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||||
|
centralHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||||
|
result.set(eocd, pos);
|
||||||
|
return new Blob([result], { type: 'application/zip' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var crcTable = null;
|
||||||
|
function crc32(bytes) {
|
||||||
|
if (!crcTable) {
|
||||||
|
crcTable = new Uint32Array(256);
|
||||||
|
for (var n = 0; n < 256; n++) {
|
||||||
|
var c = n;
|
||||||
|
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
crcTable[n] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var crc = 0xFFFFFFFF;
|
||||||
|
for (var i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePkgFile(manifest, extraFiles) {
|
||||||
|
var entries = [{ name: 'manifest.json', content: JSON.stringify(manifest) }];
|
||||||
|
if (extraFiles) entries = entries.concat(extraFiles);
|
||||||
|
var blob = buildZip(entries);
|
||||||
|
return new File([blob], manifest.id + '.pkg', { type: 'application/zip' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.registerDomain('dependencies', async function () {
|
||||||
|
|
||||||
|
if (!window.sw || !sw.isAdmin) {
|
||||||
|
await T.test('dependencies', 'guard', 'skip — not admin', async function () {
|
||||||
|
T.assert(true, 'skipped (user is not admin)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var libId = 'sdk-test-lib-' + Date.now();
|
||||||
|
var consumerId = 'sdk-test-consumer-' + Date.now();
|
||||||
|
|
||||||
|
// ── 1. Install library ────────────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'install-library', 'install library package', async function () {
|
||||||
|
var manifest = {
|
||||||
|
id: libId,
|
||||||
|
title: 'SDK Test Library',
|
||||||
|
type: 'library',
|
||||||
|
tier: 'starlark',
|
||||||
|
version: '1.0.0',
|
||||||
|
exports: ['get_data', 'transform'],
|
||||||
|
permissions: []
|
||||||
|
};
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'def get_data():\n return {"ok": True}\n\ndef transform(x):\n return x\n' }
|
||||||
|
]);
|
||||||
|
var r = await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(r && r.id === libId, 'library should install');
|
||||||
|
T.assert(r.type === 'library', 'type should be library');
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
try { await sw.api.admin.packages.del(libId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Verify library in package list ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'library-in-list', 'library appears in package list', async function () {
|
||||||
|
var r = await sw.api.admin.packages.get(libId);
|
||||||
|
T.assert(r && r.type === 'library', 'should be type library');
|
||||||
|
T.assert(r.version === '1.0.0', 'version should match');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Install consumer with dependency ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'install-consumer', 'install consumer with dependency on library', async function () {
|
||||||
|
var manifest = {
|
||||||
|
id: consumerId,
|
||||||
|
title: 'SDK Test Consumer',
|
||||||
|
type: 'extension',
|
||||||
|
tier: 'starlark',
|
||||||
|
version: '0.1.0',
|
||||||
|
tools: [{ name: 'noop', description: 'test', parameters: { type: 'object', properties: {} } }],
|
||||||
|
dependencies: {}
|
||||||
|
};
|
||||||
|
manifest.dependencies[libId] = '>=1.0.0';
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'def on_tool_call(call):\n return {"ok": True}\n' }
|
||||||
|
]);
|
||||||
|
var r = await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(r && r.id === consumerId, 'consumer should install');
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
try { await sw.api.admin.packages.del(consumerId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Verify dependency records ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'list-dependencies', 'admin can list consumer dependencies', async function () {
|
||||||
|
var r = await sw.api.admin.packages.dependencies(consumerId);
|
||||||
|
T.assert(r && r.data, 'should return data');
|
||||||
|
T.assert(r.data.length === 1, 'should have 1 dependency');
|
||||||
|
T.assert(r.data[0].library_id === libId, 'library_id should match');
|
||||||
|
T.assert(r.data[0].version_spec === '>=1.0.0', 'version_spec should match');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Verify consumer records ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'list-consumers', 'admin can list library consumers', async function () {
|
||||||
|
var r = await sw.api.admin.packages.consumers(libId);
|
||||||
|
T.assert(r && r.data, 'should return data');
|
||||||
|
T.assert(r.data.length === 1, 'should have 1 consumer');
|
||||||
|
T.assert(r.data[0].consumer_id === consumerId, 'consumer_id should match');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. Uninstall protection ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'uninstall-protection', 'cannot uninstall library with active consumers', async function () {
|
||||||
|
try {
|
||||||
|
await sw.api.admin.packages.del(libId);
|
||||||
|
T.assert(false, 'should have been rejected');
|
||||||
|
} catch (e) {
|
||||||
|
var msg = (e && e.message) || String(e);
|
||||||
|
T.assert(
|
||||||
|
msg.indexOf('409') >= 0 || msg.indexOf('active consumers') >= 0 || msg.indexOf('Conflict') >= 0,
|
||||||
|
'expected 409 conflict, got: ' + msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7. Uninstall consumer, verify cleanup ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'uninstall-consumer', 'uninstall consumer cleans up dependencies', async function () {
|
||||||
|
await sw.api.admin.packages.del(consumerId);
|
||||||
|
consumerId = null; // disable cleanup
|
||||||
|
|
||||||
|
var r = await sw.api.admin.packages.consumers(libId);
|
||||||
|
T.assert(r && r.data, 'should return data');
|
||||||
|
T.assert(r.data.length === 0, 'library should have 0 consumers after uninstall');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8. Library uninstall succeeds ────────────────
|
||||||
|
|
||||||
|
await T.test('dependencies', 'uninstall-library', 'library can be uninstalled after consumers removed', async function () {
|
||||||
|
await sw.api.admin.packages.del(libId);
|
||||||
|
libId = null; // disable cleanup
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
try {
|
||||||
|
await sw.api.admin.packages.get(libId);
|
||||||
|
T.assert(false, 'should be deleted');
|
||||||
|
} catch (e) {
|
||||||
|
T.assert(true, 'library deleted successfully');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -233,5 +233,60 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── v0.38.2: Library package validation ──
|
||||||
|
|
||||||
|
await T.test('packages', 'library-no-exports', 'library without exports → 400', async function () {
|
||||||
|
var manifest = makeManifest({ type: 'library', tier: 'starlark' });
|
||||||
|
delete manifest.tools;
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'x = 1\n' }
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(false, 'should have rejected library without exports');
|
||||||
|
} catch (e) {
|
||||||
|
var msg = (e && e.message) || String(e);
|
||||||
|
T.assert(
|
||||||
|
msg.indexOf('400') >= 0 || msg.indexOf('exports') >= 0 || msg.indexOf('error') >= 0,
|
||||||
|
'expected 400 for missing exports'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('packages', 'library-with-tools', 'library with tools → 400', async function () {
|
||||||
|
var manifest = makeManifest({ type: 'library', tier: 'starlark', exports: ['fn1'] });
|
||||||
|
// manifest already has tools from makeManifest
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'def fn1():\n return 1\n' }
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(false, 'should have rejected library with tools');
|
||||||
|
} catch (e) {
|
||||||
|
var msg = (e && e.message) || String(e);
|
||||||
|
T.assert(
|
||||||
|
msg.indexOf('400') >= 0 || msg.indexOf('tools') >= 0 || msg.indexOf('error') >= 0,
|
||||||
|
'expected 400 for library with tools'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('packages', 'consumer-bad-dep', 'consumer with non-existent dependency → 400', async function () {
|
||||||
|
var manifest = makeManifest({ dependencies: { 'nonexistent-lib-xyz': '>=1.0.0' } });
|
||||||
|
var file = makePkgFile(manifest, [
|
||||||
|
{ name: 'script.star', content: 'def on_tool_call(call):\n return {"ok": True}\n' }
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await sw.api.admin.packages.install(file);
|
||||||
|
T.assert(false, 'should have rejected non-existent dependency');
|
||||||
|
} catch (e) {
|
||||||
|
var msg = (e && e.message) || String(e);
|
||||||
|
T.assert(
|
||||||
|
msg.indexOf('400') >= 0 || msg.indexOf('not found') >= 0 || msg.indexOf('error') >= 0,
|
||||||
|
'expected 400 for non-existent dependency'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
'domains/admin.js',
|
'domains/admin.js',
|
||||||
'domains/packages.js',
|
'domains/packages.js',
|
||||||
'domains/connections.js',
|
'domains/connections.js',
|
||||||
|
'domains/dependencies.js',
|
||||||
// UI (must come last)
|
// UI (must come last)
|
||||||
'ui.js'
|
'ui.js'
|
||||||
];
|
];
|
||||||
|
|||||||
31
server/database/migrations/023_ext_dependencies.sql
Normal file
31
server/database/migrations/023_ext_dependencies.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 023 Extension Dependencies
|
||||||
|
-- ==========================================
|
||||||
|
-- v0.38.2: Library package dependency tracking.
|
||||||
|
-- Consumers declare which libraries they depend on.
|
||||||
|
-- Libraries with active consumers cannot be uninstalled.
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Add 'library' to the packages type constraint.
|
||||||
|
ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_type_check;
|
||||||
|
ALTER TABLE packages ADD CONSTRAINT packages_type_check
|
||||||
|
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ext_dependencies (
|
||||||
|
consumer_id TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
|
||||||
|
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
|
PRIMARY KEY (consumer_id, library_id),
|
||||||
|
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (library_id) REFERENCES packages(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE ext_dependencies IS 'v0.38.2: Package dependency graph — consumers → libraries';
|
||||||
|
COMMENT ON COLUMN ext_dependencies.consumer_id IS 'Package that depends on the library';
|
||||||
|
COMMENT ON COLUMN ext_dependencies.library_id IS 'Library package being depended upon';
|
||||||
|
COMMENT ON COLUMN ext_dependencies.version_spec IS 'Semver version spec declared by consumer (e.g. ">=1.0.0")';
|
||||||
|
COMMENT ON COLUMN ext_dependencies.resolved_ver IS 'Actual library version at dependency creation time';
|
||||||
62
server/database/migrations/sqlite/023_ext_dependencies.sql
Normal file
62
server/database/migrations/sqlite/023_ext_dependencies.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 023 Extension Dependencies
|
||||||
|
-- ==========================================
|
||||||
|
-- v0.38.2: Library package dependency tracking.
|
||||||
|
-- SQLite version.
|
||||||
|
--
|
||||||
|
-- Also adds 'library' to the packages type CHECK constraint.
|
||||||
|
-- SQLite doesn't support ALTER CONSTRAINT, so we rebuild the table.
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Step 1: Rebuild packages table with updated CHECK constraint.
|
||||||
|
CREATE TABLE IF NOT EXISTS packages_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'surface'
|
||||||
|
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')),
|
||||||
|
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT 'browser'
|
||||||
|
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'global'
|
||||||
|
CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
manifest TEXT NOT NULL DEFAULT '{}',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||||
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||||
|
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO packages_new SELECT * FROM packages;
|
||||||
|
DROP TABLE packages;
|
||||||
|
ALTER TABLE packages_new RENAME TO packages;
|
||||||
|
|
||||||
|
-- Recreate indexes (dropped with old table)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status);
|
||||||
|
|
||||||
|
-- Step 2: Dependencies table.
|
||||||
|
CREATE TABLE IF NOT EXISTS ext_dependencies (
|
||||||
|
consumer_id TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
|
||||||
|
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
|
PRIMARY KEY (consumer_id, library_id),
|
||||||
|
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (library_id) REFERENCES packages(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);
|
||||||
69
server/handlers/dependencies.go
Normal file
69
server/handlers/dependencies.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Extension Dependencies (v0.38.2) ──────────────────
|
||||||
|
// Admin-only read endpoints for dependency graph visibility.
|
||||||
|
// Dependencies are created/deleted implicitly by InstallPackage/DeletePackage.
|
||||||
|
|
||||||
|
// ListDependencies returns all libraries that a package depends on.
|
||||||
|
// GET /api/v1/admin/packages/:id/dependencies
|
||||||
|
func (h *PackageHandler) ListDependencies(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if h.stores.Dependencies == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deps, err := h.stores.Dependencies.ListByConsumer(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps == nil {
|
||||||
|
deps = []models.ExtDependency{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConsumers returns all packages that depend on a library.
|
||||||
|
// GET /api/v1/admin/packages/:id/consumers
|
||||||
|
func (h *PackageHandler) ListConsumers(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if h.stores.Dependencies == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deps, err := h.stores.Dependencies.ListByLibrary(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list consumers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps == nil {
|
||||||
|
deps = []models.ExtDependency{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllDependencies returns the full dependency graph.
|
||||||
|
// GET /api/v1/admin/dependencies
|
||||||
|
func (h *PackageHandler) ListAllDependencies(c *gin.Context) {
|
||||||
|
if h.stores.Dependencies == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deps, err := h.stores.Dependencies.ListAll(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps == nil {
|
||||||
|
deps = []models.ExtDependency{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
"chat-switchboard/database"
|
"chat-switchboard/database"
|
||||||
|
"chat-switchboard/models"
|
||||||
"chat-switchboard/sandbox"
|
"chat-switchboard/sandbox"
|
||||||
"chat-switchboard/store"
|
"chat-switchboard/store"
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,7 @@ type PackageHandler struct {
|
|||||||
stores store.Stores
|
stores store.Stores
|
||||||
packagesDir string // e.g. /data/packages — where archives are extracted
|
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||||
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
||||||
|
runner *sandbox.Runner // v0.38.2: for test-tool execution
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||||
@@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
|||||||
h.sandbox = sb
|
h.sandbox = sb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2).
|
||||||
|
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
|
||||||
|
h.runner = r
|
||||||
|
}
|
||||||
|
|
||||||
// ListPackages returns all registered packages.
|
// ListPackages returns all registered packages.
|
||||||
// GET /api/v1/admin/packages
|
// GET /api/v1/admin/packages
|
||||||
// GET /api/v1/admin/surfaces (alias)
|
// GET /api/v1/admin/surfaces (alias)
|
||||||
@@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.38.2: Libraries with active consumers cannot be uninstalled.
|
||||||
|
if pkg.Type == "library" && h.stores.Dependencies != nil {
|
||||||
|
hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
|
||||||
|
if hasConsumers {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.38.2: Clean up dependency records when uninstalling a consumer.
|
||||||
|
if h.stores.Dependencies != nil {
|
||||||
|
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
|
||||||
|
}
|
||||||
|
|
||||||
// v0.29.2: Drop namespaced DB tables before removing the package record.
|
// v0.29.2: Drop namespaced DB tables before removing the package record.
|
||||||
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
|
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
|
||||||
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
|
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
|
||||||
@@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
if pkgType == "" {
|
if pkgType == "" {
|
||||||
pkgType = "surface"
|
pkgType = "surface"
|
||||||
}
|
}
|
||||||
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
|
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +345,21 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "library":
|
||||||
|
// v0.38.2: Libraries must declare exports, cannot have tools/pipes/route.
|
||||||
|
exports, _ := manifest["exports"].([]any)
|
||||||
|
if len(exports) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasTools || hasPipes {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasRoute {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for conflicts with core packages
|
// Check for conflicts with core packages
|
||||||
@@ -470,6 +507,47 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.38.2: Create dependency records from manifest "dependencies" map.
|
||||||
|
// Libraries must be installed first; consumers declare them.
|
||||||
|
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
||||||
|
// Clear stale dependency records from a previous install of the same consumer.
|
||||||
|
if h.stores.Dependencies != nil {
|
||||||
|
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||||
|
}
|
||||||
|
for libID, vSpec := range deps {
|
||||||
|
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||||
|
if err != nil || lib == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lib.Type != "library" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lib.Status != "active" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versionSpec, _ := vSpec.(string)
|
||||||
|
if versionSpec == "" {
|
||||||
|
versionSpec = ">=0.0.0"
|
||||||
|
}
|
||||||
|
if h.stores.Dependencies != nil {
|
||||||
|
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||||
|
ConsumerID: pkgID,
|
||||||
|
LibraryID: libID,
|
||||||
|
VersionSpec: versionSpec,
|
||||||
|
ResolvedVer: lib.Version,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Test Tool (v0.38.2) ─────────────────────────
|
||||||
|
// POST /admin/packages/:id/test-tool
|
||||||
|
// Invokes a starlark extension's on_tool_call entry point directly,
|
||||||
|
// without going through the AI chat loop. Admin-only test harness.
|
||||||
|
|
||||||
|
// TestToolRequest is the JSON body for test-tool invocation.
|
||||||
|
type TestToolRequest struct {
|
||||||
|
ToolName string `json:"tool_name" binding:"required"`
|
||||||
|
Arguments map[string]any `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTool invokes a package's on_tool_call entry point for testing.
|
||||||
|
func (h *PackageHandler) TestTool(c *gin.Context) {
|
||||||
|
if h.runner == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgID := c.Param("id")
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.Tier != "starlark" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !pkg.Enabled {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req TestToolRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a call dict identical to what executeExtensionTool sends.
|
||||||
|
// Build the arguments as an interface{} map for jsonToStarlark.
|
||||||
|
var args interface{}
|
||||||
|
if req.Arguments != nil {
|
||||||
|
argsJSON, _ := json.Marshal(req.Arguments)
|
||||||
|
_ = json.Unmarshal(argsJSON, &args)
|
||||||
|
}
|
||||||
|
|
||||||
|
callDict := starlark.NewDict(3)
|
||||||
|
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
|
||||||
|
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
|
||||||
|
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
|
||||||
|
|
||||||
|
val, output, err := h.runner.CallEntryPoint(
|
||||||
|
c.Request.Context(), pkg, "on_tool_call",
|
||||||
|
starlark.Tuple{callDict}, nil, rc,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": err.Error(),
|
||||||
|
"output": output,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the Starlark return value to Go types.
|
||||||
|
result := starlarkValueToGo(val)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"result": result,
|
||||||
|
"output": output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1357,6 +1357,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
||||||
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
|
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
|
||||||
|
pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool
|
||||||
|
|
||||||
// Package registry — must be registered before /packages/:id (v0.30.0)
|
// Package registry — must be registered before /packages/:id (v0.30.0)
|
||||||
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
||||||
@@ -1371,6 +1372,10 @@ func main() {
|
|||||||
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
|
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
|
||||||
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0
|
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0
|
||||||
admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0
|
admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0
|
||||||
|
admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2
|
||||||
|
admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2
|
||||||
|
admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2
|
||||||
|
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
|
||||||
|
|
||||||
// Package export (v0.30.0)
|
// Package export (v0.30.0)
|
||||||
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
||||||
|
|||||||
13
server/models/ext_dependency.go
Normal file
13
server/models/ext_dependency.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ── Extension Dependencies (v0.38.2) ──────────────────
|
||||||
|
|
||||||
|
// ExtDependency records that a consumer package depends on a library package.
|
||||||
|
// Created at consumer install time from the manifest's "dependencies" map.
|
||||||
|
// Immutable — update by uninstalling/reinstalling the consumer.
|
||||||
|
type ExtDependency struct {
|
||||||
|
ConsumerID string `json:"consumer_id" db:"consumer_id"`
|
||||||
|
LibraryID string `json:"library_id" db:"library_id"`
|
||||||
|
VersionSpec string `json:"version_spec" db:"version_spec"`
|
||||||
|
ResolvedVer string `json:"resolved_ver" db:"resolved_ver"`
|
||||||
|
}
|
||||||
155
server/sandbox/lib_module.go
Normal file
155
server/sandbox/lib_module.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Package sandbox — lib_module.go
|
||||||
|
//
|
||||||
|
// v0.38.2: Library loading module for Starlark extensions.
|
||||||
|
// No permission required — any starlark package can use lib.require().
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// gitea = lib.require("gitea-client") → struct with library's exports
|
||||||
|
//
|
||||||
|
// The loaded library runs with its own permission context, not the
|
||||||
|
// consumer's. Results are cached per ExecPackage invocation.
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// libContext carries per-invocation state for lib.require() calls.
|
||||||
|
// Created once per ExecPackage and threaded through buildModules.
|
||||||
|
type libContext struct {
|
||||||
|
cache map[string]starlark.Value // library ID → frozen exports struct
|
||||||
|
loading map[string]bool // cycle detection
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLibContext() *libContext {
|
||||||
|
return &libContext{
|
||||||
|
cache: make(map[string]starlark.Value),
|
||||||
|
loading: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildLibModule creates the "lib" module.
|
||||||
|
// consumerPkgID is the package calling lib.require() — used to validate
|
||||||
|
// the dependency record exists.
|
||||||
|
func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *RunContext, lc *libContext) *starlarkstruct.Module {
|
||||||
|
return MakeModule("lib", starlark.StringDict{
|
||||||
|
"require": starlark.NewBuiltin("lib.require", func(
|
||||||
|
thread *starlark.Thread, b *starlark.Builtin,
|
||||||
|
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||||
|
) (starlark.Value, error) {
|
||||||
|
var libraryID string
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &libraryID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check cache
|
||||||
|
if cached, ok := lc.cache[libraryID]; ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cycle detection
|
||||||
|
if lc.loading[libraryID] {
|
||||||
|
return nil, fmt.Errorf("lib.require: circular dependency: %q", libraryID)
|
||||||
|
}
|
||||||
|
lc.loading[libraryID] = true
|
||||||
|
defer func() { delete(lc.loading, libraryID) }()
|
||||||
|
|
||||||
|
// 3. Verify dependency is declared
|
||||||
|
if r.stores.Dependencies == nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: dependency store not available")
|
||||||
|
}
|
||||||
|
deps, err := r.stores.Dependencies.ListByConsumer(ctx, consumerPkgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: failed to check dependencies: %w", err)
|
||||||
|
}
|
||||||
|
declared := false
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.LibraryID == libraryID {
|
||||||
|
declared = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !declared {
|
||||||
|
return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Load library's PackageRegistration
|
||||||
|
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err)
|
||||||
|
}
|
||||||
|
if libPkg.Type != "library" {
|
||||||
|
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||||||
|
}
|
||||||
|
if libPkg.Status != models.PackageStatusActive {
|
||||||
|
return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status)
|
||||||
|
}
|
||||||
|
if libPkg.Tier != models.ExtTierStarlark {
|
||||||
|
return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build library's module set (library's own permissions)
|
||||||
|
libModules, err := r.buildModulesWithLibCtx(ctx, libraryID, libPkg.Manifest, rc, lc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: failed to build modules for library %q: %w", libraryID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Load library script from disk
|
||||||
|
libScript, err := r.loadScript(libPkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Execute with library-scoped loader
|
||||||
|
libLoader := r.packageLoader(libraryID, libModules)
|
||||||
|
result, err := r.sandbox.ExecWithLoader(ctx, libraryID+"/script.star", libScript, libModules, libLoader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Extract exports from manifest
|
||||||
|
exports := extractExports(libPkg.Manifest)
|
||||||
|
if len(exports) == 0 {
|
||||||
|
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
members := make(starlark.StringDict, len(exports))
|
||||||
|
for _, name := range exports {
|
||||||
|
val, ok := result.Globals[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("lib.require: library %q does not export %q", libraryID, name)
|
||||||
|
}
|
||||||
|
members[name] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Freeze into struct, cache, return
|
||||||
|
frozen := starlarkstruct.FromStringDict(starlark.String(libraryID), members)
|
||||||
|
lc.cache[libraryID] = frozen
|
||||||
|
|
||||||
|
log.Printf(" 📦 lib.require: loaded %s (%d exports) for %s", libraryID, len(exports), consumerPkgID)
|
||||||
|
return frozen, nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractExports reads the "exports" array from a package manifest.
|
||||||
|
func extractExports(manifest map[string]any) []string {
|
||||||
|
raw, ok := manifest["exports"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exports := make([]string, 0, len(raw))
|
||||||
|
for _, v := range raw {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
exports = append(exports, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exports
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
// at startup. db.read grants query/view/list_tables; db.write adds
|
// at startup. db.read grants query/view/list_tables; db.write adds
|
||||||
// insert/update/delete. If both are granted, db.write wins (superset).
|
// insert/update/delete. If both are granted, db.write wins (superset).
|
||||||
//
|
//
|
||||||
|
// v0.38.2: Adds lib.load() for library packages. Libraries run with
|
||||||
|
// their own permission context. Per-invocation lib cache + cycle detection.
|
||||||
|
//
|
||||||
// The runner is the bridge between the package/permission system
|
// The runner is the bridge between the package/permission system
|
||||||
// and the sandboxed Starlark interpreter.
|
// and the sandboxed Starlark interpreter.
|
||||||
package sandbox
|
package sandbox
|
||||||
@@ -119,8 +122,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.38.2: per-invocation lib context for lib.load() caching + cycle detection
|
||||||
|
lc := newLibContext()
|
||||||
|
|
||||||
// Build modules based on granted permissions
|
// Build modules based on granted permissions
|
||||||
modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc)
|
modules, err := r.buildModulesWithLibCtx(ctx, pkg.ID, pkg.Manifest, rc, lc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||||
}
|
}
|
||||||
@@ -256,10 +262,17 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildModules assembles the module map based on granted permissions.
|
// buildModules assembles the module map based on granted permissions.
|
||||||
|
// Delegates to buildModulesWithLibCtx with a nil lib context (no lib.load support).
|
||||||
|
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
|
||||||
|
return r.buildModulesWithLibCtx(ctx, packageID, manifest, rc, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildModulesWithLibCtx assembles the module map based on granted permissions.
|
||||||
// The manifest is passed through so modules can read their config
|
// The manifest is passed through so modules can read their config
|
||||||
// (e.g., http module reads network_access, provider reads requires_provider).
|
// (e.g., http module reads network_access, provider reads requires_provider).
|
||||||
// The RunContext carries per-invocation state for user-scoped modules.
|
// The RunContext carries per-invocation state for user-scoped modules.
|
||||||
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
|
// The libContext enables lib.load() caching and cycle detection (v0.38.2).
|
||||||
|
func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext, lc *libContext) (map[string]starlark.Value, error) {
|
||||||
if r.stores.ExtPermissions == nil {
|
if r.stores.ExtPermissions == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -332,5 +345,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
|||||||
}
|
}
|
||||||
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID)
|
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID)
|
||||||
|
|
||||||
|
// v0.38.2: lib module — always injected, no permission required.
|
||||||
|
// Allows any starlark package to load declared library dependencies.
|
||||||
|
if lc != nil {
|
||||||
|
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||||
|
}
|
||||||
|
|
||||||
return modules, nil
|
return modules, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8820,6 +8820,74 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
# ── Extension Dependencies (v0.38.2) ─────────────────────────────────
|
||||||
|
/api/v1/admin/packages/{id}/dependencies:
|
||||||
|
get:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: List libraries this package depends on
|
||||||
|
description: "v0.38.2: Returns dependency records where this package is the consumer."
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Dependencies list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ExtDependency'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
/api/v1/admin/packages/{id}/consumers:
|
||||||
|
get:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: List packages that depend on this library
|
||||||
|
description: "v0.38.2: Returns dependency records where this package is the library."
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Consumers list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ExtDependency'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
/api/v1/admin/dependencies:
|
||||||
|
get:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: List all package dependencies
|
||||||
|
description: "v0.38.2: Returns the full dependency graph."
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Full dependency graph
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ExtDependency'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
||||||
/api/v1/connections:
|
/api/v1/connections:
|
||||||
get:
|
get:
|
||||||
@@ -12266,6 +12334,22 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
ExtDependency:
|
||||||
|
type: object
|
||||||
|
description: "v0.38.2: Package dependency record (consumer → library)"
|
||||||
|
properties:
|
||||||
|
consumer_id:
|
||||||
|
type: string
|
||||||
|
description: Package ID of the consumer
|
||||||
|
library_id:
|
||||||
|
type: string
|
||||||
|
description: Package ID of the library
|
||||||
|
version_spec:
|
||||||
|
type: string
|
||||||
|
description: Semver version spec declared by consumer
|
||||||
|
resolved_ver:
|
||||||
|
type: string
|
||||||
|
description: Actual library version at dependency creation time
|
||||||
ExtConnection:
|
ExtConnection:
|
||||||
type: object
|
type: object
|
||||||
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
|
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type Stores struct {
|
|||||||
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
||||||
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
||||||
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
||||||
|
Dependencies DependencyStore // v0.38.2: Library package dependency graph
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -1396,6 +1397,35 @@ type ConnectionStore interface {
|
|||||||
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// DEPENDENCY STORE (v0.38.2)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type DependencyStore interface {
|
||||||
|
// Create records that consumerID depends on libraryID.
|
||||||
|
Create(ctx context.Context, dep *models.ExtDependency) error
|
||||||
|
|
||||||
|
// Delete removes a single dependency record.
|
||||||
|
Delete(ctx context.Context, consumerID, libraryID string) error
|
||||||
|
|
||||||
|
// DeleteAllForConsumer removes all dependency records for a consumer.
|
||||||
|
// Called when uninstalling a consumer package.
|
||||||
|
DeleteAllForConsumer(ctx context.Context, consumerID string) error
|
||||||
|
|
||||||
|
// ListByConsumer returns all libraries that consumerID depends on.
|
||||||
|
ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error)
|
||||||
|
|
||||||
|
// ListByLibrary returns all consumers that depend on libraryID.
|
||||||
|
ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error)
|
||||||
|
|
||||||
|
// ListAll returns the full dependency graph.
|
||||||
|
ListAll(ctx context.Context) ([]models.ExtDependency, error)
|
||||||
|
|
||||||
|
// HasConsumers returns true if any package depends on libraryID.
|
||||||
|
// Used for uninstall protection.
|
||||||
|
HasConsumers(ctx context.Context, libraryID string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// ListOptions provides standard pagination/sort for list queries.
|
// ListOptions provides standard pagination/sort for list queries.
|
||||||
|
|||||||
95
server/store/postgres/ext_dependency.go
Normal file
95
server/store/postgres/ext_dependency.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DependencyStore (v0.38.2) ────────────────────────
|
||||||
|
|
||||||
|
type DependencyStore struct{}
|
||||||
|
|
||||||
|
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
|
||||||
|
|
||||||
|
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
|
||||||
|
|
||||||
|
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (consumer_id, library_id) DO UPDATE
|
||||||
|
SET version_spec = EXCLUDED.version_spec, resolved_ver = EXCLUDED.resolved_ver`,
|
||||||
|
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_dependencies WHERE consumer_id = $1 AND library_id = $2`,
|
||||||
|
consumerID, libraryID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_dependencies WHERE consumer_id = $1`, consumerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = $1 ORDER BY library_id`,
|
||||||
|
consumerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = $1 ORDER BY consumer_id`,
|
||||||
|
libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM ext_dependencies WHERE library_id = $1)`,
|
||||||
|
libraryID).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────
|
||||||
|
|
||||||
|
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
|
||||||
|
var result []models.ExtDependency
|
||||||
|
for rows.Next() {
|
||||||
|
var d models.ExtDependency
|
||||||
|
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, d)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
Export: NewExportStore(),
|
||||||
Connections: NewConnectionStore(),
|
Connections: NewConnectionStore(),
|
||||||
|
Dependencies: NewDependencyStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
server/store/sqlite/ext_dependency.go
Normal file
93
server/store/sqlite/ext_dependency.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DependencyStore (v0.38.2) ────────────────────────
|
||||||
|
|
||||||
|
type DependencyStore struct{}
|
||||||
|
|
||||||
|
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
|
||||||
|
|
||||||
|
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
|
||||||
|
|
||||||
|
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT OR REPLACE INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_dependencies WHERE consumer_id = ? AND library_id = ?`,
|
||||||
|
consumerID, libraryID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_dependencies WHERE consumer_id = ?`, consumerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = ? ORDER BY library_id`,
|
||||||
|
consumerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = ? ORDER BY consumer_id`,
|
||||||
|
libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanDependencies(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM ext_dependencies WHERE library_id = ?`,
|
||||||
|
libraryID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────
|
||||||
|
|
||||||
|
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
|
||||||
|
var result []models.ExtDependency
|
||||||
|
for rows.Next() {
|
||||||
|
var d models.ExtDependency
|
||||||
|
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, d)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
Export: NewExportStore(),
|
||||||
Connections: NewConnectionStore(),
|
Connections: NewConnectionStore(),
|
||||||
|
Dependencies: NewDependencyStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,10 +500,17 @@ export function createDomains(restClient) {
|
|||||||
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
|
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
|
||||||
settings: (id) => rc.get(`/api/v1/admin/packages/${id}/settings`),
|
settings: (id) => rc.get(`/api/v1/admin/packages/${id}/settings`),
|
||||||
updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data),
|
updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data),
|
||||||
|
dependencies: (id) => rc.get(`/api/v1/admin/packages/${id}/dependencies`), // v0.38.2
|
||||||
|
consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2
|
||||||
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
||||||
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// v0.38.2: Full dependency graph
|
||||||
|
dependencies: {
|
||||||
|
list: () => rc.get('/api/v1/admin/dependencies'),
|
||||||
|
},
|
||||||
|
|
||||||
tasks: {
|
tasks: {
|
||||||
list: () => rc.get('/api/v1/admin/tasks'),
|
list: () => rc.get('/api/v1/admin/tasks'),
|
||||||
run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
|
run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user