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:
2026-03-25 14:09:05 +00:00
committed by xcaliber
parent 6943c91f40
commit 2136535176
22 changed files with 1233 additions and 6 deletions

View File

@@ -1,5 +1,95 @@
# 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
### Summary

View File

@@ -1 +1 @@
0.37.19.0
0.38.2.0

View File

@@ -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
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`

View File

@@ -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.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.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. |

View 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');
}
});
});
})();

View File

@@ -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'
);
}
});
});
})();

View File

@@ -76,6 +76,7 @@
'domains/admin.js',
'domains/packages.js',
'domains/connections.js',
'domains/dependencies.js',
// UI (must come last)
'ui.js'
];

View 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';

View 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);

View 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})
}

View File

@@ -13,8 +13,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"chat-switchboard/database"
"chat-switchboard/models"
"chat-switchboard/sandbox"
"chat-switchboard/store"
)
@@ -29,6 +31,7 @@ type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
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 {
@@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
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.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
@@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
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.
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)
@@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or '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', 'workflow', or 'library'"})
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"})
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
@@ -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{
"id": pkgID,
"title": title,
@@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
}
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,
})
}

View File

@@ -1357,6 +1357,7 @@ func main() {
}
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
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)
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
@@ -1371,6 +1372,10 @@ func main() {
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // 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)
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)

View 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"`
}

View 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
}

View File

@@ -13,6 +13,9 @@
// at startup. db.read grants query/view/list_tables; db.write adds
// 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
// and the sandboxed Starlark interpreter.
package sandbox
@@ -119,8 +122,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
return nil, err
}
// v0.38.2: per-invocation lib context for lib.load() caching + cycle detection
lc := newLibContext()
// 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 {
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.
// 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
// (e.g., http module reads network_access, provider reads requires_provider).
// 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 {
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)
// 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
}

View File

@@ -8820,6 +8820,74 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$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) ─────────────────────────────────
/api/v1/connections:
get:
@@ -12266,6 +12334,22 @@ components:
updated_at:
type: string
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:
type: object
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"

View File

@@ -66,6 +66,7 @@ type Stores struct {
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
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)
}
// =========================================
// 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.

View 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()
}

View File

@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(),
}
}

View 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()
}

View File

@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(),
}
}

View File

@@ -500,10 +500,17 @@ export function createDomains(restClient) {
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
settings: (id) => rc.get(`/api/v1/admin/packages/${id}/settings`),
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'),
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: {
list: () => rc.get('/api/v1/admin/tasks'),
run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),