@intx/hub-sessions 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,189 @@
1
+ import { type } from "arktype";
2
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
3
+ import { UserPrincipal, } from "./repo-store/index.js";
4
+ const SidecarPrincipal = type({
5
+ kind: "'sidecar'",
6
+ agentId: "string",
7
+ });
8
+ export const AGENT_STATE_DEPLOY_REF = "refs/heads/deploy";
9
+ // Mirror of the sidecar's agent-state write surface. The isogit
10
+ // ContextStore (`packages/storage-isogit/src/store.ts`) writes exactly
11
+ // these top-level entries: `turns.jsonl`, `prompt.jsonl`,
12
+ // `response.jsonl`, `manifest.jsonl`, `metadata.json`, the `tool-output/`
13
+ // blob directory, and the `state/` directory holding `state/audit/` and
14
+ // `state/errors/`. `.gitignore` is seeded once by `initSidecarRepo`.
15
+ // Adding a new top-level write on the sidecar side requires adding the
16
+ // entry here in the same change — receivePack will silently
17
+ // `path_violation` the push otherwise. The receivePack path
18
+ // (`pack-receive.ts:validateTree`) walks every top-level tree entry —
19
+ // files and directories alike — through this allowlist; widening it
20
+ // to anything unowned by the sidecar's writer would let a malicious
21
+ // pack smuggle non-state content into the repo.
22
+ const ALLOWED_STATE_TOP_LEVEL = new Set([
23
+ "state",
24
+ ".gitignore",
25
+ "turns.jsonl",
26
+ "prompt.jsonl",
27
+ "response.jsonl",
28
+ "manifest.jsonl",
29
+ "metadata.json",
30
+ "tool-output",
31
+ ]);
32
+ const ALLOWED_DEPLOY_TOP_LEVEL = new Set(["deploy", ".gitignore"]);
33
+ export const agentStateKindHandler = {
34
+ kind: "agent-state",
35
+ directoryPrefix: "agents",
36
+ validatePush: ({ ref, topLevelTreePaths }) => {
37
+ // The deploy ref carries hub-authored prompt content under
38
+ // `deploy/`; every other ref carries sidecar-pushed agent state
39
+ // and must stay confined to the state-bearing allowlist.
40
+ const allowed = ref === AGENT_STATE_DEPLOY_REF
41
+ ? ALLOWED_DEPLOY_TOP_LEVEL
42
+ : ALLOWED_STATE_TOP_LEVEL;
43
+ const offender = topLevelTreePaths.find((p) => !allowed.has(p));
44
+ if (offender !== undefined) {
45
+ return {
46
+ ok: false,
47
+ reason: `tree contains disallowed top-level path: ${offender}`,
48
+ };
49
+ }
50
+ if (!topLevelTreePaths.some((p) => p !== ".gitignore")) {
51
+ return {
52
+ ok: false,
53
+ reason: "tree must include at least one state-bearing top-level entry",
54
+ };
55
+ }
56
+ return { ok: true };
57
+ },
58
+ onRefUpdated: () => {
59
+ // No consumer at this kind today; the substrate's hook surface is
60
+ // uniform across kinds and future kinds will use it.
61
+ },
62
+ };
63
+ export const agentStateAuthorize = (principal, repoId, ref, action) => {
64
+ if (principal.kind === "hub") {
65
+ // Full access at this kind. Hub-side reads (getDeployRef,
66
+ // createDeployPack) and writes (writeDeployTree) all flow through
67
+ // here, so changing this branch tightens behavior in non-obvious
68
+ // places.
69
+ return { allowed: true };
70
+ }
71
+ if (principal.kind === "sidecar") {
72
+ const parsed = SidecarPrincipal(principal);
73
+ if (parsed instanceof type.errors) {
74
+ return {
75
+ allowed: false,
76
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
77
+ };
78
+ }
79
+ if (repoId.kind !== "agent-state" || repoId.id !== parsed.agentId) {
80
+ return {
81
+ allowed: false,
82
+ reason: `sidecar ${parsed.agentId} cannot access ${repoId.kind}/${repoId.id}`,
83
+ };
84
+ }
85
+ switch (action) {
86
+ case "receivePack":
87
+ case "resolveRef":
88
+ return { allowed: true };
89
+ case "createPack":
90
+ if (ref !== AGENT_STATE_DEPLOY_REF) {
91
+ return {
92
+ allowed: false,
93
+ reason: `sidecar may only fetch ${AGENT_STATE_DEPLOY_REF}, not ${ref}`,
94
+ };
95
+ }
96
+ return { allowed: true };
97
+ case "init":
98
+ return {
99
+ allowed: false,
100
+ reason: "init is not authorize-gated for agent-state",
101
+ };
102
+ case "writeTree":
103
+ return {
104
+ allowed: false,
105
+ reason: `action ${action} is hub-only for agent-state`,
106
+ };
107
+ default: {
108
+ const _exhaustive = action;
109
+ return {
110
+ allowed: false,
111
+ reason: `unhandled action: ${String(_exhaustive)}`,
112
+ };
113
+ }
114
+ }
115
+ }
116
+ if (principal.kind === "user") {
117
+ // The route layer has already pre-resolved the grant verdict and
118
+ // attached it as `authz`. The substrate does NOT re-query the
119
+ // grant store here; it (a) checks the bearer-token's claims
120
+ // bound the requested (ref, action) and have not expired, and
121
+ // (b) sanity-checks that the pre-resolved verdict targets this
122
+ // exact resource and grant verb. Both gates must pass before the
123
+ // verdict's `effect` is honoured.
124
+ const parsed = UserPrincipal(principal);
125
+ if (parsed instanceof type.errors) {
126
+ return {
127
+ allowed: false,
128
+ reason: `user principal is malformed: ${parsed.summary}`,
129
+ };
130
+ }
131
+ if (repoId.kind !== "agent-state") {
132
+ return {
133
+ allowed: false,
134
+ reason: `user authorize received non-agent-state repo ${repoId.kind}/${repoId.id}`,
135
+ };
136
+ }
137
+ if (!parsed.tokenClaims.actions.includes(action)) {
138
+ return {
139
+ allowed: false,
140
+ reason: `token does not grant action ${action}`,
141
+ };
142
+ }
143
+ // `ref === "*"` is the substrate's sentinel for the bulk read
144
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
145
+ // layer's responsibility, so the bulk read is gated on action and
146
+ // expiry alone.
147
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
148
+ return {
149
+ allowed: false,
150
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
151
+ };
152
+ }
153
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
154
+ return {
155
+ allowed: false,
156
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
157
+ };
158
+ }
159
+ const expectedResource = `agent-state:${repoId.id}`;
160
+ if (parsed.authz.resource !== expectedResource) {
161
+ return {
162
+ allowed: false,
163
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
164
+ };
165
+ }
166
+ const expectedGrantVerb = repoActionToGrantVerb(action);
167
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
168
+ return {
169
+ allowed: false,
170
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
171
+ };
172
+ }
173
+ if (parsed.authz.effect === "allow") {
174
+ return { allowed: true };
175
+ }
176
+ return {
177
+ allowed: false,
178
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
179
+ };
180
+ }
181
+ // Fail closed on any kind not handled above. The tenant-level
182
+ // `workflow` principal kind (`@intx/types` principalKinds) is a
183
+ // grant owner, not an agent-state bearer, and never carries an
184
+ // agent-state repo push here -- so it is intentionally left denied.
185
+ return {
186
+ allowed: false,
187
+ reason: `unknown principal kind: ${principal.kind}`,
188
+ };
189
+ };
@@ -0,0 +1,104 @@
1
+ import { type DB } from "@intx/db";
2
+ import type { RepoKind } from "@intx/types/sidecar";
3
+ import type { InitRepoOpts, Principal, RepoStore, TreeContent } from "./repo-store/index.js";
4
+ export type Asset = {
5
+ id: string;
6
+ tenantId: string;
7
+ kind: RepoKind;
8
+ name: string;
9
+ displayName: string | null;
10
+ creatorPrincipalId: string | null;
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ };
14
+ export type CreateAssetParams = {
15
+ tenantId: string;
16
+ /** Accepted kinds: "skill", "package-registry", "workflow".
17
+ * "agent-state" is rejected because those repos are managed by the
18
+ * agent lifecycle, not the asset service. */
19
+ kind: RepoKind;
20
+ name: string;
21
+ displayName?: string;
22
+ creatorPrincipalId?: string;
23
+ /** Forwarded verbatim to `repoStore.initRepo`. Lets the REST route
24
+ * layer ship a per-asset `.gitignore` body (OS/editor cruft + build
25
+ * artefacts + `keys/`) in the genesis tree without the service
26
+ * encoding policy for any one consumer. When omitted, the substrate
27
+ * default body applies. */
28
+ initOpts?: InitRepoOpts;
29
+ };
30
+ export type PopulateAssetParams = {
31
+ assetId: string;
32
+ ref: string;
33
+ tree: TreeContent;
34
+ /** The principal authorized to write the kind. The substrate's
35
+ * authorize gate uses this; the kind handler also relies on it
36
+ * (e.g. skillAuthorize only permits `kind: "hub"` writes). */
37
+ principal: Principal;
38
+ };
39
+ export interface AssetService {
40
+ createAsset(params: CreateAssetParams): Promise<Asset>;
41
+ populateAsset(params: PopulateAssetParams): Promise<{
42
+ commitSha: string;
43
+ }>;
44
+ /**
45
+ * In-process blob read. Resolves the asset's row, then reads the blob
46
+ * at `path` from the commit pointed to by `ref` (defaults to
47
+ * `refs/heads/main`). Throws `AssetServiceError("not_found", ...)`
48
+ * when the asset, ref, or path do not exist.
49
+ */
50
+ readAssetBlob(params: ReadAssetBlobParams): Promise<Uint8Array>;
51
+ /**
52
+ * Enumerate the immediate child entry names at `dir` in the asset's
53
+ * commit tree. `dir` is a repo-root-relative POSIX directory path
54
+ * (no trailing slash, no leading slash); pass the empty string to
55
+ * list the root. Throws `AssetServiceError("not_found", ...)` when
56
+ * the asset or ref do not exist, or when `dir` is not a directory.
57
+ */
58
+ listAssetBlobs(params: ListAssetBlobsParams): Promise<string[]>;
59
+ }
60
+ export type ReadAssetBlobParams = {
61
+ assetId: string;
62
+ path: string;
63
+ /** Defaults to `refs/heads/main`. */
64
+ ref?: string;
65
+ };
66
+ export type ListAssetBlobsParams = {
67
+ assetId: string;
68
+ /** Empty string lists the tree root. */
69
+ dir: string;
70
+ /** Defaults to `refs/heads/main`. */
71
+ ref?: string;
72
+ };
73
+ /**
74
+ * Default ref the read API resolves against when callers do not
75
+ * supply one. The smart-HTTP route and the REST tarball routes both
76
+ * push to this ref so it carries the published-asset HEAD.
77
+ */
78
+ export declare const DEFAULT_ASSET_REF = "refs/heads/main";
79
+ /** Discriminator for AssetServiceError variants. Lets callers branch
80
+ * without instanceof gymnastics across the different error subclasses. */
81
+ export type AssetServiceErrorReason = "unsupported_kind" | "duplicate_asset" | "duplicate_attachment" | "invalid_name" | "invalid_reference" | "name_reserved" | "not_found" | "path_violation";
82
+ export declare const ASSET_NAME_PATTERN: RegExp;
83
+ export declare class AssetServiceError extends Error {
84
+ readonly reason: AssetServiceErrorReason;
85
+ constructor(reason: AssetServiceErrorReason, message: string, cause?: unknown);
86
+ }
87
+ export declare function createAssetService(deps: {
88
+ db: DB["db"];
89
+ repoStore: RepoStore;
90
+ /**
91
+ * Names that the session service treats as configured HTTP registries
92
+ * when assembling the per-launch package-registry map. A
93
+ * `package-registry` asset whose name collides with one of these
94
+ * shadows the corresponding HTTP registry at session-launch time
95
+ * (asset wins on name collision). Creating such an asset is almost
96
+ * always an operator footgun — silently rerouting the public npm
97
+ * registry traffic to a tenant-owned asset — so reject the creation
98
+ * up front rather than letting the misroute surface later. The host
99
+ * threads in its `httpRegistries` keys; the asset service holds them
100
+ * statically because the registry config is loaded at hub boot and
101
+ * does not change at runtime.
102
+ */
103
+ reservedPackageRegistryNames?: ReadonlySet<string>;
104
+ }): AssetService;
@@ -0,0 +1,267 @@
1
+ // In-process service for creating and populating skill-asset repos.
2
+ //
3
+ // Three responsibilities are layered here, mirroring the substrate's
4
+ // own layering (DB row, repo bookkeeping, content validation):
5
+ //
6
+ // createAsset inserts the asset row and initializes an empty
7
+ // skill-kind repo via RepoStore.initRepo.
8
+ // populateAsset drives RepoStore.writeTree, which runs the kind
9
+ // handler's validatePush before advancing the ref.
10
+ // Content rejections surface as AssetValidationError.
11
+ //
12
+ // The factory is closure-based to match createAgentRepoStore and
13
+ // createRepoStore. There is no class because there is no per-instance
14
+ // mutable state — every method is a pure function over the deps.
15
+ import fs from "node:fs";
16
+ import { eq } from "drizzle-orm";
17
+ import git from "isomorphic-git";
18
+ import { pgErrorCode, PG_UNIQUE_VIOLATION } from "@intx/db";
19
+ import { asset as assetTable } from "@intx/db/schema";
20
+ import { generateId } from "@intx/hub-common";
21
+ import { getLogger } from "@intx/log";
22
+ const logger = getLogger(["hub-sessions", "asset-service"]);
23
+ /**
24
+ * Default ref the read API resolves against when callers do not
25
+ * supply one. The smart-HTTP route and the REST tarball routes both
26
+ * push to this ref so it carries the published-asset HEAD.
27
+ */
28
+ export const DEFAULT_ASSET_REF = "refs/heads/main";
29
+ // Asset names become the default workspace mountpath segment at
30
+ // session start (`skills/<asset.name>/`). The mountpath segment
31
+ // validator in applyAssetPack rejects anything outside a safe
32
+ // character set; validate at the createAsset boundary so a bad name
33
+ // fails at creation time rather than at materialization time. Names
34
+ // must be lowercase-kebab: lowercase letters, digits, hyphens, with
35
+ // no leading or trailing hyphen. Exported so a caller deriving a name
36
+ // (the agent-fold materializer) asserts the shape at its own boundary
37
+ // rather than discovering a violation three layers in as a generic
38
+ // `invalid_name`.
39
+ export const ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
40
+ export class AssetServiceError extends Error {
41
+ reason;
42
+ constructor(reason, message, cause) {
43
+ super(message, cause === undefined ? undefined : { cause });
44
+ this.name = "AssetServiceError";
45
+ this.reason = reason;
46
+ }
47
+ }
48
+ /**
49
+ * Reject malformed `dir` arguments at the `listAssetBlobs` boundary
50
+ * before any tree walk begins. The empty string is the documented
51
+ * "list the root" form; anything else must be a relative path with
52
+ * no leading slash, no trailing slash, no `..` segment, and no empty
53
+ * segments (no `//`). This mirrors the same rules that
54
+ * `validateClearPrefix` in the repo-store enforces on `clearPrefix`
55
+ * arguments, surfaced here as a path-violation error so the caller
56
+ * sees a structured rejection instead of a confusing "no directory
57
+ * at /tarballs/" miss when an absolute or `..`-bearing input slips
58
+ * past upstream validation.
59
+ */
60
+ function assertWellFormedListDir(dir) {
61
+ if (dir === "")
62
+ return;
63
+ if (dir === "/" || dir.startsWith("/")) {
64
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir must be a relative path or "" for root; got ${JSON.stringify(dir)}`);
65
+ }
66
+ if (dir.endsWith("/")) {
67
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir must not end with a trailing slash; got ${JSON.stringify(dir)}`);
68
+ }
69
+ const segments = dir.split("/");
70
+ for (const segment of segments) {
71
+ if (segment === "") {
72
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir contains an empty segment ("//"): ${JSON.stringify(dir)}`);
73
+ }
74
+ if (segment === "..") {
75
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir contains a ".." segment: ${JSON.stringify(dir)}`);
76
+ }
77
+ }
78
+ }
79
+ function rowToAsset(row) {
80
+ // The schema stores `kind` as plain text. RepoKind is an arktype
81
+ // enum; narrow by exhaustive check so an out-of-band kind value
82
+ // loudly fails rather than silently mistypes the returned shape.
83
+ let narrowed;
84
+ switch (row.kind) {
85
+ case "agent-state":
86
+ narrowed = "agent-state";
87
+ break;
88
+ case "skill":
89
+ narrowed = "skill";
90
+ break;
91
+ case "package-registry":
92
+ narrowed = "package-registry";
93
+ break;
94
+ case "workflow":
95
+ narrowed = "workflow";
96
+ break;
97
+ default:
98
+ throw new Error(`asset row ${row.id} has unknown kind ${JSON.stringify(row.kind)}`);
99
+ }
100
+ return {
101
+ id: row.id,
102
+ tenantId: row.tenantId,
103
+ kind: narrowed,
104
+ name: row.name,
105
+ displayName: row.displayName,
106
+ creatorPrincipalId: row.creatorPrincipalId,
107
+ createdAt: row.createdAt,
108
+ updatedAt: row.updatedAt,
109
+ };
110
+ }
111
+ export function createAssetService(deps) {
112
+ const { db, repoStore } = deps;
113
+ const reservedPackageRegistryNames = deps.reservedPackageRegistryNames ?? new Set();
114
+ async function createAsset(params) {
115
+ if (params.kind !== "skill" &&
116
+ params.kind !== "package-registry" &&
117
+ params.kind !== "workflow") {
118
+ throw new AssetServiceError("unsupported_kind", `createAsset rejects kind ${JSON.stringify(params.kind)}: the asset service handles "skill", "package-registry", and "workflow" assets; other repo kinds are managed by their respective subsystems`);
119
+ }
120
+ if (!ASSET_NAME_PATTERN.test(params.name)) {
121
+ throw new AssetServiceError("invalid_name", `createAsset rejects name ${JSON.stringify(params.name)}: must be lowercase-kebab (letters, digits, hyphens; no leading or trailing hyphen)`);
122
+ }
123
+ if (params.kind === "package-registry" &&
124
+ reservedPackageRegistryNames.has(params.name)) {
125
+ // Session-launch builds the per-launch registry map by iterating
126
+ // package-registry assets first and HTTP registries second, with
127
+ // an asset-wins-on-collision rule. A `package-registry` asset
128
+ // named after a configured HTTP registry would silently shadow
129
+ // that registry for every session that resolves through this
130
+ // tenant — almost certainly an operator misconfig, not an
131
+ // intended override. Reject the creation so the operator sees
132
+ // the collision at intent time instead of debugging an
133
+ // unexpected reroute later.
134
+ throw new AssetServiceError("name_reserved", `createAsset rejects name ${JSON.stringify(params.name)}: it collides with a configured HTTP registry of the same name and would silently shadow it at session launch`);
135
+ }
136
+ const id = generateId("asset");
137
+ const now = new Date();
138
+ const insertRow = {
139
+ id,
140
+ tenantId: params.tenantId,
141
+ kind: params.kind,
142
+ name: params.name,
143
+ displayName: params.displayName ?? null,
144
+ creatorPrincipalId: params.creatorPrincipalId ?? null,
145
+ createdAt: now,
146
+ updatedAt: now,
147
+ };
148
+ // Init the repo before the row insert so a repo-init failure leaves
149
+ // no orphan row in the database. initRepo is idempotent and the
150
+ // generated id is locally unique, so a follow-up failure of the row
151
+ // insert (duplicate, FK violation, etc.) leaves at worst an empty
152
+ // unreferenced repo directory — harmless and reused on retry of a
153
+ // logically identical asset. The asset-service db handle does not
154
+ // expose transactions in the current narrowing, so this ordering is
155
+ // the safest cross-cutting fix without widening the dep surface.
156
+ //
157
+ // Note: each failed insert with a fresh `id` does leave its own
158
+ // orphan repo directory on disk. The directories carry no asset
159
+ // row and no traffic, so they are inert; a periodic GC walker
160
+ // that drops on-disk repos with no matching row is a follow-up.
161
+ await repoStore.initRepo({ kind: params.kind, id }, params.initOpts);
162
+ let inserted;
163
+ try {
164
+ const rows = await db.insert(assetTable).values(insertRow).returning();
165
+ const row = rows[0];
166
+ if (row === undefined) {
167
+ throw new Error("insert into asset returned no rows");
168
+ }
169
+ inserted = row;
170
+ }
171
+ catch (err) {
172
+ if (pgErrorCode(err) === PG_UNIQUE_VIOLATION) {
173
+ throw new AssetServiceError("duplicate_asset", `asset (tenantId=${params.tenantId}, kind=${params.kind}, name=${params.name}) already exists`, err);
174
+ }
175
+ throw err;
176
+ }
177
+ logger.debug `created asset ${id} (kind=${params.kind}, tenant=${params.tenantId}, name=${params.name})`;
178
+ return rowToAsset(inserted);
179
+ }
180
+ async function populateAsset(params) {
181
+ // The asset row carries `kind`. We must read it before writing so
182
+ // the RepoId is shaped correctly; without it, callers could write
183
+ // against the wrong kind handler.
184
+ const row = await db.query.asset.findFirst({
185
+ where: eq(assetTable.id, params.assetId),
186
+ });
187
+ if (row === undefined) {
188
+ throw new AssetServiceError("not_found", `populateAsset: asset ${params.assetId} not found`);
189
+ }
190
+ const assetRow = rowToAsset(row);
191
+ try {
192
+ return await repoStore.writeTree(params.principal, { kind: assetRow.kind, id: assetRow.id }, params.ref, params.tree);
193
+ }
194
+ catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ if (msg.startsWith("path_violation:")) {
197
+ throw new AssetServiceError("path_violation", msg, err);
198
+ }
199
+ throw err;
200
+ }
201
+ }
202
+ async function resolveAssetRowOrThrow(assetId, label) {
203
+ const row = await db.query.asset.findFirst({
204
+ where: eq(assetTable.id, assetId),
205
+ });
206
+ if (row === undefined) {
207
+ throw new AssetServiceError("not_found", `${label}: asset ${assetId} not found`);
208
+ }
209
+ return rowToAsset(row);
210
+ }
211
+ async function resolveCommitTreeOid(asset, ref, label) {
212
+ const dir = repoStore.getRepoDir({ kind: asset.kind, id: asset.id });
213
+ let commitSha;
214
+ try {
215
+ commitSha = await git.resolveRef({ fs, dir, ref });
216
+ }
217
+ catch (cause) {
218
+ throw new AssetServiceError("not_found", `${label}: asset ${asset.id} ref ${ref} not resolvable`, cause);
219
+ }
220
+ const { commit } = await git.readCommit({ fs, dir, oid: commitSha });
221
+ return { dir, treeOid: commit.tree };
222
+ }
223
+ async function readAssetBlob(params) {
224
+ const ref = params.ref ?? DEFAULT_ASSET_REF;
225
+ const asset = await resolveAssetRowOrThrow(params.assetId, "readAssetBlob");
226
+ const { dir, treeOid } = await resolveCommitTreeOid(asset, ref, "readAssetBlob");
227
+ try {
228
+ const { blob } = await git.readBlob({
229
+ fs,
230
+ dir,
231
+ oid: treeOid,
232
+ filepath: params.path,
233
+ });
234
+ return blob;
235
+ }
236
+ catch (cause) {
237
+ throw new AssetServiceError("not_found", `readAssetBlob: asset ${params.assetId} has no blob at ${JSON.stringify(params.path)} on ref ${ref}`, cause);
238
+ }
239
+ }
240
+ async function listAssetBlobs(params) {
241
+ assertWellFormedListDir(params.dir);
242
+ const ref = params.ref ?? DEFAULT_ASSET_REF;
243
+ const asset = await resolveAssetRowOrThrow(params.assetId, "listAssetBlobs");
244
+ const { dir, treeOid } = await resolveCommitTreeOid(asset, ref, "listAssetBlobs");
245
+ if (params.dir === "") {
246
+ const { tree } = await git.readTree({ fs, dir, oid: treeOid });
247
+ return tree.filter((e) => e.type === "blob").map((e) => e.path);
248
+ }
249
+ let currentOid = treeOid;
250
+ for (const segment of params.dir.split("/")) {
251
+ const { tree } = await git.readTree({ fs, dir, oid: currentOid });
252
+ const entry = tree.find((e) => e.path === segment);
253
+ if (entry === undefined || entry.type !== "tree") {
254
+ throw new AssetServiceError("not_found", `listAssetBlobs: asset ${params.assetId} has no directory at ${JSON.stringify(params.dir)} on ref ${ref}`);
255
+ }
256
+ currentOid = entry.oid;
257
+ }
258
+ const { tree } = await git.readTree({ fs, dir, oid: currentOid });
259
+ return tree.filter((e) => e.type === "blob").map((e) => e.path);
260
+ }
261
+ return {
262
+ createAsset,
263
+ populateAsset,
264
+ readAssetBlob,
265
+ listAssetBlobs,
266
+ };
267
+ }
@@ -0,0 +1,10 @@
1
+ import type { CommittedReads } from "./repo-store/types.js";
2
+ import type { SourceTreeReads } from "./workflow-source-closure.js";
3
+ /**
4
+ * Wrap `reads` so `readBlob(path)` resolves a repo-relative POSIX path to its
5
+ * blob: list the path's parent directory, match the final segment as a `blob`
6
+ * entry, then read by its object id. `listDir` and `treeOid` pass straight
7
+ * through -- both handles already speak repo-relative paths and return the same
8
+ * shapes.
9
+ */
10
+ export declare function committedReadsToSourceTree(reads: CommittedReads): SourceTreeReads;
@@ -0,0 +1,35 @@
1
+ // Adapts a repo-store `CommittedReads` handle (reads pinned to a commit,
2
+ // addressed by object id) to the `SourceTreeReads` shape the source-closure
3
+ // resolver consumes (reads addressed by repo-relative path). This bridge lives
4
+ // at the caller layer, not inside the resolver: the resolver stays agnostic of
5
+ // where its tree bytes come from (a test fake, this store, a future one), and
6
+ // the repo-store stays unaware of the closure interface. Only a layer that
7
+ // composes both types -- the install/deploy glue's caller -- owns the join.
8
+ /**
9
+ * Wrap `reads` so `readBlob(path)` resolves a repo-relative POSIX path to its
10
+ * blob: list the path's parent directory, match the final segment as a `blob`
11
+ * entry, then read by its object id. `listDir` and `treeOid` pass straight
12
+ * through -- both handles already speak repo-relative paths and return the same
13
+ * shapes.
14
+ */
15
+ export function committedReadsToSourceTree(reads) {
16
+ return {
17
+ async readBlob(blobPath) {
18
+ // The parent of a top-level path (no slash) is the root tree, which
19
+ // `CommittedReads.listDir` addresses with the empty string.
20
+ const slash = blobPath.lastIndexOf("/");
21
+ const parentDir = slash === -1 ? "" : blobPath.slice(0, slash);
22
+ const name = slash === -1 ? blobPath : blobPath.slice(slash + 1);
23
+ const entries = await reads.listDir(parentDir);
24
+ // Match on the blob type so a path naming a directory or a gitlink fails
25
+ // loud here rather than handing a tree/commit oid to `readBlobByOid`.
26
+ const entry = entries.find((e) => e.name === name && e.type === "blob");
27
+ if (entry === undefined) {
28
+ throw new Error(`committedReadsToSourceTree: no blob at ${JSON.stringify(blobPath)}`);
29
+ }
30
+ return reads.readBlobByOid(entry.oid);
31
+ },
32
+ listDir: (dir) => reads.listDir(dir),
33
+ treeOid: (dir) => reads.treeOid(dir),
34
+ };
35
+ }
@@ -0,0 +1,33 @@
1
+ import type { DB } from "@intx/db";
2
+ import type { CredentialCipher } from "@intx/types";
3
+ import type { SidecarRouter } from "./ws/sidecar-handler.js";
4
+ /**
5
+ * Re-resolve a single running instance's inference sources from the catalog
6
+ * (the definition's model requirements plus the invoker preferences persisted
7
+ * on the instance) and push the ordered list to its sidecar. The head of the
8
+ * catalog-priority-ordered list is the active default; the tail is the
9
+ * failover chain.
10
+ *
11
+ * No-op when the instance resolves to no launchable source — the resolver's
12
+ * own logger is the signal for why.
13
+ */
14
+ export declare function pushInstanceSourceUpdate(db: DB["db"], sidecarRouter: Pick<SidecarRouter, "sendSourcesUpdate">, instance: {
15
+ address: string;
16
+ definitionId: string;
17
+ tenantId: string;
18
+ modelPreferences: unknown;
19
+ }, credentialCipher?: CredentialCipher): Promise<void>;
20
+ /**
21
+ * After a credential secret is rotated, re-resolve every running instance in
22
+ * the tenant against the catalog and push the updates. A rotated secret flows
23
+ * through because resolution dereferences the provider's credential reference
24
+ * to the current secret.
25
+ */
26
+ export declare function pushSourceUpdates(db: DB["db"], sidecarRouter: SidecarRouter, tenantId: string, credentialCipher?: CredentialCipher): Promise<void>;
27
+ /**
28
+ * After a catalog edit in a tenant, re-resolve and push to every running
29
+ * instance in that tenant AND its descendants. Descendants inherit the
30
+ * edited tenant's catalog, so a change there (a disabled provider, a new
31
+ * offering, a price update) alters their resolved sources too.
32
+ */
33
+ export declare function pushSourceUpdatesSubtree(db: DB["db"], sidecarRouter: SidecarRouter, tenantId: string, credentialCipher?: CredentialCipher): Promise<void>;