@agentage/extension 0.2.0 → 0.2.1

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 (69) hide show
  1. package/dist/agcid/scope.d.ts +0 -1
  2. package/dist/agcid/scope.js +0 -1
  3. package/dist/auth/route-access.d.ts +2 -0
  4. package/dist/auth/route-access.js +21 -0
  5. package/dist/auth/token-check.d.ts +10 -0
  6. package/dist/auth/token-check.js +18 -0
  7. package/dist/command/check-cli.js +2 -7
  8. package/dist/command/run-command.d.ts +1 -0
  9. package/dist/command/run-command.js +5 -15
  10. package/dist/context/test-context.js +20 -17
  11. package/dist/discovery/discovery.schema.d.ts +5 -0
  12. package/dist/discovery/discovery.schema.js +1 -1
  13. package/dist/manifest/entry.d.ts +5 -0
  14. package/dist/manifest/entry.js +29 -0
  15. package/dist/manifest/parts.d.ts +1 -0
  16. package/dist/manifest/parts.js +1 -0
  17. package/dist/mcp/mcp-app.js +27 -7
  18. package/dist/mcp/server.js +3 -1
  19. package/dist/mcp/tools.d.ts +2 -0
  20. package/dist/mcp/tools.js +4 -0
  21. package/dist/runtime/part-runners.d.ts +5 -5
  22. package/dist/runtime/part-runners.js +19 -24
  23. package/dist/runtime/run-part.d.ts +4 -2
  24. package/dist/runtime/run-part.js +43 -27
  25. package/dist/server/app.d.ts +2 -0
  26. package/dist/server/app.js +12 -1
  27. package/dist/store/migrate.js +7 -3
  28. package/dist/store/migrations.d.ts +2 -0
  29. package/dist/store/migrations.js +32 -6
  30. package/dist/store/store.d.ts +7 -1
  31. package/dist/store/store.js +37 -7
  32. package/dist/tool/build.d.ts +0 -2
  33. package/dist/tool/build.js +5 -3
  34. package/dist/tool/check.d.ts +16 -0
  35. package/dist/tool/check.js +30 -0
  36. package/dist/tool/docker.js +3 -2
  37. package/dist/tool/exec.d.ts +1 -0
  38. package/dist/tool/exec.js +43 -16
  39. package/dist/tool/main.js +23 -24
  40. package/dist/tool/record-extras.js +7 -11
  41. package/dist/tool/stage.d.ts +1 -0
  42. package/dist/tool/stage.js +8 -10
  43. package/dist/tool/timeouts.d.ts +10 -0
  44. package/dist/tool/timeouts.js +19 -0
  45. package/dist/tool/up/compose-run.d.ts +2 -1
  46. package/dist/tool/up/compose-run.js +3 -2
  47. package/dist/tool/up/compose.d.ts +2 -1
  48. package/dist/tool/up/compose.js +13 -10
  49. package/dist/tool/up/dev-issuer.js +2 -1
  50. package/dist/tool/up/down.d.ts +1 -0
  51. package/dist/tool/up/down.js +39 -30
  52. package/dist/tool/up/gateway.d.ts +9 -0
  53. package/dist/tool/up/gateway.js +11 -17
  54. package/dist/tool/up/lines.js +1 -1
  55. package/dist/tool/up/ports.d.ts +2 -0
  56. package/dist/tool/up/ports.js +22 -0
  57. package/dist/tool/up/project.d.ts +4 -0
  58. package/dist/tool/up/project.js +20 -0
  59. package/dist/tool/up/store-line.d.ts +11 -0
  60. package/dist/tool/up/store-line.js +36 -0
  61. package/dist/tool/up/token.js +9 -2
  62. package/dist/tool/up/up.d.ts +3 -6
  63. package/dist/tool/up/up.js +49 -46
  64. package/dist/tool/up/wait.js +4 -2
  65. package/dist/ui/ui-app.d.ts +2 -0
  66. package/dist/ui/ui-app.js +18 -5
  67. package/dist/worker/runs.js +11 -11
  68. package/dist/worker/scheduler.js +14 -5
  69. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { mustRun } from '../exec.js';
2
- export const compose = (run, target, args) => mustRun(run, 'docker', [
2
+ export const composeArgs = (target, args) => [
3
3
  'compose',
4
4
  ...(target.file ? ['-f', target.file] : []),
5
5
  '-p',
6
6
  target.project,
7
7
  ...args,
8
- ]);
8
+ ];
9
+ export const compose = (run, target, args, timeoutMs) => mustRun(run, 'docker', composeArgs(target, args), { timeoutMs });
9
10
  // `compose ps --format json` prints one object per line (older releases: one array).
