@agentage/extension 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/agcid/scope.d.ts +0 -1
  2. package/dist/agcid/scope.js +0 -1
  3. package/dist/auth/part-auth.d.ts +18 -0
  4. package/dist/auth/part-auth.js +61 -0
  5. package/dist/auth/revoked.d.ts +9 -0
  6. package/dist/auth/revoked.js +34 -0
  7. package/dist/auth/route-access.d.ts +2 -0
  8. package/dist/auth/route-access.js +21 -0
  9. package/dist/auth/token-check.d.ts +13 -0
  10. package/dist/auth/token-check.js +26 -0
  11. package/dist/auth/verify-token.d.ts +18 -0
  12. package/dist/auth/verify-token.js +68 -0
  13. package/dist/command/check-cli.js +2 -7
  14. package/dist/command/run-command.d.ts +1 -0
  15. package/dist/command/run-command.js +5 -15
  16. package/dist/context/context.d.ts +4 -0
  17. package/dist/context/context.js +3 -1
  18. package/dist/context/test-context.js +23 -17
  19. package/dist/discovery/discovery.d.ts +8 -2
  20. package/dist/discovery/discovery.schema.d.ts +14 -2
  21. package/dist/discovery/discovery.schema.js +7 -3
  22. package/dist/envelope/envelope.d.ts +2 -0
  23. package/dist/envelope/envelope.js +1 -0
  24. package/dist/manifest/entry.d.ts +5 -0
  25. package/dist/manifest/entry.js +29 -0
  26. package/dist/manifest/parts.d.ts +1 -0
  27. package/dist/manifest/parts.js +1 -0
  28. package/dist/mcp/mcp-app.js +31 -7
  29. package/dist/mcp/server.js +20 -6
  30. package/dist/mcp/tools.d.ts +2 -0
  31. package/dist/mcp/tools.js +4 -0
  32. package/dist/runtime/part-runners.d.ts +6 -5
  33. package/dist/runtime/part-runners.js +20 -27
  34. package/dist/runtime/run-part.d.ts +2 -2
  35. package/dist/runtime/run-part.js +55 -28
  36. package/dist/server/app.d.ts +3 -0
  37. package/dist/server/app.js +15 -1
  38. package/dist/server/errors.js +2 -0
  39. package/dist/store/migrate.js +7 -3
  40. package/dist/store/migrations.d.ts +2 -0
  41. package/dist/store/migrations.js +32 -6
  42. package/dist/store/store.d.ts +7 -1
  43. package/dist/store/store.js +37 -7
  44. package/dist/tool/build.d.ts +0 -2
  45. package/dist/tool/build.js +5 -3
  46. package/dist/tool/check.d.ts +16 -0
  47. package/dist/tool/check.js +30 -0
  48. package/dist/tool/docker.js +3 -2
  49. package/dist/tool/exec.d.ts +1 -0
  50. package/dist/tool/exec.js +43 -16
  51. package/dist/tool/main.js +23 -24
  52. package/dist/tool/record-extras.d.ts +4 -1
  53. package/dist/tool/record-extras.js +16 -15
  54. package/dist/tool/stage.d.ts +1 -0
  55. package/dist/tool/stage.js +8 -10
  56. package/dist/tool/timeouts.d.ts +10 -0
  57. package/dist/tool/timeouts.js +19 -0
  58. package/dist/tool/up/compose-run.d.ts +2 -1
  59. package/dist/tool/up/compose-run.js +3 -2
  60. package/dist/tool/up/compose.d.ts +2 -1
  61. package/dist/tool/up/compose.js +32 -23
  62. package/dist/tool/up/dev-issuer-app.d.ts +9 -3
  63. package/dist/tool/up/dev-issuer-app.js +53 -24
  64. package/dist/tool/up/dev-issuer.js +2 -1
  65. package/dist/tool/up/down.d.ts +1 -0
  66. package/dist/tool/up/down.js +39 -30
  67. package/dist/tool/up/gateway.d.ts +9 -0
  68. package/dist/tool/up/gateway.js +11 -17
  69. package/dist/tool/up/lines.js +1 -1
  70. package/dist/tool/up/local-record.d.ts +4 -1
  71. package/dist/tool/up/ports.d.ts +2 -0
  72. package/dist/tool/up/ports.js +22 -0
  73. package/dist/tool/up/project.d.ts +4 -0
  74. package/dist/tool/up/project.js +20 -0
  75. package/dist/tool/up/store-line.d.ts +11 -0
  76. package/dist/tool/up/store-line.js +36 -0
  77. package/dist/tool/up/token.js +9 -2
  78. package/dist/tool/up/up.d.ts +3 -6
  79. package/dist/tool/up/up.js +49 -46
  80. package/dist/tool/up/wait.js +4 -2
  81. package/dist/ui/ui-app.d.ts +2 -0
  82. package/dist/ui/ui-app.js +18 -5
  83. package/dist/worker/runs.js +11 -11
  84. package/dist/worker/scheduler.js +14 -5
  85. package/package.json +2 -1
  86. package/schemas/extension-discovery.schema.json +17 -3
  87. package/schemas/extension-record.schema.json +17 -3
