-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbundle.ts
More file actions
238 lines (215 loc) · 8.74 KB
/
Copy pathbundle.ts
File metadata and controls
238 lines (215 loc) · 8.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
import { mkdir, writeFile, stat } from 'node:fs/promises';
import { builtinModules, createRequire } from 'node:module';
import path from 'node:path';
import { build } from 'esbuild';
import type { BundleStageInput, BundleResult, BundleStager } from './types.js';
const require = createRequire(import.meta.url);
/**
* Versioned identifier embedded in the generated runner so a future
* bundle reader can detect format drift. Bumped whenever the runner
* shape changes incompatibly.
*/
const RUNNER_FORMAT_VERSION = 2;
const NODE_EXTERNALS = [
...builtinModules,
'node:*'
];
/**
* Stage a deploy-ready bundle to `input.outDir`. Output layout:
*
* <outDir>/
* agent.bundle.mjs — esbuilt user `onEvent` (default-exported handler)
* runner.mjs — entry that imports the runtime + bundle + persona
* persona.json — verbatim copy of the input persona spec
* package.json — minimal manifest pinning the runtime dep
*
* The bundle is idempotent: re-running with the same `outDir` overwrites
* the four files cleanly. Auxiliary files left behind from earlier runs
* are not touched (callers control the directory lifecycle).
*
* Externals: every bare and `node:` builtin and `@agentworkforce/runtime`
* itself are left external so the runner can resolve them at execution time.
* Bundling the runtime in would require shipping the runtime sources into
* every sandbox; the chosen split keeps the bundle small and lets ops
* patch the runtime without rebuilding every persona.
*/
export const bundleStager: BundleStager = {
async stage(input: BundleStageInput): Promise<BundleResult> {
await mkdir(input.outDir, { recursive: true });
const onEventAbs = path.resolve(path.dirname(input.personaPath), input.persona.onEvent ?? '');
if (!input.persona.onEvent) {
throw new Error(
`bundle: persona "${input.persona.id}" is missing onEvent (cannot stage a bundle without a handler)`
);
}
await assertReadableFile(onEventAbs, `persona "${input.persona.id}" onEvent`);
const bundlePath = path.join(input.outDir, 'agent.bundle.mjs');
const runnerPath = path.join(input.outDir, 'runner.mjs');
const personaCopyPath = path.join(input.outDir, 'persona.json');
const packageJsonPath = path.join(input.outDir, 'package.json');
await build({
entryPoints: [onEventAbs],
outfile: bundlePath,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
sourcemap: 'inline',
logLevel: 'silent',
minify: input.bundlerOptions?.minify ?? false,
banner: {
js: [
'import { createRequire as __agentworkforceCreateRequire } from "node:module";',
'const require = __agentworkforceCreateRequire(import.meta.url);'
].join('\n')
},
// Resolve TypeScript / JS extensions without forcing the user to
// write `.ts`-suffixed imports in their handler file.
resolveExtensions: ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json'],
external: [
// Runtime stays external — see file header comment.
'@agentworkforce/runtime',
'@agentworkforce/runtime/raw',
// Node builtins must never be bundled.
...NODE_EXTERNALS
]
});
await writeFile(personaCopyPath, JSON.stringify(input.persona, null, 2) + '\n', 'utf8');
await writeFile(packageJsonPath, buildPackageJson(input.persona.id, resolveRuntimeVersion()), 'utf8');
await writeFile(runnerPath, renderRunner(), 'utf8');
const bundleStat = await stat(bundlePath);
const runnerStat = await stat(runnerPath);
const sizeBytes = bundleStat.size + runnerStat.size;
return {
personaCopyPath,
runnerPath,
bundlePath,
packageJsonPath,
sizeBytes
};
}
};
/**
* Resolve the exact `@agentworkforce/runtime` version this copy of
* `@agentworkforce/deploy` was built against, by reading the installed
* package's own `package.json`. In a published install this is the exact
* version `workspace:*` was pinned to at publish time (see
* `packages/deploy/package.json`'s own dependency); in the monorepo it's
* whatever's checked out locally. Either way it's the version the CLI
* actually knows how to talk to — never a wildcard the sandbox's npm
* install could silently satisfy with a stale cached/pre-baked copy.
*/
function resolveRuntimeVersion(): string {
let packageJsonPath: string;
try {
packageJsonPath = require.resolve('@agentworkforce/runtime/package.json');
} catch (err) {
throw new Error(
`bundle: could not resolve @agentworkforce/runtime/package.json to pin an exact version (${
err instanceof Error ? err.message : String(err)
})`
);
}
const pkg = require(packageJsonPath) as { version?: unknown };
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
throw new Error(`bundle: ${packageJsonPath} has no valid "version" field`);
}
return pkg.version;
}
function buildPackageJson(personaId: string, runtimeVersion: string): string {
return (
JSON.stringify(
{
name: `@agentworkforce/deployed-${personaId}`,
private: true,
version: '0.0.0',
type: 'module',
main: './runner.mjs',
dependencies: {
'@agentworkforce/runtime': runtimeVersion
},
comment:
'Generated by workforce deploy. The runtime dep is pinned to the exact version this deploy CLI was built against so the sandbox installs the same runtime the bundle was compiled for, instead of trusting whatever is pre-baked or cached.'
},
null,
2
) + '\n'
);
}
function renderRunner(): string {
return `// Generated by @agentworkforce/deploy. Format version ${RUNNER_FORMAT_VERSION}.
// Do not edit by hand — \`workforce deploy\` overwrites this file on every stage.
//
// The runner imports the user's handler from the esbuilt bundle, the
// parsed persona spec from the verbatim JSON copy, and the runtime's
// \`startRunner\` to drive the dispatch loop. Envelopes arrive on stdin
// as NDJSON; structured logs go to stdout.
import { createRequire } from 'node:module';
import { startRunner } from '@agentworkforce/runtime/runner';
import { handler as wrapHandler } from '@agentworkforce/runtime';
import * as userModule from './agent.bundle.mjs';
const require = createRequire(import.meta.url);
const persona = require('./persona.json');
// The agent.ts default export is a \`defineAgent({...})\` object carrying the
// handler plus the listener declarations. A bare function default export is
// accepted as a legacy fallback (treated as the handler with no listeners).
const exported = userModule.default ?? userModule.handler;
let candidate;
let agentSpec;
if (exported && exported.__workforceAgent) {
candidate = exported.handler;
agentSpec = projectAgentSpec(exported);
} else if (exported && typeof exported.handler === 'function') {
candidate = exported.handler;
agentSpec = projectAgentSpec(exported);
} else {
candidate = exported;
}
if (typeof candidate !== 'function') {
throw new TypeError(
\`workforce deploy bundle: \${persona.id} did not default-export defineAgent({ ..., handler }). Did you forget \\\`export default defineAgent(...)\\\`?\`
);
}
const handler = candidate.__workforceHandler ? candidate : wrapHandler(candidate);
function projectAgentSpec(value) {
const spec = { ...value };
delete spec.handler;
delete spec.__workforceAgent;
// In a single-file agent, root fields not belonging to a listener are
// persona-owned and already appear in persona.json. Removing those keys
// keeps the split form extension-safe without duplicating combined persona
// extensions into the runtime agent block.
for (const key of Object.keys(persona)) delete spec[key];
return spec;
}
const agent = readRuntimeContext('WORKFORCE_AGENT_CONTEXT');
const deployment = readRuntimeContext('WORKFORCE_DEPLOYMENT_CONTEXT');
await startRunner({ persona, agent, deployment, handler, ...(agentSpec ? { agentSpec } : {}) });
function readRuntimeContext(name) {
const raw = process.env[name];
if (!raw) {
throw new Error(\`workforce deploy bundle: missing \${name}; the deploy launcher must inject runtime row context\`);
}
try {
return JSON.parse(raw);
} catch (err) {
throw new Error(
\`workforce deploy bundle: \${name} must be valid JSON: \${err instanceof Error ? err.message : String(err)}\`
);
}
}
`;
}
async function assertReadableFile(abs: string, label: string): Promise<void> {
try {
const st = await stat(abs);
if (!st.isFile()) {
throw new Error(`${label}: ${abs} is not a regular file`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`${label}: file not found at ${abs}`);
}
throw err;
}
}