10
11
  export const parsePs = (stdout) => {
11
12
  const text = stdout.trim();
@@ -2,11 +2,12 @@ import { type ExtensionPackage } from '../../manifest/manifest.schema.js';
2
2
  export declare const STORE_IMAGE = "postgres:18-alpine";
3
3
  export interface ComposeInput {
4
4
  pkg: ExtensionPackage;
5
+ project: string;
5
6
  tags: Record<string, string>;
6
7
  recordFile: string;
7
8
  gatewayFile: string;
9
+ seedsDir?: string;
8
10
  }
9
- export declare const projectName: (pkg: ExtensionPackage) => string;
10
11
  export declare const composeFile: (input: ComposeInput) => {
11
12
  name: string;
12
13
  services: Record<string, unknown>;
@@ -1,22 +1,24 @@
1
1
  import {} from '../../manifest/manifest.schema.js';
2
- import { imageParts, normalizeParts } from '../../manifest/parts.js';
2
+ import { hasPostgres, imageParts } from '../../manifest/parts.js';
3
3
  import {} from '../../manifest/primitives.schema.js';
4
+ import { DEV_ISSUER } from './dev-issuer-app.js';
4
5
  import { GATEWAY_PORT, ISSUER_PORT, PART_PORT, TRAEFIK_COMMAND, TRAEFIK_IMAGE } from './gateway.js';
5
6
  export const STORE_IMAGE = 'postgres:18-alpine';
6
7
  const RECORD_TARGET = '/run/agentage/agentage-extension.json';
7
8
  const ISSUER_ENTRY = 'node_modules/@agentage/extension/dist/tool/up/dev-issuer.js';
8
- export const projectName = (pkg) => `agentage-${pkg.agentage.id}`;
9
9
  // A local-only credential: the store has no published port, only the parts' network reaches it.
10
- const storeUrl = (id) => `postgres://${id}:agentage-local@store:5432/${id}`;
11
- const hasStore = (pkg) => normalizeParts(pkg.agentage.parts).store === 'postgres';
10
+ const STORE_PASSWORD = 'agentage-local';
11
+ const storeUrl = (id) => `postgres://${id}:${STORE_PASSWORD}@store:5432/${id}`;
12
+ // Only the ui under `up` names the dev issuer: its CSP then lets the harness ask it for a token.
12
13
  const partEnv = (pkg, part) => ({
13
14
  AGENTAGE_ORIGIN: `http://localhost:${GATEWAY_PORT}`,
14
15
  AGENTAGE_RECORD_FILE: RECORD_TARGET,
15
- ...(hasStore(pkg) && part !== 'ui' ? { AGENTAGE_STORE_URL: storeUrl(pkg.agentage.id) } : {}),
16
+ ...(hasPostgres(pkg) && part !== 'ui' ? { AGENTAGE_STORE_URL: storeUrl(pkg.agentage.id) } : {}),
17
+ ...(part === 'ui' ? { AGENTAGE_DEV_ISSUER: DEV_ISSUER } : {}),
16
18
  });
17
19
  const storeService = (id) => ({
18
20
  image: STORE_IMAGE,
19
- environment: { POSTGRES_USER: id, POSTGRES_PASSWORD: 'agentage-local', POSTGRES_DB: id },
21
+ environment: { POSTGRES_USER: id, POSTGRES_PASSWORD: STORE_PASSWORD, POSTGRES_DB: id },
20
22
  volumes: ['store:/var/lib/postgresql'],
21
23
  healthcheck: {
22
24
  test: ['CMD-SHELL', `pg_isready -U ${id} -d ${id}`],
@@ -29,7 +31,7 @@ const partService = (input, part) => ({
29
31
  image: input.tags[part],
30
32
  environment: partEnv(input.pkg, part),
31
33
  volumes: [`${input.recordFile}:${RECORD_TARGET}:ro`],
32
- ...(hasStore(input.pkg) && part !== 'ui'
34
+ ...(hasPostgres(input.pkg) && part !== 'ui'
33
35
  ? { depends_on: { store: { condition: 'service_healthy' } } }
34
36
  : {}),
35
37
  });
@@ -56,6 +58,7 @@ const migrateService = (input) => ({
56
58
  image: input.tags['api'],
57
59
  command: ['node', 'dist/store/migrate.js'],
58
60
  environment: partEnv(input.pkg, 'migrate'),
61
+ ...(input.seedsDir ? { volumes: [`${input.seedsDir}:/app/seeds:ro`] } : {}),
59
62
  depends_on: { store: { condition: 'service_healthy' } },
60
63
  profiles: ['jobs'],
61
64
  restart: 'no',
@@ -67,11 +70,11 @@ export const composeFile = (input) => {
67
70
  for (const part of parts)
68
71
  services[part] = partService(input, part);
69
72
  Object.assign(services, fixedServices(input, input.tags[parts[0] ?? 'api'] ?? ''));
70
- if (hasStore(input.pkg)) {
73
+ if (hasPostgres(input.pkg)) {
71
74
  services['store'] = storeService(input.pkg.agentage.id);
72
75
  if (input.tags['api'])
73
76
  services['migrate'] = migrateService(input);
74
77
  }
75
- const volumes = hasStore(input.pkg) ? { store: {} } : {};
76
- return { name: projectName(input.pkg), services, volumes };
78
+ const volumes = hasPostgres(input.pkg) ? { store: {} } : {};
79
+ return { name: input.project, services, volumes };
77
80
  };
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { portFromEnv } from '../../runtime/build-info.js';
2
3
  import { createIssuer } from './dev-issuer-app.js';
3
4
  // The dev issuer of `up`: run from a part image, on the image's port so its HEALTHCHECK answers.
4
- const port = Number(process.env['PORT'] ?? 3000);
5
+ const port = portFromEnv(process.env);
5
6
  createIssuer().listen(port, () => process.stderr.write(`dev issuer on :${port}\n`));
@@ -5,6 +5,7 @@ export interface DownOptions {
5
5
  onLine?: (line: string) => void;
6
6
  }
7
7
  export interface DownResult {
8
+ project: string;
8
9
  containers: number;
9
10
  networks: number;
10
11
  volumes: number;
@@ -1,38 +1,47 @@
1
- import { access } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
1
+ import { join } from 'node:path';
3
2
  import { readManifest } from '../../manifest/read-manifest.js';
4
3
  import { quietly, runCommand } from '../exec.js';
5
- import { projectName } from './compose.js';
4
+ import { composeArgs } from './compose-run.js';
6
5
  import { plural, stepLine } from './lines.js';
7
6
  import { LOCAL_DIR } from './local-record.js';
7
+ import { legacyProject, projectName, renderedProject, workingCopy } from './project.js';
8
8
  const count = (log, kind) => log.split('\n').filter((line) => new RegExp(`\\b${kind}\\b.*\\bRemoved\\b`).test(line)).length;
9
- // Everything `up` started goes; --keep-data keeps the store volume for the next `up`.
10
- export const down = async (options = {}, run = runCommand) => {
11
- const dir = resolve(options.dir ?? '.');
12
- const pkg = await readManifest(dir);
13
- const file = join(dir, LOCAL_DIR, 'compose.json');
14
- const withFile = await access(file).then(() => ['-f', file], () => []);
15
- const args = [
16
- 'compose',
17
- ...withFile,
18
- '-p',
19
- projectName(pkg),
20
- '--profile',
21
- 'jobs',
22
- 'down',
23
- '--remove-orphans',
9
+ const removedOf = (log) => ({
10
+ containers: count(log, 'Container'),
11
+ networks: count(log, 'Network'),
12
+ volumes: count(log, 'Volume'),
13
+ });
14
+ const summary = (removed, keepData) => {
15
+ if (removed.containers + removed.networks + removed.volumes === 0)
16
+ return 'nothing was running';
17
+ const counts = [
18
+ plural(removed.containers, 'container'),
19
+ plural(removed.networks, 'network'),
20
+ plural(removed.volumes, 'volume'),
24
21
  ];
25
- const result = await quietly(run)('docker', options.keepData ? args : [...args, '--volumes']);
26
- if (result.code !== 0)
27
- throw new Error(`docker compose down failed: ${(result.stderr ?? '').trim()}`);
22
+ return counts.join(' · ') + (keepData ? ' (the store volume kept)' : '');
23
+ };
24
+ // This folder's own project; a copied compose.json naming another copy's project is ignored.
25
+ const targetOf = async (dir) => {
26
+ const { id } = (await readManifest(dir)).agentage;
27
+ const file = join(dir, LOCAL_DIR, 'compose.json');
28
+ const rendered = await renderedProject(file);
29
+ const project = projectName(id, dir);
30
+ if (rendered === project)
31
+ return { file, project };
32
+ return { project: rendered === legacyProject(id) ? rendered : project };
33
+ };
34
+ // Everything `up` started in this working copy goes, and only that; --keep-data keeps the store.
35
+ export const down = async (options = {}, run = runCommand) => {
36
+ const dir = await workingCopy(options.dir);
37
+ const target = await targetOf(dir);
38
+ const volumes = options.keepData ? [] : ['--volumes'];
39
+ const args = composeArgs(target, ['--profile', 'jobs', 'down', '--remove-orphans', ...volumes]);
40
+ const result = await quietly(run)('docker', args);
28
41
  const log = result.stderr ?? '';
29
- const removed = {
30
- containers: count(log, 'Container'),
31
- networks: count(log, 'Network'),
32
- volumes: count(log, 'Volume'),
33
- };
34
- const kept = options.keepData ? ' (the store volume kept)' : '';
35
- const what = `${plural(removed.containers, 'container')} · ${plural(removed.networks, 'network')} · ${plural(removed.volumes, 'volume')}${kept}`;
36
- options.onLine?.(stepLine('removed', what));
37
- return removed;
42
+ if (result.code !== 0)
43
+ throw new Error(`docker compose down failed: ${log.trim()}`);
44
+ const removed = removedOf(log);
45
+ options.onLine?.(stepLine('removed', summary(removed, options.keepData), target.project));
46
+ return { project: target.project, ...removed };
38
47
  };
@@ -3,6 +3,14 @@ export declare const GATEWAY_PORT = 4280;
3
3
  export declare const ISSUER_PORT = 4281;
4
4
  export declare const PART_PORT = 3000;
5
5
  export declare const TRAEFIK_IMAGE = "traefik:v3.7.17@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748";
6
+ export declare const PART_PATHS: {
7
+ pages: string;
8
+ ui: string;
9
+ api: string;
10
+ mcp: string;
11
+ };
12
+ type RoutedPart = keyof typeof PART_PATHS;
13
+ export declare const partPath: (part: RoutedPart, version: string) => string;
6
14
  export declare const gatewayConfig: (parts: ImagePartKind[], version: string) => {
7
15
  http: {
8
16
  middlewares: {
@@ -25,3 +33,4 @@ export declare const gatewayConfig: (parts: ImagePartKind[], version: string) =>
25
33
  };
26
34
  };
27
35
  export declare const TRAEFIK_COMMAND: string[];
36
+ export {};
@@ -5,14 +5,12 @@ export const PART_PORT = 3000;
5
5
  export const TRAEFIK_IMAGE = 'traefik:v3.7.17@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748';
6
6
  // The URL layout of api-standards section 1, as the estate's gateway renders it.
7
7
  const RESERVED = 'PathPrefix(`/api/`) || Path(`/mcp`) || PathPrefix(`/mcp/`) || PathPrefix(`/ui/`) || PathPrefix(`/.well-known/`) || PathPrefix(`/git/`) || Path(`/health`)';
8
+ // Each routed part's path, in the order `up` prints them; /mcp is exact, the rest are prefixes.
9
+ export const PART_PATHS = { pages: '/', ui: '/ui/{version}/', api: '/api/v1/', mcp: '/mcp' };
10
+ // In this order, so the first present serves /.well-known/ (api-standards section 1).
8
11
  const ROUTED = ['api', 'mcp', 'pages', 'ui'];
9
- const ruleOf = (part, version) => ({
10
- api: 'PathPrefix(`/api/v1/`)',
11
- mcp: 'Path(`/mcp`)',
12
- ui: `PathPrefix(\`/ui/${version}/\`)`,
13
- pages: 'PathPrefix(`/`)',
14
- worker: undefined,
15
- })[part];
12
+ export const partPath = (part, version) => PART_PATHS[part].replace('{version}', version);
13
+ const ruleOf = (part, version) => part === 'mcp' ? 'Path(`/mcp`)' : `PathPrefix(\`${partPath(part, version)}\`)`;
16
14
  const router = (rule, priority, service) => ({
17
15
  rule,
18
16
  priority,
@@ -20,21 +18,17 @@ const router = (rule, priority, service) => ({
20
18
  entryPoints: ['web'],
21
19
  middlewares: ['gateway-headers'],
22
20
  });
23
- const partRouters = (parts, version) => {
21
+ const partRouters = (routed, version) => {
24
22
  const routers = {};
25
- for (const part of parts) {
26
- const rule = ruleOf(part, version);
27
- if (rule)
28
- routers[part] = router(rule, part === 'pages' ? 1 : 100, part);
29
- }
30
- const owner = ROUTED.find((part) => parts.includes(part));
31
- if (owner)
32
- routers['well-known'] = router('PathPrefix(`/.well-known/`)', 100, owner);
23
+ for (const part of routed)
24
+ routers[part] = router(ruleOf(part, version), part === 'pages' ? 1 : 100, part);
25
+ if (routed[0])
26
+ routers['well-known'] = router('PathPrefix(`/.well-known/`)', 100, routed[0]);
33
27
  return routers;
34
28
  };
35
29
  // Traefik's file provider: a reserved-prefix miss is Traefik's own 404, like the estate gateway.
36
30
  export const gatewayConfig = (parts, version) => {
37
- const routed = parts.filter((part) => ROUTED.includes(part));
31
+ const routed = ROUTED.filter((part) => parts.includes(part));
38
32
  const services = {
39
33
  'gateway-miss': { loadBalancer: { servers: [{ url: 'http://127.0.0.1:8081' }] } },
40
34
  };
@@ -1,3 +1,3 @@
1
1
  // What `up` and `down` print: a label, what it is, the detail; the columns line up as in a terminal.
2
- export const stepLine = (label, what, detail = '') => `${label.padEnd(9)}${what.padEnd(45)}${detail}`.trimEnd();
2
+ export const stepLine = (label, what, detail = '') => `${label.padEnd(9)}${what.padEnd(Math.max(45, what.length + 2))}${detail}`.trimEnd();
3
3
  export const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
@@ -0,0 +1,2 @@
1
+ import { type Run } from '../exec.js';
2
+ export declare const assertPortsFree: (run: Run, project: string) => Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { mustRun } from '../exec.js';
2
+ import { GATEWAY_PORT, ISSUER_PORT } from './gateway.js';
3
+ const FORMAT = '{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.Ports}}';
4
+ const runningContainers = async (run) => (await mustRun(run, 'docker', ['ps', '--format', FORMAT]))
5
+ .split('\n')
6
+ .filter((line) => line.trim() !== '')
7
+ .map((line) => {
8
+ const [name = '', project = '', ports = ''] = line.split('\t');
9
+ return { name, project, ports };
10
+ });
11
+ const heldPort = (holder) => [GATEWAY_PORT, ISSUER_PORT].find((port) => holder.ports.includes(`:${port}->`));
12
+ const refusal = (holder, port) => new Error(holder.project
13
+ ? `localhost:${port} is held by compose project ${holder.project}: run agentage extension down in the working copy that started it, then up again`
14
+ : `localhost:${port} is held by container ${holder.name}: stop it, then up again`);
15
+ // Before anything starts: the gateway and issuer ports are free, or this working copy's own.
16
+ export const assertPortsFree = async (run, project) => {
17
+ for (const holder of await runningContainers(run)) {
18
+ const port = heldPort(holder);
19
+ if (port !== undefined && holder.project !== project)
20
+ throw refusal(holder, port);
21
+ }
22
+ };
@@ -0,0 +1,4 @@
1
+ export declare const projectName: (id: string, dir: string) => string;
2
+ export declare const legacyProject: (id: string) => string;
3
+ export declare const workingCopy: (dir?: string) => Promise<string>;
4
+ export declare const renderedProject: (file: string) => Promise<string | undefined>;
@@ -0,0 +1,20 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { readJsonIfExists } from '../../manifest/read-manifest.js';
6
+ // One compose project per working copy: two checkouts of one extension never share containers or data.
7
+ export const projectName = (id, dir) => `agentage-${id}-${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`;
8
+ // Kit 0.2.0 named one project per id, shared by every copy; down still removes it.
9
+ export const legacyProject = (id) => `agentage-${id}`;
10
+ // Absolute, symlinks resolved, so one working copy always gets the same project name.
11
+ export const workingCopy = async (dir = '.') => {
12
+ const absolute = resolve(dir);
13
+ return realpath(absolute).catch(() => absolute);
14
+ };
15
+ const RenderedSchema = z.object({ name: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/) });
16
+ // The project a rendered compose file names; down trusts it only when it is this folder's own.
17
+ export const renderedProject = async (file) => {
18
+ const parsed = RenderedSchema.safeParse(await readJsonIfExists(file).catch(() => undefined));
19
+ return parsed.success ? parsed.data.name : undefined;
20
+ };
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ declare const MigrateSummarySchema: z.ZodObject<{
3
+ migrations: z.ZodInt;
4
+ applied: z.ZodInt;
5
+ seeds: z.ZodInt;
6
+ seedsBefore: z.ZodDefault<z.ZodInt>;
7
+ }, z.core.$strip>;
8
+ export type MigrateSummary = z.infer<typeof MigrateSummarySchema>;
9
+ export declare const summaryOf: (out: string) => MigrateSummary;
10
+ export declare const storePhrase: (summary: MigrateSummary) => string;
11
+ export {};
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { plural } from './lines.js';
3
+ // The migrate entry's last stdout line (migrate.ts); an image on kit 0.2.0 prints no seedsBefore.
4
+ const MigrateSummarySchema = z.object({
5
+ migrations: z.int().min(0),
6
+ applied: z.int().min(0),
7
+ seeds: z.int().min(0),
8
+ seedsBefore: z.int().min(0).default(0),
9
+ });
10
+ const lastJson = (out) => {
11
+ try {
12
+ return JSON.parse(out.trim().split('\n').at(-1) ?? '');
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ };
18
+ export const summaryOf = (out) => {
19
+ const parsed = MigrateSummarySchema.safeParse(lastJson(out));
20
+ if (!parsed.success)
21
+ throw new Error(`the migrate job printed no summary: ${out.slice(-200)}`);
22
+ return parsed.data;
23
+ };
24
+ const migrationsPhrase = ({ migrations, applied }) => {
25
+ const already = migrations - applied;
26
+ if (already === 0)
27
+ return `${plural(applied, 'migration')} applied`;
28
+ if (applied === 0)
29
+ return `${plural(already, 'migration')} already applied`;
30
+ return `${plural(applied, 'migration')} applied, ${already} already applied`;
31
+ };
32
+ const seedsPhrase = ({ seeds, seedsBefore }) => seeds === 0 && seedsBefore > 0
33
+ ? `${plural(seedsBefore, 'seed')} already applied`
34
+ : plural(seeds, 'seed');
35
+ // A kept volume says so: what this run applied, and what an earlier run already had.
36
+ export const storePhrase = (summary) => `${migrationsPhrase(summary)} · ${seedsPhrase(summary)}`;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { TIMEOUTS } from '../timeouts.js';
2
3
  import { DEV_ISSUER } from './dev-issuer-app.js';
3
4
  const AnswerSchema = z.object({ token: z.string().startsWith('aga_') });
4
5
  // A token from the dev issuer of `up`, for calling /api/v1/ and /mcp by hand or from tests.
@@ -8,11 +9,17 @@ export const token = async (options) => {
8
9
  method: 'POST',
9
10
  headers: { 'content-type': 'application/json' },
10
11
  body: JSON.stringify({ scope: options.scope }),
11
- }).catch(() => {
12
+ signal: AbortSignal.timeout(TIMEOUTS.issuer),
13
+ }).catch((err) => {
14
+ if (err.name === 'TimeoutError')
15
+ throw new Error(`the dev issuer at ${issuer} did not answer in ${TIMEOUTS.issuer / 1000} s`);
12
16
  throw new Error(`no dev issuer at ${issuer}: run agentage extension up first`);
13
17
  });
14
18
  const body = await res.json().catch(() => null);
15
19
  if (!res.ok)
16
20
  throw new Error(`the dev issuer refused: ${JSON.stringify(body)}`);
17
- return AnswerSchema.parse(body).token;
21
+ const answer = AnswerSchema.safeParse(body);
22
+ if (!answer.success)
23
+ throw new Error('the dev issuer answered without an aga_ token');
24
+ return answer.data.token;
18
25
  };
@@ -1,19 +1,16 @@
1
1
  import { type ExtensionPackage } from '../../manifest/manifest.schema.js';
2
2
  import { type Env } from '../../runtime/build-info.js';
3
- import { type Run } from '../exec.js';
3
+ import { type WaitDeps } from './wait.js';
4
4
  export interface UpOptions {
5
5
  dir?: string;
6
6
  env?: Env;
7
7
  onLine?: (line: string) => void;
8
8
  }
9
- export interface UpDeps {
10
- run: Run;
11
- sleep?: (ms: number) => Promise<void>;
12
- timeoutMs?: number;
13
- }
9
+ export type UpDeps = WaitDeps;
14
10
  export interface UpResult {
15
11
  parts: number;
16
12
  seconds: number;
13
+ project: string;
17
14
  }
18
15
  export declare const routesOf: (pkg: ExtensionPackage) => string[];
19
16
  export declare const up: (options?: UpOptions, deps?: UpDeps) => Promise<UpResult>;
@@ -1,81 +1,84 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
3
- import { checkCli } from '../../command/check-cli.js';
2
+ import { join } from 'node:path';
4
3
  import {} from '../../manifest/manifest.schema.js';
5
- import { imageParts, normalizeParts } from '../../manifest/parts.js';
4
+ import { hasPostgres, imageParts, normalizeParts } from '../../manifest/parts.js';
6
5
  import { readManifest } from '../../manifest/read-manifest.js';
7
6
  import {} from '../../runtime/build-info.js';
8
7
  import { build } from '../build.js';
9
8
  import { quietly, runCommand } from '../exec.js';
9
+ import { exists } from '../stage.js';
10
+ import { TIMEOUTS } from '../timeouts.js';
10
11
  import { compose } from './compose-run.js';
11
- import { composeFile, projectName, STORE_IMAGE } from './compose.js';
12
- import { GATEWAY_PORT, gatewayConfig, ISSUER_PORT } from './gateway.js';
12
+ import { composeFile, STORE_IMAGE } from './compose.js';
13
+ import { GATEWAY_PORT, gatewayConfig, ISSUER_PORT, PART_PATHS, partPath } from './gateway.js';
13
14
  import { plural, stepLine } from './lines.js';
14
15
  import { assertDiscoverable, LOCAL_DIR, writeLocalRecord } from './local-record.js';
16
+ import { assertPortsFree } from './ports.js';
17
+ import { projectName, workingCopy } from './project.js';
18
+ import { storePhrase, summaryOf } from './store-line.js';
15
19
  import { waitHealthy } from './wait.js';
16
20
  const GATEWAY = `http://localhost:${GATEWAY_PORT}`;
21
+ const jsonFile = (file, value) => writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
17
22
  const renderFiles = async (s, recordFile, tags) => {
18
23
  const gatewayFile = join(s.dir, LOCAL_DIR, 'gateway.yml');
19
- await writeFile(gatewayFile, `${JSON.stringify(gatewayConfig(imageParts(s.pkg), s.pkg.version), null, 2)}\n`);
20
- await writeFile(s.target.file ?? '', `${JSON.stringify(composeFile({ pkg: s.pkg, tags, recordFile, gatewayFile }), null, 2)}\n`);
24
+ const seeds = join(s.dir, 'seeds');
25
+ const seedsDir = (await exists(seeds)) ? seeds : undefined;
26
+ await jsonFile(gatewayFile, gatewayConfig(imageParts(s.pkg), s.pkg.version));
27
+ const project = s.target.project;
28
+ await jsonFile(s.target.file, composeFile({ pkg: s.pkg, project, tags, recordFile, gatewayFile, seedsDir }));
21
29
  };
22
30
  const buildStep = async (s, env) => {
23
- const built = await build({ dir: s.dir, env }, { run: s.run });
24
- s.line('build', `${plural(built.images.length, 'image')} from the release recipes`, 'same recipe and tags as a release');
25
- const { file, record } = await writeLocalRecord(s.dir, s.pkg, built, s.run);
31
+ const built = await build({ dir: s.dir, env }, { run: s.deps.run });
32
+ const images = plural(built.images.length, 'image');
33
+ s.line('build', `${images} from the release recipes`, 'same recipe and tags as a release');
34
+ const { file, record } = await writeLocalRecord(s.dir, s.pkg, built, s.deps.run);
26
35
  assertDiscoverable(record, GATEWAY);
27
36
  s.line('record', `${LOCAL_DIR}/record.json`, 'discovery valid');
28
- await renderFiles(s, file, Object.fromEntries(built.images.map((image) => [image.part, image.tag])));
37
+ await renderFiles(s, file, Object.fromEntries(built.images.map((i) => [i.part, i.tag])));
29
38
  };
30
- // The migrate job runs before any part starts; its last stdout line is the summary.
39
+ // The migrate job runs before any part starts; a kept volume reports what it already had.
31
40
  const storeStep = async (s) => {
32
- await compose(s.run, s.target, ['up', '-d', 'store']);
41
+ await compose(s.deps.run, s.target, ['up', '-d', 'store'], TIMEOUTS.start);
33
42
  await waitHealthy(s.target, ['store'], s.deps);
34
- const out = await compose(s.run, s.target, ['run', '--rm', '--no-deps', 'migrate']);
35
- const summary = JSON.parse(out.trim().split('\n').at(-1) ?? '{}');
36
- s.line('store', STORE_IMAGE, `${plural(summary.applied ?? 0, 'migration')} applied · ${plural(summary.seeds ?? 0, 'seed')}`);
43
+ const run = ['run', '--rm', '--no-deps', 'migrate'];
44
+ const out = await compose(s.deps.run, s.target, run, TIMEOUTS.migrate).catch((err) => {
45
+ throw new Error(`the migrate job failed, no part was started: ${err.message}`);
46
+ });
47
+ const summary = summaryOf(out);
48
+ s.line('store', STORE_IMAGE, storePhrase(summary));
37
49
  };
38
- const ROUTES = { pages: '/', ui: '/ui/{version}/', api: '/api/v1/', mcp: '/mcp' };
39
- export const routesOf = (pkg) => Object.keys(ROUTES)
50
+ export const routesOf = (pkg) => Object.keys(PART_PATHS)
40
51
  .filter((part) => imageParts(pkg).includes(part))
41
- .map((part) => ROUTES[part].replace('{version}', pkg.version));
52
+ .map((part) => partPath(part, pkg.version));
42
53
  const partsStep = async (s) => {
43
- await compose(s.run, s.target, ['up', '-d', '--remove-orphans']);
44
- const services = [...imageParts(s.pkg), 'gateway', 'issuer'];
45
- await waitHealthy(s.target, services, s.deps);
54
+ await compose(s.deps.run, s.target, ['up', '-d', '--remove-orphans'], TIMEOUTS.start);
55
+ await waitHealthy(s.target, [...imageParts(s.pkg), 'gateway', 'issuer'], s.deps);
46
56
  s.line('issuer', 'dev tokens', `http://localhost:${ISSUER_PORT}`);
47
57
  s.line('gateway', GATEWAY, routesOf(s.pkg).join(' · '));
48
58
  };
49
- const cliStep = async (s) => {
50
- if (!normalizeParts(s.pkg.agentage.parts).code.cli)
51
- return 0;
52
- const problem = await checkCli(s.dir);
53
- if (problem)
54
- throw new Error(`cli part: ${problem}`);
55
- return 1;
59
+ // The cli part has no container: build's check already proved its handlers match the commands.
60
+ const cliParts = (pkg) => normalizeParts(pkg.agentage.parts).code.cli ? 1 : 0;
61
+ const sessionOf = async (options, deps) => {
62
+ const dir = await workingCopy(options.dir);
63
+ const pkg = await readManifest(dir);
64
+ const file = join(dir, LOCAL_DIR, 'compose.json');
65
+ const target = { file, project: projectName(pkg.agentage.id, dir) };
66
+ const line = (label, what, detail) => options.onLine?.(stepLine(label, what, detail));
67
+ return { dir, pkg, target, line, deps: { ...deps, run: quietly(deps.run) } };
56
68
  };
57
69
  // The laptop shape of a deployment: the release images, a local record, store, issuer, gateway.
58
70
  export const up = async (options = {}, deps = { run: runCommand }) => {
59
71
  const started = Date.now();
60
- const dir = resolve(options.dir ?? '.');
61
- const pkg = await readManifest(dir);
62
- const target = { file: join(dir, LOCAL_DIR, 'compose.json'), project: projectName(pkg) };
63
- const line = (label, what, detail) => options.onLine?.(stepLine(label, what, detail));
64
- const s = {
65
- dir,
66
- pkg,
67
- run: quietly(deps.run),
68
- target,
69
- line,
70
- deps: { ...deps, run: quietly(deps.run) },
71
- };
72
+ const s = await sessionOf(options, deps);
73
+ await assertPortsFree(s.deps.run, s.target.project);
72
74
  await buildStep(s, options.env ?? process.env);
73
- const store = normalizeParts(pkg.agentage.parts).store === 'postgres';
74
- if (store)
75
+ // Again after the build: another up may have started meanwhile, and the store comes next.
76
+ await assertPortsFree(s.deps.run, s.target.project);
77
+ if (hasPostgres(s.pkg))
75
78
  await storeStep(s);
76
79
  await partsStep(s);
77
- const parts = imageParts(pkg).length + (store ? 1 : 0) + (await cliStep(s));
80
+ const parts = imageParts(s.pkg).length + (hasPostgres(s.pkg) ? 1 : 0) + cliParts(s.pkg);
78
81
  const seconds = Math.round((Date.now() - started) / 1000);
79
- line('ready', `${plural(parts, 'part')} healthy`, `${seconds} s`);
80
- return { parts, seconds };
82
+ s.line('ready', `${plural(parts, 'part')} healthy`, `${seconds} s`);
83
+ return { parts, seconds, project: s.target.project };
81
84
  };
@@ -1,5 +1,6 @@
1
1
  import {} from '../exec.js';
2
2
  import { compose, states } from './compose-run.js';
3
+ const DEFAULT_TIMEOUT_MS = 180_000;
3
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
  const broken = (state) => state !== undefined &&
5
6
  (state.health === 'unhealthy' || state.state === 'exited' || state.state === 'dead');
@@ -7,7 +8,8 @@ const logsOf = async (run, target, service) => compose(run, target, ['logs', '--
7
8
  const failure = async (run, target, state, why) => new Error(`${state.service} ${why}\n${(await logsOf(run, target, state.service)).trim()}`);
8
9
  // Every named service healthy (its HEALTHCHECK on 127.0.0.1), or the first broken one with its log.
9
10
  export const waitHealthy = async (target, services, deps) => {
10
- const deadline = Date.now() + (deps.timeoutMs ?? 180_000);
11
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12
+ const deadline = Date.now() + timeoutMs;
11
13
  for (;;) {
12
14
  const byService = new Map((await states(deps.run, target)).map((s) => [s.service, s]));
13
15
  const bad = services.map((name) => byService.get(name)).find(broken);
@@ -17,7 +19,7 @@ export const waitHealthy = async (target, services, deps) => {
17
19
  return;
18
20
  if (Date.now() > deadline) {
19
21
  const waiting = services.filter((name) => byService.get(name)?.health !== 'healthy');
20
- throw new Error(`not healthy after ${Math.round((deps.timeoutMs ?? 180_000) / 1000)} s: ${waiting.join(', ')}`);
22
+ throw new Error(`not healthy after ${Math.round(timeoutMs / 1000)} s: ${waiting.join(', ')}`);
21
23
  }
22
24
  await (deps.sleep ?? pause)(1000);
23
25
  }
@@ -2,5 +2,7 @@ import { type Express } from 'express';
2
2
  import { type PartAppBase } from '../server/app.js';
3
3
  export interface UiAppOptions extends PartAppBase {
4
4
  dir: string;
5
+ devIssuer?: string;
5
6
  }
7
+ export declare const devIssuerOrigin: (value: string | undefined) => string | undefined;
6
8
  export declare const createUiApp: (options: UiAppOptions) => Express;