@@ -1,8 +1,12 @@
1
1
  import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { z } from 'zod';
4
+ import { GATEWAY_PORT, PART_PORT } from './gateway.js';
5
+ // Where the host reaches the dev issuer; the parts of `up` reach it, and tokens name it, by service.
4
6
  export const DEV_ISSUER = 'http://localhost:4281';
5
- export const DEV_AUDIENCE = '*.agentage.io';
7
+ export const UP_ISSUER = `http://issuer:${PART_PORT}`;
8
+ // The host of `up` (http://localhost:4280): the one host this audience covers.
9
+ export const DEV_AUDIENCE = 'localhost';
6
10
  const TokenRequestSchema = z.strictObject({
7
11
  scope: z
8
12
  .string()
@@ -12,8 +16,10 @@ const TokenRequestSchema = z.strictObject({
12
16
  .string()
13
17
  .regex(/^[A-Za-z0-9_-]{1,64}$/)
14
18
  .optional(),
15
- ttlSeconds: z.int().min(60).max(86_400).optional(),
19
+ ttlSeconds: z.int().min(1).max(86_400).optional(),
20
+ aud: z.string().min(1).max(253).optional(),
16
21
  });
22
+ const RevokeSchema = z.strictObject({ jti: z.string().regex(/^tok_[0-9a-f-]{36}$/) });
17
23
  export const newKeys = () => {
18
24
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
19
25
  const kid = `dev-${randomUUID().slice(0, 8)}`;
@@ -25,18 +31,11 @@ export const newKeys = () => {
25
31
  };
26
32
  const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
27
33
  // The api-standards token shape: aga_<jwt>, typ at+jwt, EdDSA, scope {id}:{verb}, a jti.
28
- export const mint = (keys, request, now = Date.now()) => {
34
+ export const mint = (keys, request, settings, now = Date.now()) => {
29
35
  const iat = Math.floor(now / 1000);
30
36
  const exp = iat + (request.ttlSeconds ?? 3600);
31
- const claims = {
32
- iss: DEV_ISSUER,
33
- sub: request.sub ?? 'usr_dev',
34
- aud: DEV_AUDIENCE,
35
- scope: request.scope,
36
- iat,
37
- exp,
38
- jti: `tok_${randomUUID()}`,
39
- };
37
+ const { scope, sub = 'usr_dev', aud = settings.audience } = request;
38
+ const claims = { iss: settings.issuer, sub, aud, scope, iat, exp, jti: `tok_${randomUUID()}` };
40
39
  const input = `${b64({ alg: 'EdDSA', typ: 'at+jwt', kid: keys.kid })}.${b64(claims)}`;
41
40
  const signature = sign(null, Buffer.from(input), keys.privateKey).toString('base64url');
42
41
  return { token: `aga_${input}.${signature}`, expiresAt: new Date(exp * 1000).toISOString() };
@@ -46,7 +45,7 @@ const reply = (res, status, body) => {
46
45
  'content-type': 'application/json',
47
46
  'cache-control': 'no-store',
48
47
  'access-control-allow-origin': '*',
49
- 'access-control-allow-headers': 'content-type',
48
+ 'access-control-allow-headers': 'content-type, x-client-type',
50
49
  'access-control-allow-methods': 'GET, POST, OPTIONS',
51
50
  });
52
51
  res.end(body === undefined ? undefined : JSON.stringify(body));
@@ -59,22 +58,52 @@ const readBody = async (req) => {
59
58
  throw new Error('body too large');
60
59
  return JSON.parse(text || '{}');
61
60
  };
62
- const tokenRoute = async (keys, req, res) => {
61
+ const tokenRoute = async (issuer, req, res) => {
63
62
  const parsed = TokenRequestSchema.safeParse(await readBody(req).catch(() => null));
64
63
  if (!parsed.success)
65
64
  return reply(res, 400, { error: 'scope must be {id}:{read|write|admin}, space-separated' });
66
- reply(res, 200, mint(keys, parsed.data));
65
+ reply(res, 200, mint(issuer.keys, parsed.data, issuer.settings));
66
+ };
67
+ // Kept for a day, the longest a dev token lives, so an entry never outlasts its token by more.
68
+ const revokeRoute = async (issuer, req, res) => {
69
+ const parsed = RevokeSchema.safeParse(await readBody(req).catch(() => null));
70
+ if (!parsed.success)
71
+ return reply(res, 400, { error: 'jti must be a tok_ id' });
72
+ issuer.revoked.set(parsed.data.jti, Math.floor(Date.now() / 1000) + 86_400);
73
+ reply(res, 200, { jti: parsed.data.jti, revoked: true });
74
+ };
75
+ const revokedList = ({ revoked }) => {
76
+ const now = Date.now() / 1000;
77
+ const live = [...revoked].filter(([, exp]) => exp > now).map(([jti, exp]) => ({ jti, exp }));
78
+ return { revoked: live, generatedAt: new Date().toISOString() };
67
79
  };
68
- // Mints only: the kit does not verify tokens yet. Never part of a deployed estate.
69
- export const createIssuer = (keys = newKeys()) => createServer((req, res) => {
70
- const path = (req.url ?? '/').split('?')[0];
80
+ // security.md, local daemons: its tokens open the parts of `up`, so no other site may mint one.
81
+ const ORIGINS = ['localhost', '127.0.0.1'].map((host) => `http://${host}:${GATEWAY_PORT}`);
82
+ const allowed = ({ settings }, req) => {
83
+ const host = (req.headers.host ?? '').replace(/:[0-9]+$/, '');
84
+ const origin = req.headers.origin;
85
+ const hosts = ['localhost', '127.0.0.1', new URL(settings.issuer).hostname];
86
+ return hosts.includes(host) && (origin === undefined || ORIGINS.includes(origin));
87
+ };
88
+ const route = (issuer, req, res) => {
89
+ const at = `${req.method ?? ''} ${(req.url ?? '/').split('?')[0] ?? ''}`;
90
+ if (!allowed(issuer, req))
91
+ return reply(res, 403, { error: 'host or origin not allowed' });
71
92
  if (req.method === 'OPTIONS')
72
93
  return reply(res, 204, undefined);
73
- if (req.method === 'GET' && path === '/health')
94
+ if (at === 'GET /health')
74
95
  return reply(res, 200, { status: 'ok' });
75
- if (req.method === 'GET' && path === '/.well-known/jwks.json')
76
- return reply(res, 200, { keys: [keys.jwk] });
77
- if (req.method === 'POST' && path === '/token')
78
- return void tokenRoute(keys, req, res);
96
+ if (at === 'GET /.well-known/jwks.json')
97
+ return reply(res, 200, { keys: [issuer.keys.jwk] });
98
+ if (at === 'GET /.well-known/agentage-revoked')
99
+ return reply(res, 200, revokedList(issuer));
100
+ if (at === 'POST /token')
101
+ return void tokenRoute(issuer, req, res);
102
+ if (at === 'POST /revoke')
103
+ return void revokeRoute(issuer, req, res);
79
104
  reply(res, 404, { error: 'not found' });
80
- });
105
+ };
106
+ export const createIssuer = (settings = { issuer: UP_ISSUER, audience: DEV_AUDIENCE }, keys = newKeys()) => {
107
+ const issuer = { settings, keys, revoked: new Map() };
108
+ return createServer((req, res) => route(issuer, req, res));
109
+ };
@@ -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'}`;
@@ -33,7 +33,10 @@ export declare const writeLocalRecord: (dir: string, pkg: ExtensionPackage, buil
33
33
  } | undefined;
34
34
  };
35
35
  openapi?: string | undefined;
36
- tools?: string[] | undefined;
36
+ tools?: {
37
+ name: string;
38
+ scope: string;
39
+ }[] | undefined;
37
40
  widgets?: {
38
41
  name: string;
39
42
  element: string;
@@ -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;