@agentage/extension 0.1.2 → 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 (137) hide show
  1. package/README.md +6 -5
  2. package/dist/agcid/current.d.ts +7 -0
  3. package/dist/agcid/current.js +4 -0
  4. package/dist/agcid/scope.d.ts +0 -1
  5. package/dist/agcid/scope.js +2 -4
  6. package/dist/auth/route-access.d.ts +2 -0
  7. package/dist/auth/route-access.js +21 -0
  8. package/dist/auth/token-check.d.ts +10 -0
  9. package/dist/auth/token-check.js +18 -0
  10. package/dist/command/args.d.ts +14 -0
  11. package/dist/command/args.js +60 -0
  12. package/dist/command/check-cli.d.ts +1 -0
  13. package/dist/command/check-cli.js +19 -0
  14. package/dist/command/command.types.d.ts +14 -0
  15. package/dist/command/command.types.js +2 -0
  16. package/dist/command/out.d.ts +19 -0
  17. package/dist/command/out.js +32 -0
  18. package/dist/command/run-command.d.ts +16 -0
  19. package/dist/command/run-command.js +43 -0
  20. package/dist/context/context.d.ts +8 -14
  21. package/dist/context/context.js +20 -26
  22. package/dist/context/settings.d.ts +5 -0
  23. package/dist/context/settings.js +32 -0
  24. package/dist/context/test-context.d.ts +44 -0
  25. package/dist/context/test-context.js +75 -0
  26. package/dist/discovery/discovery.d.ts +12 -1
  27. package/dist/discovery/discovery.js +15 -6
  28. package/dist/discovery/discovery.schema.d.ts +7 -0
  29. package/dist/discovery/discovery.schema.js +4 -2
  30. package/dist/discovery/openapi.d.ts +3 -0
  31. package/dist/discovery/openapi.js +19 -0
  32. package/dist/health/health.schema.js +1 -1
  33. package/dist/index.d.ts +16 -3
  34. package/dist/index.js +16 -3
  35. package/dist/log/log.js +1 -1
  36. package/dist/manifest/entry.d.ts +5 -0
  37. package/dist/manifest/entry.js +29 -0
  38. package/dist/manifest/manifest.schema.d.ts +18 -0
  39. package/dist/manifest/manifest.schema.js +1 -0
  40. package/dist/manifest/parts.d.ts +1 -0
  41. package/dist/manifest/parts.js +1 -0
  42. package/dist/mcp/mcp-app.d.ts +8 -0
  43. package/dist/mcp/mcp-app.js +94 -0
  44. package/dist/mcp/server.d.ts +8 -0
  45. package/dist/mcp/server.js +38 -0
  46. package/dist/mcp/snapshot.d.ts +11 -0
  47. package/dist/mcp/snapshot.js +69 -0
  48. package/dist/mcp/tools.d.ts +26 -0
  49. package/dist/mcp/tools.js +21 -0
  50. package/dist/pages/pages-app.d.ts +8 -0
  51. package/dist/pages/pages-app.js +29 -0
  52. package/dist/pages/pages.d.ts +4 -0
  53. package/dist/pages/pages.js +16 -0
  54. package/dist/runtime/part-runners.d.ts +20 -0
  55. package/dist/runtime/part-runners.js +63 -0
  56. package/dist/runtime/run-part.d.ts +7 -3
  57. package/dist/runtime/run-part.js +67 -29
  58. package/dist/server/app.d.ts +20 -2
  59. package/dist/server/app.js +39 -11
  60. package/dist/store/migrate.d.ts +2 -0
  61. package/dist/store/migrate.js +22 -0
  62. package/dist/store/migrations.d.ts +12 -0
  63. package/dist/store/migrations.js +81 -0
  64. package/dist/store/store.d.ts +16 -0
  65. package/dist/store/store.js +55 -0
  66. package/dist/store/store.types.d.ts +7 -0
  67. package/dist/store/store.types.js +1 -0
  68. package/dist/store/test-store.d.ts +5 -0
  69. package/dist/store/test-store.js +39 -0
  70. package/dist/testing/test-server.d.ts +6 -0
  71. package/dist/testing/test-server.js +31 -9
  72. package/dist/tool/build.d.ts +4 -0
  73. package/dist/tool/build.js +21 -26
  74. package/dist/tool/check.d.ts +16 -0
  75. package/dist/tool/check.js +30 -0
  76. package/dist/tool/docker.js +3 -2
  77. package/dist/tool/exec.d.ts +5 -0
  78. package/dist/tool/exec.js +48 -9
  79. package/dist/tool/index.d.ts +8 -0
  80. package/dist/tool/index.js +8 -0
  81. package/dist/tool/kit-version.d.ts +1 -0
  82. package/dist/tool/kit-version.js +3 -0
  83. package/dist/tool/main.d.ts +6 -0
  84. package/dist/tool/main.js +60 -35
  85. package/dist/tool/record-extras.d.ts +4 -0
  86. package/dist/tool/record-extras.js +56 -0
  87. package/dist/tool/stage.d.ts +4 -0
  88. package/dist/tool/stage.js +39 -21
  89. package/dist/tool/timeouts.d.ts +10 -0
  90. package/dist/tool/timeouts.js +19 -0
  91. package/dist/tool/up/compose-run.d.ts +15 -0
  92. package/dist/tool/up/compose-run.js +25 -0
  93. package/dist/tool/up/compose.d.ts +19 -0
  94. package/dist/tool/up/compose.js +80 -0
  95. package/dist/tool/up/dev-issuer-app.d.ts +22 -0
  96. package/dist/tool/up/dev-issuer-app.js +80 -0
  97. package/dist/tool/up/dev-issuer.d.ts +2 -0
  98. package/dist/tool/up/dev-issuer.js +6 -0
  99. package/dist/tool/up/down.d.ts +13 -0
  100. package/dist/tool/up/down.js +47 -0
  101. package/dist/tool/up/gateway.d.ts +36 -0
  102. package/dist/tool/up/gateway.js +60 -0
  103. package/dist/tool/up/lines.d.ts +2 -0
  104. package/dist/tool/up/lines.js +3 -0
  105. package/dist/tool/up/local-record.d.ts +44 -0
  106. package/dist/tool/up/local-record.js +28 -0
  107. package/dist/tool/up/ports.d.ts +2 -0
  108. package/dist/tool/up/ports.js +22 -0
  109. package/dist/tool/up/project.d.ts +4 -0
  110. package/dist/tool/up/project.js +20 -0
  111. package/dist/tool/up/store-line.d.ts +11 -0
  112. package/dist/tool/up/store-line.js +36 -0
  113. package/dist/tool/up/token.d.ts +6 -0
  114. package/dist/tool/up/token.js +25 -0
  115. package/dist/tool/up/up.d.ts +16 -0
  116. package/dist/tool/up/up.js +84 -0
  117. package/dist/tool/up/wait.d.ts +8 -0
  118. package/dist/tool/up/wait.js +26 -0
  119. package/dist/ui/ui-app.d.ts +8 -0
  120. package/dist/ui/ui-app.js +39 -0
  121. package/dist/worker/cron.d.ts +11 -0
  122. package/dist/worker/cron.js +73 -0
  123. package/dist/worker/job-options.d.ts +9 -0
  124. package/dist/worker/job-options.js +17 -0
  125. package/dist/worker/run-log.d.ts +9 -0
  126. package/dist/worker/run-log.js +38 -0
  127. package/dist/worker/runs.d.ts +20 -0
  128. package/dist/worker/runs.js +59 -0
  129. package/dist/worker/scheduler.d.ts +20 -0
  130. package/dist/worker/scheduler.js +106 -0
  131. package/dist/worker/worker.types.d.ts +5 -0
  132. package/dist/worker/worker.types.js +2 -0
  133. package/package.json +11 -2
  134. package/schemas/extension-discovery.schema.json +5 -0
  135. package/schemas/extension-package.schema.json +3 -0
  136. package/schemas/extension-record.schema.json +5 -0
  137. package/schemas/health.schema.json +1 -1
