@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.
- package/LICENSE +176 -0
- package/README.md +82 -1
- package/dist/agent-repo.d.ts +93 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +189 -0
- package/dist/asset-service.d.ts +104 -0
- package/dist/asset-service.js +267 -0
- package/dist/committed-source-tree.d.ts +10 -0
- package/dist/committed-source-tree.js +35 -0
- package/dist/credential-push.d.ts +33 -0
- package/dist/credential-push.js +109 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +365 -0
- package/dist/hub-session-lookups.d.ts +135 -0
- package/dist/hub-session-lookups.js +663 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +87 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +24 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1829 -0
- package/dist/repo-store/subscribe-kind.d.ts +56 -0
- package/dist/repo-store/subscribe-kind.js +144 -0
- package/dist/repo-store/types.d.ts +571 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +416 -0
- package/dist/session-service.js +1191 -0
- package/dist/sidecar-allocation/contracts.d.ts +78 -0
- package/dist/sidecar-allocation/contracts.js +21 -0
- package/dist/sidecar-allocation/index.d.ts +4 -0
- package/dist/sidecar-allocation/index.js +3 -0
- package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
- package/dist/sidecar-allocation/placement-policy.js +21 -0
- package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
- package/dist/sidecar-allocation/plugin-registry.js +37 -0
- package/dist/sidecar-allocation/reconciler.d.ts +42 -0
- package/dist/sidecar-allocation/reconciler.js +431 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +292 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-allocation-service.d.ts +58 -0
- package/dist/workflow-allocation-service.js +239 -0
- package/dist/workflow-closure-resolution.d.ts +106 -0
- package/dist/workflow-closure-resolution.js +123 -0
- package/dist/workflow-definition-ensure.d.ts +24 -0
- package/dist/workflow-definition-ensure.js +75 -0
- package/dist/workflow-dispatch-service.d.ts +40 -0
- package/dist/workflow-dispatch-service.js +146 -0
- package/dist/workflow-dispatch-settlement.d.ts +29 -0
- package/dist/workflow-dispatch-settlement.js +140 -0
- package/dist/workflow-kind.d.ts +37 -0
- package/dist/workflow-kind.js +310 -0
- package/dist/workflow-probe-gate.d.ts +214 -0
- package/dist/workflow-probe-gate.js +207 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +440 -0
- package/dist/workflow-run-kind.js +2916 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +153 -0
- package/dist/workflow-run-restore.d.ts +15 -0
- package/dist/workflow-run-restore.js +26 -0
- package/dist/workflow-source-closure.d.ts +35 -0
- package/dist/workflow-source-closure.js +342 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +222 -0
- package/dist/ws/sidecar-events.js +72 -0
- package/dist/ws/sidecar-handler.d.ts +305 -0
- package/dist/ws/sidecar-handler.js +2537 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
- package/dist/ws/sidecar-token-authenticator.js +81 -0
- package/package.json +35 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- 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>;
|