anbaric-cloud-hosting 1.68.3 → 1.70.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.68.3",
3
+ "version": "1.70.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.68.3",
22
- "anbaric-plugins": "^1.68.3",
23
- "anbaric-tsapi": "^1.68.3",
24
- "anbaric-web": "^1.68.3",
21
+ "anbaric-data-store": "^1.70.0",
22
+ "anbaric-plugins": "^1.70.0",
23
+ "anbaric-tsapi": "^1.70.0",
24
+ "anbaric-web": "^1.70.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.68.3",
32
- "anbaric-state-machine": "^1.68.3",
31
+ "anbaric-impl-cloud": "^1.70.0",
32
+ "anbaric-state-machine": "^1.70.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -3,7 +3,7 @@ import {mkdir, readFile, rm, symlink, writeFile} from "node:fs/promises";
3
3
  import {join} from "node:path";
4
4
  import {adminPing} from "../app-admin/adminPing";
5
5
  import {DocGenerator} from "../docs/DocGenerator";
6
- import {AppSize, BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
6
+ import {AppSize, BuildLayer, DeploymentStatus, DeploymentSummary, DeployOptions} from "./BuildLayer";
7
7
 
8
8
  type Deployment = {
9
9
  appName : string,
@@ -14,6 +14,8 @@ type Deployment = {
14
14
  adminPort : number,
15
15
  consumerPort : number,
16
16
  log : Array<string>,
17
+ // Whether this deploy keeps the version it replaces up until it answers.
18
+ zeroDowntime : boolean,
17
19
  process? : ChildProcess,
18
20
  replaces? : Deployment,
19
21
  draining? : { inFlight : number, since : number },
@@ -47,7 +49,7 @@ abstract class BaseBuildLayer implements BuildLayer {
47
49
 
48
50
  constructor(protected appsDir : string, private consumerPortBase : number = 8800,
49
51
  private probe : Probe = adminProbe,
50
- private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS,
52
+ protected livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS,
51
53
  private drainTimeoutMs : number = DRAIN_TIMEOUT_MS,
52
54
  private drainPollIntervalMs : number = DRAIN_POLL_INTERVAL_MS) {}
53
55
 
@@ -63,7 +65,7 @@ abstract class BaseBuildLayer implements BuildLayer {
63
65
 
64
66
  protected async rehydrate() : Promise<void> {}
65
67
 
66
- deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary {
68
+ deploy(appName : string, appPort : number, tarball : Buffer, options : DeployOptions = {}) : DeploymentSummary {
67
69
  const existing = this.deployments.get(appName);
68
70
 
69
71
  const deployment : Deployment = {
@@ -76,6 +78,7 @@ abstract class BaseBuildLayer implements BuildLayer {
76
78
  adminPort: APP_ADMIN_PORT,
77
79
  consumerPort: existing?.consumerPort ?? this.consumerPortBase + this.nextAppIndex++,
78
80
  log: [],
81
+ zeroDowntime: options.zeroDowntime ?? false,
79
82
  replaces: existing,
80
83
  };
81
84
  this.deployments.set(appName, deployment);
@@ -247,24 +250,43 @@ abstract class BaseBuildLayer implements BuildLayer {
247
250
  const manifest = JSON.parse(await readFile(join(appDir, "package.json"), "utf8"));
248
251
  await this.linkWorkspacePackages(appDir, manifest);
249
252
 
250
- // The version being replaced finishes its work first; the build above
251
- // ran while it carried on, so the drain is the only pause it sees.
253
+ /* The version being replaced finishes its work first; the build above
254
+ ran while it carried on, so the drain is the only pause it sees. A
255
+ layer that can roll in place keeps it up past that, still serving,
256
+ until the new version has taken over. */
252
257
  if (deployment.replaces) {
253
258
  if (deployment.replaces.status === "running") await this.drain(deployment, deployment.replaces);
254
259
  if (!this.isCurrent(deployment)) return;
255
- await this.stop(deployment.replaces);
260
+ if (! this.rollsInPlace(deployment)) await this.stop(deployment.replaces);
256
261
  }
257
262
 
258
263
  if (!this.isCurrent(deployment)) return;
259
264
  await this.start(deployment, appDir, manifest.main);
260
- await this.awaitLive(deployment);
265
+ if (this.rollsInPlace(deployment)) {
266
+ await this.awaitRolled(deployment);
267
+ } else {
268
+ await this.awaitLive(deployment);
269
+ }
261
270
  }
262
271
 
263
272
  protected isCurrent(deployment : Deployment) : boolean {
264
273
  return this.deployments.get(deployment.appName) === deployment && deployment.status === "building";
265
274
  }
266
275
 
267
- private async awaitLive(deployment : Deployment) : Promise<void> {
276
+ /* Whether this deploy replaces the old version without stopping it first.
277
+ Only a layer whose backend can run two versions of one app side by side
278
+ and swap between them says yes; the default cannot, and the option on
279
+ the deploy is then simply not honoured. */
280
+ protected rollsInPlace(_deployment : Deployment) : boolean {
281
+ return false;
282
+ }
283
+
284
+ // Waits for a roll-in-place to finish; a layer that rolls must say how.
285
+ protected async awaitRolled(deployment : Deployment) : Promise<void> {
286
+ await this.awaitLive(deployment);
287
+ }
288
+
289
+ protected async awaitLive(deployment : Deployment) : Promise<void> {
268
290
  const deadline = Date.now() + this.livenessTimeoutMs;
269
291
 
270
292
  while (Date.now() < deadline) {
@@ -27,6 +27,15 @@ type DeploymentSummary = {
27
27
  draining? : Draining,
28
28
  };
29
29
 
30
+ /* How a deploy replaces the running version. By default the old version is
31
+ drained and stopped, then the new one started, so there is a gap. With
32
+ zeroDowntime the new version is brought up beside the old once the drain is
33
+ done and the old is let go only when the new answers - on a build layer that
34
+ can do that; one that cannot ignores it. */
35
+ type DeployOptions = {
36
+ zeroDowntime? : boolean,
37
+ };
38
+
30
39
  interface BuildLayer {
31
40
 
32
41
  /* Moves a running app onto a different size of instance. The app is rolled
@@ -35,7 +44,7 @@ interface BuildLayer {
35
44
  resize(appName : string, size : AppSize) : Promise<DeploymentSummary | undefined>;
36
45
 
37
46
  ensureHydrated() : Promise<void>;
38
- deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
47
+ deploy(appName : string, appPort : number, tarball : Buffer, options? : DeployOptions) : DeploymentSummary;
39
48
  status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
40
49
  list() : Array<DeploymentSummary>;
41
50
  ping(appName : string) : Promise<boolean>;
@@ -61,5 +70,5 @@ interface BuildLayer {
61
70
 
62
71
  }
63
72
 
64
- export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining, AppSize };
73
+ export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining, AppSize, DeployOptions };
65
74
  export { APP_SIZES, isAppSize };
@@ -35,6 +35,10 @@ type FargateBuildLayerOptions = {
35
35
  sqlDatabaseUrlSecretArn? : string,
36
36
  sqlSchema? : string,
37
37
  docGenerator? : DocGenerator,
38
+ /* Put on every task definition and service this layer makes, and from
39
+ there on every task, so the cost of a tenant's apps can be told apart
40
+ on the bill. Empty means untagged. */
41
+ tags? : Record<string, string>,
38
42
  };
39
43
 
40
44
  type AwsClients = {
@@ -55,6 +59,7 @@ const defaultClients = (region : string) : AwsClients => ({
55
59
 
56
60
  const BUILD_TIMEOUT_MS = 900_000;
57
61
  const FARGATE_LIVENESS_TIMEOUT_MS = 420_000;
62
+ const ROLL_POLL_INTERVAL_MS = 5_000;
58
63
 
59
64
  /* What each size is on Fargate, in the units ECS wants. The memory is what
60
65
  the console describes as "2× the RAM"; the CPU is not advertised, only felt. */
@@ -65,6 +70,22 @@ const TASK_SIZES : Record<AppSize, { cpu : string, memory : string }> = {
65
70
 
66
71
  const sizeOfTask = (memory? : string) : AppSize => memory === TASK_SIZES.large.memory ? "large" : "small";
67
72
 
73
+ /* What makes a roll zero-downtime: ECS only retires the old task once the
74
+ new one is healthy, and without a health check "healthy" means "started",
75
+ before the app inside is listening. This asks the app's admin port the
76
+ same `ping` the platform's own probe sends. Only a task with zero-downtime
77
+ deploys carries it, so nothing already running is touched. The start
78
+ period is Fargate's longest, for apps that build at boot. */
79
+ const adminHealthCheck = (adminPort : number) => ({
80
+ command: ["CMD-SHELL", `node -e "const s=require('net').connect(${adminPort},'127.0.0.1');`
81
+ + `s.setTimeout(3000,()=>process.exit(1));s.on('connect',()=>s.write('ping\\n'));`
82
+ + `s.on('data',d=>process.exit(String(d).startsWith('ok')?0:1));s.on('error',()=>process.exit(1))"`],
83
+ interval: 10,
84
+ timeout: 5,
85
+ retries: 3,
86
+ startPeriod: 300,
87
+ });
88
+
68
89
  class FargateBuildLayer extends BaseBuildLayer {
69
90
 
70
91
  private aws : AwsClients;
@@ -135,6 +156,50 @@ class FargateBuildLayer extends BaseBuildLayer {
135
156
  await this.upsertService(deployment, taskDefinition, registryArn);
136
157
  }
137
158
 
159
+ /* ECS can run the new task beside the old one and swap the service over
160
+ once the new one is up, which is what a zero-downtime deploy buys: the
161
+ old version, already drained, keeps answering until then. */
162
+ protected rollsInPlace(deployment : Deployment) : boolean {
163
+ return deployment.zeroDowntime && deployment.replaces !== undefined;
164
+ }
165
+
166
+ /* The roll is done when ECS says so, not when something answers on the
167
+ app's name: during the roll that name resolves to the old task as
168
+ readily as the new, so a probe would call the deploy live too soon.
169
+ ECS reports one deployment, completed, once the old task is gone. */
170
+ protected async awaitRolled(deployment : Deployment) : Promise<void> {
171
+ const deadline = Date.now() + this.livenessTimeoutMs;
172
+
173
+ while (Date.now() < deadline) {
174
+ if (deployment.status !== "building") return;
175
+ if (await this.rolled(deployment.appName)) {
176
+ deployment.status = "running";
177
+ this.log(deployment, "app is live: the service has rolled over to the new version");
178
+ return;
179
+ }
180
+ await new Promise(resolve => setTimeout(resolve, ROLL_POLL_INTERVAL_MS));
181
+ }
182
+
183
+ /* The old version is still serving - ECS keeps it until the new one
184
+ is healthy - so nothing is torn down; the deploy is reported as
185
+ failed and the app's log says why it never came up. */
186
+ deployment.status = "failed";
187
+ this.log(deployment, `the service did not roll over within ${this.livenessTimeoutMs / 1000}s; the previous version is still serving`);
188
+ for (const line of await this.diagnostics(deployment).catch(() => [])) this.log(deployment, ` ${line}`);
189
+ }
190
+
191
+ private async rolled(appName : string) : Promise<boolean> {
192
+ const {services} = await this.aws.ecs.send(new DescribeServicesCommand({
193
+ cluster: this.options.cluster,
194
+ services: [this.serviceNameFor(appName)],
195
+ }));
196
+ const deployments : Array<{ status? : string, rolloutState? : string, runningCount? : number }> = services?.[0]?.deployments ?? [];
197
+ const primary = deployments.find(each => each.status === "PRIMARY");
198
+
199
+ return deployments.length === 1 && primary !== undefined
200
+ && (primary.rolloutState === "COMPLETED" || (primary.rolloutState === undefined && (primary.runningCount ?? 0) > 0));
201
+ }
202
+
138
203
  protected async stop(deployment : Deployment) : Promise<void> {
139
204
  if (this.deployments.get(deployment.appName) !== deployment) return;
140
205
 
@@ -192,6 +257,7 @@ class FargateBuildLayer extends BaseBuildLayer {
192
257
  const registered = await this.aws.ecs.send(new RegisterTaskDefinitionCommand({
193
258
  family: this.serviceNameFor(deployment.appName),
194
259
  requiresCompatibilities: ["FARGATE"],
260
+ ...this.taggedWith(),
195
261
  networkMode: "awsvpc",
196
262
  ...TASK_SIZES[deployment.size],
197
263
  executionRoleArn: this.options.appExecutionRoleArn,
@@ -203,6 +269,7 @@ class FargateBuildLayer extends BaseBuildLayer {
203
269
  // The most Fargate allows between SIGTERM and SIGKILL, so an
204
270
  // app signalled with steps still in hand gets to finish them.
205
271
  stopTimeout: 120,
272
+ ...(deployment.zeroDowntime ? { healthCheck: adminHealthCheck(deployment.adminPort) } : {}),
206
273
  portMappings: [
207
274
  { containerPort: deployment.appPort, protocol: "tcp" },
208
275
  { containerPort: deployment.adminPort, protocol: "tcp" },
@@ -280,6 +347,7 @@ class FargateBuildLayer extends BaseBuildLayer {
280
347
  taskDefinition,
281
348
  desiredCount: 1,
282
349
  forceNewDeployment: true,
350
+ ...this.propagatingTags(),
283
351
  }));
284
352
  return;
285
353
  }
@@ -291,6 +359,8 @@ class FargateBuildLayer extends BaseBuildLayer {
291
359
  taskDefinition,
292
360
  desiredCount: 1,
293
361
  launchType: "FARGATE",
362
+ ...this.taggedWith(),
363
+ ...this.propagatingTags(),
294
364
  networkConfiguration: {
295
365
  awsvpcConfiguration: {
296
366
  subnets: this.options.subnets,
@@ -358,6 +428,7 @@ class FargateBuildLayer extends BaseBuildLayer {
358
428
  appPort,
359
429
  appHost: this.appHostFor(appName),
360
430
  size: sizeOfTask(taskDefinition.memory),
431
+ zeroDowntime: false,
361
432
  adminPort: port("ANBARIC_ADMIN_PORT", 8791),
362
433
  consumerPort: port("ANBARIC_CONSUMER_PORT", 0),
363
434
  log: [],
@@ -368,6 +439,16 @@ class FargateBuildLayer extends BaseBuildLayer {
368
439
  return `anbaric-app-${appName}`;
369
440
  }
370
441
 
442
+ private taggedWith() : { tags? : Array<{ key : string, value : string }> } {
443
+ const entries = Object.entries(this.options.tags ?? {});
444
+ return entries.length === 0 ? {} : { tags: entries.map(([key, value]) => ({ key, value })) };
445
+ }
446
+
447
+ // The task definition's tags become each task's, which is where the cost is.
448
+ private propagatingTags() : { propagateTags? : "TASK_DEFINITION" } {
449
+ return Object.keys(this.options.tags ?? {}).length === 0 ? {} : { propagateTags: "TASK_DEFINITION" };
450
+ }
451
+
371
452
  private buildKeyFor(appName : string) : string {
372
453
  return `builds/${appName}.tar.gz`;
373
454
  }
@@ -0,0 +1,30 @@
1
+ /* What a builder has chosen for an app beyond its code: the things set in
2
+ the console that a redeploy must not forget. Kept by the platform rather
3
+ than inferred from the running infrastructure, so changing one never
4
+ restarts anything. */
5
+ type AppSettings = {
6
+ appName : string,
7
+ /* A deploy brings the new version up beside the old and only then lets
8
+ the old go, instead of stopping the old first. Priced, and off unless
9
+ the builder turns it on. */
10
+ zeroDowntime : boolean,
11
+ };
12
+
13
+ const DEFAULT_SETTINGS : Omit<AppSettings, "appName"> = { zeroDowntime: false };
14
+
15
+ interface AppSettingsStore {
16
+
17
+ // The defaults for an app nobody has configured.
18
+ get(appName : string) : Promise<AppSettings>;
19
+
20
+ set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings>;
21
+
22
+ list() : Promise<Array<AppSettings>>;
23
+
24
+ // Forgotten with the app, so a later app of the same name starts afresh.
25
+ remove(appName : string) : Promise<void>;
26
+
27
+ }
28
+
29
+ export type { AppSettings, AppSettingsStore };
30
+ export { DEFAULT_SETTINGS };
@@ -0,0 +1,27 @@
1
+ import {AppSettings, AppSettingsStore, DEFAULT_SETTINGS} from "./AppSettingsStore";
2
+
3
+ class InMemoryAppSettingsStore implements AppSettingsStore {
4
+
5
+ private settings = new Map<string, AppSettings>();
6
+
7
+ async get(appName : string) : Promise<AppSettings> {
8
+ return { ...(this.settings.get(appName) ?? { appName, ...DEFAULT_SETTINGS }) };
9
+ }
10
+
11
+ async set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings> {
12
+ const updated = { ...(await this.get(appName)), ...settings, appName };
13
+ this.settings.set(appName, updated);
14
+ return { ...updated };
15
+ }
16
+
17
+ async list() : Promise<Array<AppSettings>> {
18
+ return [...this.settings.values()].map(settings => ({ ...settings }));
19
+ }
20
+
21
+ async remove(appName : string) : Promise<void> {
22
+ this.settings.delete(appName);
23
+ }
24
+
25
+ }
26
+
27
+ export { InMemoryAppSettingsStore };
@@ -0,0 +1,31 @@
1
+ import {Skill, SkillDraft, SkillStore} from "./SkillStore";
2
+
3
+ class InMemorySkillStore implements SkillStore {
4
+
5
+ private skills = new Map<string, Skill>();
6
+
7
+ async list() : Promise<Array<Skill>> {
8
+ return [...this.skills.values()]
9
+ .sort((left, right) => left.id.localeCompare(right.id))
10
+ .map(skill => ({ ...skill }));
11
+ }
12
+
13
+ async get(id : string) : Promise<Skill | undefined> {
14
+ const skill = this.skills.get(id);
15
+ return skill && { ...skill };
16
+ }
17
+
18
+ async save(draft : SkillDraft) : Promise<Skill> {
19
+ const now = new Date();
20
+ const saved : Skill = { ...draft, createdAt: this.skills.get(draft.id)?.createdAt ?? now, updatedAt: now };
21
+ this.skills.set(draft.id, saved);
22
+ return { ...saved };
23
+ }
24
+
25
+ async remove(id : string) : Promise<boolean> {
26
+ return this.skills.delete(id);
27
+ }
28
+
29
+ }
30
+
31
+ export { InMemorySkillStore };
@@ -262,6 +262,31 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
262
262
  "CREATE INDEX IF NOT EXISTS audit_records_at ON anbaric_system.audit_records (at)",
263
263
  ),
264
264
 
265
+ /* What a builder has set for an app in the console, kept apart from the
266
+ app's running infrastructure so that changing a setting never restarts
267
+ it. An app with no row has the defaults. */
268
+ statements("007-app-settings",
269
+ `CREATE TABLE IF NOT EXISTS app_settings (
270
+ app_name TEXT PRIMARY KEY,
271
+ zero_downtime BOOLEAN NOT NULL DEFAULT false,
272
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
273
+ )`,
274
+ ),
275
+
276
+ /* Guidance the tenant's builders write for the agents that work on it:
277
+ usage hints say when to reach for a skill, the body says what to do.
278
+ A platform table rather than workflow data, since apps never see it. */
279
+ statements("008-skills",
280
+ `CREATE TABLE IF NOT EXISTS anbaric_system.skills (
281
+ id TEXT PRIMARY KEY,
282
+ title TEXT NOT NULL,
283
+ usage_hints TEXT NOT NULL,
284
+ body TEXT NOT NULL,
285
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
286
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
287
+ )`,
288
+ ),
289
+
265
290
  ];
266
291
 
267
292
  export { PLATFORM_MIGRATIONS };
@@ -0,0 +1,40 @@
1
+ import {Pool} from "pg";
2
+ import {AppSettings, AppSettingsStore, DEFAULT_SETTINGS} from "./AppSettingsStore";
3
+
4
+ const rowToSettings = (row : any) : AppSettings => ({
5
+ appName: row.app_name,
6
+ zeroDowntime: row.zero_downtime,
7
+ });
8
+
9
+ class PostgresAppSettingsStore implements AppSettingsStore {
10
+
11
+ constructor(private pool : Pool) {}
12
+
13
+ async get(appName : string) : Promise<AppSettings> {
14
+ const result = await this.pool.query(
15
+ "SELECT app_name, zero_downtime FROM app_settings WHERE app_name = $1", [appName]);
16
+ return result.rows[0] ? rowToSettings(result.rows[0]) : { appName, ...DEFAULT_SETTINGS };
17
+ }
18
+
19
+ async set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings> {
20
+ const merged = { ...(await this.get(appName)), ...settings };
21
+ const result = await this.pool.query(
22
+ `INSERT INTO app_settings (app_name, zero_downtime) VALUES ($1, $2)
23
+ ON CONFLICT (app_name) DO UPDATE SET zero_downtime = EXCLUDED.zero_downtime, updated_at = now()
24
+ RETURNING app_name, zero_downtime`,
25
+ [appName, merged.zeroDowntime]);
26
+ return rowToSettings(result.rows[0]);
27
+ }
28
+
29
+ async list() : Promise<Array<AppSettings>> {
30
+ const result = await this.pool.query("SELECT app_name, zero_downtime FROM app_settings ORDER BY app_name");
31
+ return result.rows.map(rowToSettings);
32
+ }
33
+
34
+ async remove(appName : string) : Promise<void> {
35
+ await this.pool.query("DELETE FROM app_settings WHERE app_name = $1", [appName]);
36
+ }
37
+
38
+ }
39
+
40
+ export { PostgresAppSettingsStore };
@@ -0,0 +1,47 @@
1
+ import {Pool} from "pg";
2
+ import {Skill, SkillDraft, SkillStore} from "./SkillStore";
3
+
4
+ const COLUMNS = "id, title, usage_hints, body, created_at, updated_at";
5
+
6
+ const rowToSkill = (row : any) : Skill => ({
7
+ id: row.id,
8
+ title: row.title,
9
+ usageHints: row.usage_hints,
10
+ body: row.body,
11
+ createdAt: row.created_at,
12
+ updatedAt: row.updated_at,
13
+ });
14
+
15
+ class PostgresSkillStore implements SkillStore {
16
+
17
+ constructor(private pool : Pool) {}
18
+
19
+ async list() : Promise<Array<Skill>> {
20
+ const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills ORDER BY id`);
21
+ return result.rows.map(rowToSkill);
22
+ }
23
+
24
+ async get(id : string) : Promise<Skill | undefined> {
25
+ const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills WHERE id = $1`, [id]);
26
+ return result.rows[0] && rowToSkill(result.rows[0]);
27
+ }
28
+
29
+ async save(draft : SkillDraft) : Promise<Skill> {
30
+ const result = await this.pool.query(
31
+ `INSERT INTO anbaric_system.skills (id, title, usage_hints, body)
32
+ VALUES ($1, $2, $3, $4)
33
+ ON CONFLICT (id) DO UPDATE
34
+ SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body, updated_at = now()
35
+ RETURNING ${COLUMNS}`,
36
+ [draft.id, draft.title, draft.usageHints, draft.body]);
37
+ return rowToSkill(result.rows[0]);
38
+ }
39
+
40
+ async remove(id : string) : Promise<boolean> {
41
+ const result = await this.pool.query("DELETE FROM anbaric_system.skills WHERE id = $1", [id]);
42
+ return (result.rowCount ?? 0) > 0;
43
+ }
44
+
45
+ }
46
+
47
+ export { PostgresSkillStore };
@@ -0,0 +1,38 @@
1
+ /* A skill is guidance a tenant writes for the agents that build on it: when to
2
+ reach for it (the usage hints, which an agent reads before deciding) and
3
+ what to do (the body, which it reads once it has decided). Kept by the
4
+ platform, managed from the console, and never handed to deployed apps. */
5
+ type Skill = {
6
+ id : string,
7
+ title : string,
8
+ usageHints : string,
9
+ body : string,
10
+ createdAt : Date,
11
+ updatedAt : Date,
12
+ };
13
+
14
+ type SkillDraft = Omit<Skill, "createdAt" | "updatedAt">;
15
+
16
+ /* The id ends up as the tail of a tool name an agent sees, after a fixed
17
+ prefix, and tool names are capped at 64 characters: hence the shape and
18
+ the length. */
19
+ const SKILL_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,49}$/;
20
+
21
+ const isSkillId = (value : unknown) : value is string =>
22
+ typeof value === "string" && SKILL_ID_SHAPE.test(value);
23
+
24
+ interface SkillStore {
25
+
26
+ list() : Promise<Array<Skill>>;
27
+
28
+ get(id : string) : Promise<Skill | undefined>;
29
+
30
+ // Creates or replaces the skill with that id.
31
+ save(draft : SkillDraft) : Promise<Skill>;
32
+
33
+ remove(id : string) : Promise<boolean>;
34
+
35
+ }
36
+
37
+ export type { Skill, SkillDraft, SkillStore };
38
+ export { SKILL_ID_SHAPE, isSkillId };
@@ -13,6 +13,9 @@ import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
13
  import {MembersHandler} from "./handlers/auth/MembersHandler";
14
14
  import {BillingHandler} from "./handlers/BillingHandler";
15
15
  import {CentralBilling} from "../billing/CentralBilling";
16
+ import {AppSettingsStore} from "../data-store/AppSettingsStore";
17
+ import {SkillStore} from "../data-store/SkillStore";
18
+ import {SkillsHandler} from "./handlers/SkillsHandler";
16
19
  import {Subdomains} from "../subdomains/Subdomains";
17
20
  import {AuditsHandler} from "./handlers/AuditsHandler";
18
21
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -81,7 +84,9 @@ class HostingServer {
81
84
  promptManagerFor? : (appId : string) => PromptManager,
82
85
  fileStorageFor? : (appId : string) => FileStorage,
83
86
  subdomains? : Subdomains,
84
- billing? : CentralBilling) {
87
+ billing? : CentralBilling,
88
+ appSettings? : AppSettingsStore,
89
+ skills? : SkillStore) {
85
90
  const pages = new PagesHandler();
86
91
  const ping = new PingHandler(tenant);
87
92
  const jobs = new JobsHandler(persistence);
@@ -117,6 +122,8 @@ class HostingServer {
117
122
  if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
118
123
  if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
119
124
  if (billing) publicRouter.registerApi("billing", new BillingHandler(billing));
125
+ // People and their agents only: a deployed app has no business reading skills.
126
+ if (skills) publicRouter.registerApi("skills", new SkillsHandler(skills));
120
127
  const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
121
128
  if (prompts) publicRouter.registerApi("prompts", prompts);
122
129
  if (cliAuthorizer) {
@@ -130,7 +137,7 @@ class HostingServer {
130
137
  buildLayer.consumerUrlsFor = appName =>
131
138
  [...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
132
139
  buildLayer.appRemoved = appName => void subdomains?.release(appName);
133
- publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains));
140
+ publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains, appSettings));
134
141
  publicRouter.register("app", appProxy);
135
142
 
136
143
  // The directory a person who is not a builder sees instead of the