@@ -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)}`;
@@ -0,0 +1,6 @@
1
+ export interface TokenOptions {
2
+ scope: string;
3
+ issuer?: string;
4
+ fetch?: typeof fetch;
5
+ }
6
+ export declare const token: (options: TokenOptions) => Promise<string>;
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { TIMEOUTS } from '../timeouts.js';
3
+ import { DEV_ISSUER } from './dev-issuer-app.js';
4
+ const AnswerSchema = z.object({ token: z.string().startsWith('aga_') });
5
+ // A token from the dev issuer of `up`, for calling /api/v1/ and /mcp by hand or from tests.
6
+ export const token = async (options) => {
7
+ const issuer = options.issuer ?? DEV_ISSUER;
8
+ const res = await (options.fetch ?? fetch)(`${issuer}/token`, {
9
+ method: 'POST',
10
+ headers: { 'content-type': 'application/json' },
11
+ body: JSON.stringify({ scope: options.scope }),
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`);
16
+ throw new Error(`no dev issuer at ${issuer}: run agentage extension up first`);
17
+ });
18
+ const body = await res.json().catch(() => null);
19
+ if (!res.ok)
20
+ throw new Error(`the dev issuer refused: ${JSON.stringify(body)}`);
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;
25
+ };
@@ -0,0 +1,16 @@
1
+ import { type ExtensionPackage } from '../../manifest/manifest.schema.js';
2
+ import { type Env } from '../../runtime/build-info.js';
3
+ import { type WaitDeps } from './wait.js';
4
+ export interface UpOptions {
5
+ dir?: string;
6
+ env?: Env;
7
+ onLine?: (line: string) => void;
8
+ }
9
+ export type UpDeps = WaitDeps;
10
+ export interface UpResult {
11
+ parts: number;
12
+ seconds: number;
13
+ project: string;
14
+ }
15
+ export declare const routesOf: (pkg: ExtensionPackage) => string[];
16
+ export declare const up: (options?: UpOptions, deps?: UpDeps) => Promise<UpResult>;
@@ -0,0 +1,84 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import {} from '../../manifest/manifest.schema.js';
4
+ import { hasPostgres, imageParts, normalizeParts } from '../../manifest/parts.js';
5
+ import { readManifest } from '../../manifest/read-manifest.js';
6
+ import {} from '../../runtime/build-info.js';
7
+ import { build } from '../build.js';
8
+ import { quietly, runCommand } from '../exec.js';
9
+ import { exists } from '../stage.js';
10
+ import { TIMEOUTS } from '../timeouts.js';
11
+ import { compose } from './compose-run.js';
12
+ import { composeFile, STORE_IMAGE } from './compose.js';
13
+ import { GATEWAY_PORT, gatewayConfig, ISSUER_PORT, PART_PATHS, partPath } from './gateway.js';
14
+ import { plural, stepLine } from './lines.js';
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';
19
+ import { waitHealthy } from './wait.js';
20
+ const GATEWAY = `http://localhost:${GATEWAY_PORT}`;
21
+ const jsonFile = (file, value) => writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
22
+ const renderFiles = async (s, recordFile, tags) => {
23
+ const gatewayFile = join(s.dir, LOCAL_DIR, 'gateway.yml');
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 }));
29
+ };
30
+ const buildStep = async (s, env) => {
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);
35
+ assertDiscoverable(record, GATEWAY);
36
+ s.line('record', `${LOCAL_DIR}/record.json`, 'discovery valid');
37
+ await renderFiles(s, file, Object.fromEntries(built.images.map((i) => [i.part, i.tag])));
38
+ };
39
+ // The migrate job runs before any part starts; a kept volume reports what it already had.
40
+ const storeStep = async (s) => {
41
+ await compose(s.deps.run, s.target, ['up', '-d', 'store'], TIMEOUTS.start);
42
+ await waitHealthy(s.target, ['store'], s.deps);
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));
49
+ };
50
+ export const routesOf = (pkg) => Object.keys(PART_PATHS)
51
+ .filter((part) => imageParts(pkg).includes(part))
52
+ .map((part) => partPath(part, pkg.version));
53
+ const partsStep = async (s) => {
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);
56
+ s.line('issuer', 'dev tokens', `http://localhost:${ISSUER_PORT}`);
57
+ s.line('gateway', GATEWAY, routesOf(s.pkg).join(' · '));
58
+ };
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) } };
68
+ };
69
+ // The laptop shape of a deployment: the release images, a local record, store, issuer, gateway.
70
+ export const up = async (options = {}, deps = { run: runCommand }) => {
71
+ const started = Date.now();
72
+ const s = await sessionOf(options, deps);
73
+ await assertPortsFree(s.deps.run, s.target.project);
74
+ await buildStep(s, options.env ?? process.env);
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))
78
+ await storeStep(s);
79
+ await partsStep(s);
80
+ const parts = imageParts(s.pkg).length + (hasPostgres(s.pkg) ? 1 : 0) + cliParts(s.pkg);
81
+ const seconds = Math.round((Date.now() - started) / 1000);
82
+ s.line('ready', `${plural(parts, 'part')} healthy`, `${seconds} s`);
83
+ return { parts, seconds, project: s.target.project };
84
+ };
@@ -0,0 +1,8 @@
1
+ import { type Run } from '../exec.js';
2
+ import { type ComposeTarget } from './compose-run.js';
3
+ export interface WaitDeps {
4
+ run: Run;
5
+ sleep?: (ms: number) => Promise<void>;
6
+ timeoutMs?: number;
7
+ }
8
+ export declare const waitHealthy: (target: ComposeTarget, services: string[], deps: WaitDeps) => Promise<void>;
@@ -0,0 +1,26 @@
1
+ import {} from '../exec.js';
2
+ import { compose, states } from './compose-run.js';
3
+ const DEFAULT_TIMEOUT_MS = 180_000;
4
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ const broken = (state) => state !== undefined &&
6
+ (state.health === 'unhealthy' || state.state === 'exited' || state.state === 'dead');
7
+ const logsOf = async (run, target, service) => compose(run, target, ['logs', '--no-color', '--tail', '20', service]).catch(() => '');
8
+ const failure = async (run, target, state, why) => new Error(`${state.service} ${why}\n${(await logsOf(run, target, state.service)).trim()}`);
9
+ // Every named service healthy (its HEALTHCHECK on 127.0.0.1), or the first broken one with its log.
10
+ export const waitHealthy = async (target, services, deps) => {
11
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12
+ const deadline = Date.now() + timeoutMs;
13
+ for (;;) {
14
+ const byService = new Map((await states(deps.run, target)).map((s) => [s.service, s]));
15
+ const bad = services.map((name) => byService.get(name)).find(broken);
16
+ if (bad)
17
+ throw await failure(deps.run, target, bad, `is ${bad.health || bad.state}`);
18
+ if (services.every((name) => byService.get(name)?.health === 'healthy'))
19
+ return;
20
+ if (Date.now() > deadline) {
21
+ const waiting = services.filter((name) => byService.get(name)?.health !== 'healthy');
22
+ throw new Error(`not healthy after ${Math.round(timeoutMs / 1000)} s: ${waiting.join(', ')}`);
23
+ }
24
+ await (deps.sleep ?? pause)(1000);
25
+ }
26
+ };
@@ -0,0 +1,8 @@
1
+ import { type Express } from 'express';
2
+ import { type PartAppBase } from '../server/app.js';
3
+ export interface UiAppOptions extends PartAppBase {
4
+ dir: string;
5
+ devIssuer?: string;
6
+ }
7
+ export declare const devIssuerOrigin: (value: string | undefined) => string | undefined;
8
+ export declare const createUiApp: (options: UiAppOptions) => Express;
@@ -0,0 +1,39 @@
1
+ import cors from 'cors';
2
+ import express, {} from 'express';
3
+ import { createPartApp, PUBLIC_CORS } from '../server/app.js';
4
+ const LOOPBACK = new Set(['localhost', '127.0.0.1']);
5
+ // AGENTAGE_DEV_ISSUER, when set, must be a loopback http origin: anything else stops the start.
6
+ export const devIssuerOrigin = (value) => {
7
+ if (!value)
8
+ return undefined;
9
+ const url = URL.canParse(value) ? new URL(value) : undefined;
10
+ if (url?.protocol !== 'http:' || !LOOPBACK.has(url.hostname))
11
+ throw new Error(`AGENTAGE_DEV_ISSUER ${value} is not a loopback http origin`);
12
+ return url.origin;
13
+ };
14
+ // Widgets reach their own /mcp and /api only; under `up` also the dev issuer.
15
+ const cspFor = (devIssuer) => ({
16
+ directives: {
17
+ defaultSrc: ["'self'"],
18
+ scriptSrc: ["'self'"],
19
+ styleSrc: ["'self'", "'unsafe-inline'"],
20
+ connectSrc: devIssuer ? ["'self'", devIssuer] : ["'self'"],
21
+ imgSrc: ["'self'", 'data:'],
22
+ upgradeInsecureRequests: null,
23
+ },
24
+ });
25
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
26
+ // /ui/{version}/: static widget modules, anonymous, cached forever (a version never changes).
27
+ export const createUiApp = (options) => createPartApp({
28
+ ...options,
29
+ part: 'ui',
30
+ helmet: {
31
+ contentSecurityPolicy: cspFor(devIssuerOrigin(options.devIssuer)),
32
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
33
+ },
34
+ mount: (app) => void app.use(`/ui/${options.pkg.version}`, cors(PUBLIC_CORS), express.static(options.dir, {
35
+ index: false,
36
+ fallthrough: true,
37
+ setHeaders: (res) => res.setHeader('Cache-Control', IMMUTABLE),
38
+ })),
39
+ });
@@ -0,0 +1,11 @@
1
+ export interface Cron {
2
+ readonly expression: string;
3
+ readonly sets: readonly ReadonlySet<number>[];
4
+ readonly anyDay: {
5
+ dom: boolean;
6
+ dow: boolean;
7
+ };
8
+ }
9
+ export declare const parseCron: (expression: string) => Cron;
10
+ export declare const matches: (cron: Cron, date: Date) => boolean;
11
+ export declare const nextRun: (cron: Cron, after: Date) => Date;
@@ -0,0 +1,73 @@
1
+ // Five fields: minute hour day-of-month month day-of-week (0 or 7 is Sunday), in UTC.
2
+ const FIELDS = [
3
+ { name: 'minute', min: 0, max: 59 },
4
+ { name: 'hour', min: 0, max: 23 },
5
+ { name: 'day of month', min: 1, max: 31 },
6
+ { name: 'month', min: 1, max: 12 },
7
+ { name: 'day of week', min: 0, max: 7 },
8
+ ];
9
+ const toInt = (text, field) => {
10
+ const value = Number(text);
11
+ if (!/^[0-9]+$/.test(text) || value < field.min || value > field.max) {
12
+ throw new Error(`cron ${field.name}: ${text} is not ${field.min}-${field.max}`);
13
+ }
14
+ return value;
15
+ };
16
+ const range = (part, field) => {
17
+ if (part === '*')
18
+ return [field.min, field.max];
19
+ const [from = '', to] = part.split('-');
20
+ return [toInt(from, field), to === undefined ? toInt(from, field) : toInt(to, field)];
21
+ };
22
+ const expand = (part, field) => {
23
+ const [base = '', stepText] = part.split('/');
24
+ const step = stepText === undefined ? 1 : Number(stepText);
25
+ if (!Number.isInteger(step) || step < 1)
26
+ throw new Error(`cron ${field.name}: bad step in ${part}`);
27
+ const [from, to] = range(base, field);
28
+ if (from > to)
29
+ throw new Error(`cron ${field.name}: ${part} runs backwards`);
30
+ const values = [];
31
+ for (let v = from; v <= to; v += step)
32
+ values.push(v);
33
+ return values;
34
+ };
35
+ const fieldSet = (text, field) => {
36
+ const values = text.split(',').flatMap((part) => expand(part, field));
37
+ return new Set(field.name === 'day of week' ? values.map((v) => v % 7) : values);
38
+ };
39
+ export const parseCron = (expression) => {
40
+ const parts = expression.trim().split(/\s+/);
41
+ if (parts.length !== 5)
42
+ throw new Error(`cron "${expression}": five fields expected`);
43
+ const sets = FIELDS.map((field, i) => fieldSet(parts[i] ?? '', field));
44
+ return { expression, sets, anyDay: { dom: parts[2] === '*', dow: parts[4] === '*' } };
45
+ };
46
+ // Classic cron: with both day fields restricted, either one matching is enough.
47
+ const dayMatches = (cron, date) => {
48
+ const dom = cron.sets[2]?.has(date.getUTCDate()) ?? false;
49
+ const dow = cron.sets[4]?.has(date.getUTCDay()) ?? false;
50
+ if (cron.anyDay.dom && cron.anyDay.dow)
51
+ return true;
52
+ if (cron.anyDay.dom)
53
+ return dow;
54
+ if (cron.anyDay.dow)
55
+ return dom;
56
+ return dom || dow;
57
+ };
58
+ export const matches = (cron, date) => (cron.sets[0]?.has(date.getUTCMinutes()) ?? false) &&
59
+ (cron.sets[1]?.has(date.getUTCHours()) ?? false) &&
60
+ (cron.sets[3]?.has(date.getUTCMonth() + 1) ?? false) &&
61
+ dayMatches(cron, date);
62
+ const MINUTE = 60_000;
63
+ const HORIZON_MINUTES = 5 * 366 * 24 * 60;
64
+ // The first matching minute strictly after `after`.
65
+ export const nextRun = (cron, after) => {
66
+ let t = Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE;
67
+ for (let i = 0; i < HORIZON_MINUTES; i += 1, t += MINUTE) {
68
+ const date = new Date(t);
69
+ if (matches(cron, date))
70
+ return date;
71
+ }
72
+ throw new Error(`cron "${cron.expression}" never runs`);
73
+ };
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ export declare const JobNameSchema: z.ZodString;
3
+ export declare const JobOptionsSchema: z.ZodOptional<z.ZodObject<{
4
+ timeout: z.ZodOptional<z.ZodUnion<readonly [z.ZodInt, z.ZodString]>>;
5
+ }, z.core.$strict>>;
6
+ export interface JobOptions {
7
+ timeout?: number | string;
8
+ }
9
+ export declare const toMs: (duration: number | string) => number;
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
3
+ const DurationSchema = z.union([
4
+ z.int().positive(),
5
+ z.string().regex(/^[0-9]+(ms|s|m|h)$/, { message: 'a duration like 500ms, 30s, 5m or 1h' }),
6
+ ]);
7
+ export const JobNameSchema = z
8
+ .string()
9
+ .max(48)
10
+ .regex(/^[a-z][a-z0-9-]*$/, { message: 'job names are lowercase, digits and dashes' });
11
+ export const JobOptionsSchema = z.strictObject({ timeout: DurationSchema.optional() }).optional();
12
+ export const toMs = (duration) => {
13
+ if (typeof duration === 'number')
14
+ return duration;
15
+ const match = /^([0-9]+)(ms|s|m|h)$/.exec(duration);
16
+ return Number(match?.[1]) * UNIT_MS[(match?.[2] ?? 'ms')];
17
+ };
@@ -0,0 +1,9 @@
1
+ import { type Log } from '../log/log.js';
2
+ export declare const LOG_CAP_BYTES: number;
3
+ export interface RunBuffer {
4
+ text: string;
5
+ truncated: boolean;
6
+ }
7
+ export declare const withRunBuffer: <T>(buffer: RunBuffer, fn: () => Promise<T>) => Promise<T>;
8
+ export declare const append: (buffer: RunBuffer, line: string) => void;
9
+ export declare const runLog: (base: Log) => Log;
@@ -0,0 +1,38 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { messageOf } from '../log/log.js';
3
+ export const LOG_CAP_BYTES = 64 * 1024;
4
+ const TRUNCATED = '\n[log truncated at 64 KB]\n';
5
+ const current = new AsyncLocalStorage();
6
+ export const withRunBuffer = (buffer, fn) => current.run(buffer, fn);
7
+ // Newlines kept; past the cap the rest is dropped and one marker line says so.
8
+ export const append = (buffer, line) => {
9
+ if (buffer.truncated)
10
+ return;
11
+ const next = `${buffer.text}${line}\n`;
12
+ if (Buffer.byteLength(next) <= LOG_CAP_BYTES - TRUNCATED.length) {
13
+ buffer.text = next;
14
+ return;
15
+ }
16
+ buffer.text = `${buffer.text}${TRUNCATED}`;
17
+ buffer.truncated = true;
18
+ };
19
+ const record = (level, message) => {
20
+ const buffer = current.getStore();
21
+ if (buffer)
22
+ append(buffer, `${level} ${message}`);
23
+ };
24
+ // ctx.log for a worker: the usual JSON line, and inside a run also the run's own log.
25
+ export const runLog = (base) => ({
26
+ info: (message, fields) => {
27
+ record('info', message);
28
+ base.info(message, fields);
29
+ },
30
+ warn: (message, fields) => {
31
+ record('warn', message);
32
+ base.warn(message, fields);
33
+ },
34
+ error: (error, fields) => {
35
+ record('error', messageOf(error));
36
+ base.error(error, fields);
37
+ },
38
+ });
@@ -0,0 +1,20 @@
1
+ import { type Store } from '../store/store.types.js';
2
+ export type RunStatus = 'ok' | 'failed';
3
+ export interface JobRun {
4
+ job: string;
5
+ startedAt: Date;
6
+ endedAt: Date;
7
+ status: RunStatus;
8
+ log: string;
9
+ timeoutMs?: number;
10
+ }
11
+ export interface RunStore {
12
+ save(run: JobRun): Promise<void>;
13
+ last(job: string): Promise<JobRun | undefined>;
14
+ }
15
+ export declare const KEEP_RUNS = 50;
16
+ export declare const RUNS_TABLE = "create table if not exists agentage_job_runs (\n id bigint generated always as identity primary key,\n job text not null,\n started_at timestamptz not null,\n ended_at timestamptz not null,\n status text not null,\n log text not null,\n timeout_ms integer\n)";
17
+ export declare const storeRuns: (store: Store) => RunStore;
18
+ export declare const memoryRuns: () => RunStore & {
19
+ all(job: string): JobRun[];
20
+ };
@@ -0,0 +1,59 @@
1
+ import {} from '../store/store.types.js';
2
+ export const KEEP_RUNS = 50;
3
+ export const RUNS_TABLE = `create table if not exists agentage_job_runs (
4
+ id bigint generated always as identity primary key,
5
+ job text not null,
6
+ started_at timestamptz not null,
7
+ ended_at timestamptz not null,
8
+ status text not null,
9
+ log text not null,
10
+ timeout_ms integer
11
+ )`;
12
+ const INSERT = `insert into agentage_job_runs (job, started_at, ended_at, status, log, timeout_ms)
13
+ values ($1, $2, $3, $4, $5, $6)`;
14
+ const PRUNE = `delete from agentage_job_runs where job = $1 and id not in
15
+ (select id from agentage_job_runs where job = $1 order by id desc limit ${KEEP_RUNS})`;
16
+ const fromRow = (row) => ({
17
+ job: row.job,
18
+ startedAt: new Date(row.started_at),
19
+ endedAt: new Date(row.ended_at),
20
+ status: row.status,
21
+ log: row.log,
22
+ ...(row.timeout_ms === null ? {} : { timeoutMs: row.timeout_ms }),
23
+ });
24
+ const LAST = 'select * from agentage_job_runs where job = $1 order by id desc limit 1';
25
+ const valuesOf = (run) => [
26
+ run.job,
27
+ run.startedAt,
28
+ run.endedAt,
29
+ run.status,
30
+ run.log,
31
+ run.timeoutMs ?? null,
32
+ ];
33
+ // Runs live in the extension's own store, in a table the kit owns; the last 50 per job are kept.
34
+ export const storeRuns = (store) => {
35
+ let table;
36
+ const ready = () => (table ??= store.query(RUNS_TABLE));
37
+ return {
38
+ save: async (run) => {
39
+ await ready();
40
+ await store.query(INSERT, valuesOf(run));
41
+ await store.query(PRUNE, [run.job]);
42
+ },
43
+ last: async (job) => {
44
+ await ready();
45
+ const { rows } = await store.query(LAST, [job]);
46
+ return rows[0] ? fromRow(rows[0]) : undefined;
47
+ },
48
+ };
49
+ };
50
+ // Without a store the runs live in memory, same rule.
51
+ export const memoryRuns = () => {
52
+ const runs = new Map();
53
+ const all = (job) => runs.get(job) ?? [];
54
+ return {
55
+ save: async (run) => void runs.set(run.job, [...all(run.job), run].slice(-KEEP_RUNS)),
56
+ last: async (job) => all(job).at(-1),
57
+ all,
58
+ };
59
+ };
@@ -0,0 +1,20 @@
1
+ import { type HealthCheck } from '../health/health.js';
2
+ import { type Log } from '../log/log.js';
3
+ import { type JobOptions } from './job-options.js';
4
+ import { type JobRun, type RunStore } from './runs.js';
5
+ export type Job = () => unknown;
6
+ export type Schedule = (name: string, cron: string, job: Job, options?: JobOptions) => void;
7
+ export interface SchedulerDeps {
8
+ runs: RunStore;
9
+ log: Log;
10
+ now?: () => Date;
11
+ }
12
+ export interface Scheduler {
13
+ schedule: Schedule;
14
+ start(): void;
15
+ stop(): void;
16
+ run(name: string): Promise<JobRun | undefined>;
17
+ checks(): Record<string, HealthCheck>;
18
+ }
19
+ export declare const LATE_MS = 60000;
20
+ export declare const createScheduler: (deps: SchedulerDeps) => Scheduler;