@mobileaidev/ai-app-bridge 0.3.7 → 0.4.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 (64) hide show
  1. package/README.md +52 -39
  2. package/bin/ai-app-bridge.js +56 -17
  3. package/bin/command-discovery.js +19 -4
  4. package/bin/command-registry.js +21 -6
  5. package/bin/command-request.js +68 -4
  6. package/bin/execution-host.js +32 -5
  7. package/bin/execution-runtime.js +17 -10
  8. package/bin/executors/command-schema.js +1 -1
  9. package/bin/executors/preparation.js +262 -0
  10. package/bin/extraction/json-value.js +26 -0
  11. package/bin/extraction/prepare.js +57 -0
  12. package/bin/extraction/regex.js +30 -0
  13. package/bin/extraction/runner.js +77 -0
  14. package/bin/intent/install-intent.js +3 -1
  15. package/bin/ios-provider.js +2 -2
  16. package/bin/ios-wda-project.js +48 -1
  17. package/bin/mcp-server.js +15 -20
  18. package/bin/public-reply.js +184 -0
  19. package/bin/response-store.js +60 -0
  20. package/bin/runtime-client.js +32 -15
  21. package/bin/runtime-directory.js +37 -8
  22. package/bin/script/node-runtime-adapter.js +139 -123
  23. package/bin/script/python-runtime-adapter.js +1 -1
  24. package/bin/script/script-diagnostics.js +21 -0
  25. package/bin/script/script-durable-restore.js +1 -0
  26. package/bin/script/script-host-port.js +3 -2
  27. package/bin/script/script-sdk.js +39 -4
  28. package/bin/script/script-sdk.py +79 -7
  29. package/bin/script/script-session-channel.js +27 -10
  30. package/bin/script/script-supervisor.js +8 -0
  31. package/bin/shared-kernel/argument-schema.js +44 -12
  32. package/bin/shared-kernel/evidence-archive.js +2 -2
  33. package/bin/shared-kernel/evidence-schema.js +16 -1
  34. package/bin/shared-kernel/evidence-store.js +3 -3
  35. package/bin/shared-kernel/execution-contracts.js +13 -5
  36. package/bin/shared-kernel/execution-target.js +4 -0
  37. package/bin/shared-kernel/request-context.js +2 -2
  38. package/bin/target-execution.js +2 -0
  39. package/docs/COMMAND_CONTRACT.md +91 -19
  40. package/docs/EVIDENCE_ARCHIVE.md +14 -1
  41. package/docs/INSTALLATION.md +73 -0
  42. package/docs/INTENT_FOREGROUND.md +4 -1
  43. package/docs/OPTIONAL_EXECUTORS.md +41 -13
  44. package/docs/RELEASE.md +60 -113
  45. package/docs/RESPONSE_EXTRACTION.md +122 -0
  46. package/docs/SCRIPT_AUTHORING.md +125 -6
  47. package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
  48. package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
  49. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
  50. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
  51. package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
  52. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
  53. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
  54. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
  55. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
  56. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
  57. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
  58. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
  59. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
  60. package/package.json +12 -5
  61. package/runtime/executors/android/prepare.init.gradle +92 -0
  62. package/runtime/executors/playwright/package-lock.json +2 -2
  63. package/runtime/executors/playwright/package.json +1 -1
  64. package/skills/ai-app-bridge-use/SKILL.md +19 -4
@@ -38,174 +38,190 @@ function createChildRuntimeAdapter({
38
38
  onSpawnFailure,
39
39
  } = {}) {
40
40
  let channel = null;
41
+ let child = null;
41
42
  let exited = Promise.resolve();
42
43
  let readControl = () => ({ status: 'running', pauseReason: null });
43
44
 
44
45
  async function terminate() {
45
46
  if (channel) channel.stop();
46
47
  await exited;
48
+ child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
47
49
  }
48
50
 
49
- function start(args) {
50
- return inRequestDirectory(args.spec.cwd ?? requestDirectory(), () => startInDirectory(args));
51
+ async function start(args) {
52
+ const result = await inRequestDirectory(args.spec.cwd ?? requestDirectory(), () => startInDirectory(args));
53
+ if (!result.ok && channel) {
54
+ const diagnostics = { ...channel.diagnostics(), ...result.diagnostics };
55
+ if (Object.keys(diagnostics).length) result.diagnostics = diagnostics;
56
+ }
57
+ return result;
51
58
  }
52
59
 
53
60
  async function startInDirectory({ spec, host, agent, emit, control }) {
54
61
  readControl = typeof control === 'function' ? control : () => control || { status: 'running', pauseReason: null };
55
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-script-'));
56
- const artifact = path.join(directory, `main${extension}`);
57
- fs.writeFileSync(artifact, spec.source);
58
- const sdk = path.join(__dirname, sdkFile);
59
- const child = spawn(executable, argsPrefix.concat([sdk, artifact]), {
60
- shell: false,
61
- cwd: requestDirectory(),
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- });
64
- channel = createScriptSessionChannel(child, {
65
- maxFrameBytes: Math.max(spec.policy.maxOutputBytes, spec.policy.maxProgressBytes, 1024 * 1024) + 64 * 1024,
66
- });
67
- exited = childExitPromise(child, onSpawnFailure);
68
- const finished = exited;
62
+ let directory;
69
63
  let timer = null;
70
- const timedOut = new Promise((resolve) => {
71
- timer = setTimeout(() => resolve({ type: 'timeout' }), spec.policy.timeoutMs);
72
- });
73
- const rpc = pending => Promise.race([pending, timedOut, finished.then(() => { throw new Error('child_crashed'); })]);
64
+ channel = null;
65
+ exited = Promise.resolve();
74
66
  try {
75
- const ready = await Promise.race([
76
- channel.nextMessage(),
77
- finished,
78
- timedOut,
79
- ]);
80
- if (ready && ready.type === 'timeout') {
81
- await terminate();
82
- return { ok: false, error: 'timeout' };
83
- }
84
- if (!ready || ready.type !== 'ready') {
85
- await terminate();
86
- return { ok: false, error: ready && ready.type === 'fail' && ready.error === 'handshake_failed' ? 'handshake_failed' : 'child_crashed' };
87
- }
88
- emit('child_started', { kind });
89
- channel.send({
90
- type: 'start',
91
- inputs: spec.inputs,
92
- entrypoint: spec.entrypoint,
93
- control: control(),
94
- });
95
- while (true) {
96
- const message = await Promise.race([
67
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-script-'));
68
+ const artifact = path.join(directory, `main${extension}`);
69
+ fs.writeFileSync(artifact, spec.source);
70
+ const sdk = path.join(__dirname, sdkFile);
71
+ child = spawn(executable, argsPrefix.concat([sdk, artifact]), {
72
+ shell: false,
73
+ cwd: requestDirectory(),
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ });
76
+ channel = createScriptSessionChannel(child, {
77
+ maxFrameBytes: Math.max(spec.policy.maxOutputBytes, spec.policy.maxProgressBytes, 1024 * 1024) + 64 * 1024,
78
+ });
79
+ exited = childExitPromise(child, onSpawnFailure);
80
+ const finished = exited;
81
+ const timedOut = new Promise((resolve) => {
82
+ timer = setTimeout(() => resolve({ type: 'timeout' }), spec.policy.timeoutMs);
83
+ });
84
+ const rpc = pending => Promise.race([pending, timedOut, finished.then(() => { throw new Error('child_crashed'); })]);
85
+ const ready = await Promise.race([
97
86
  channel.nextMessage(),
98
87
  finished,
99
88
  timedOut,
100
89
  ]);
101
- if (message.type === 'timeout') {
90
+ if (ready && ready.type === 'timeout') {
102
91
  await terminate();
103
- emit('script_failed', { error: 'timeout' });
104
92
  return { ok: false, error: 'timeout' };
105
93
  }
106
- if (message.type === 'call') {
107
- const value = await rpc(host.call(message.command, message.args, message.options));
108
- if (value && value.type === 'timeout') {
109
- await terminate();
110
- emit('script_failed', { error: 'timeout' });
111
- return { ok: false, error: 'timeout' };
112
- }
113
- channel.send({ type: 'result', id: message.id, value, control: readControl() });
114
- continue;
94
+ if (!ready || ready.type !== 'ready') {
95
+ await terminate();
96
+ return { ok: false, error: ready && ready.type === 'fail' && ready.error === 'handshake_failed' ? 'handshake_failed' : 'child_crashed' };
115
97
  }
116
- if (message.type === 'assert') {
117
- const value = await rpc(host.assert(message.assertion));
118
- if (value && value.type === 'timeout') {
98
+ emit('child_started', { kind });
99
+ channel.send({
100
+ type: 'start',
101
+ inputs: spec.inputs,
102
+ entrypoint: spec.entrypoint,
103
+ sourceName: spec.sourcePath || artifact,
104
+ control: control(),
105
+ });
106
+ while (true) {
107
+ const message = await Promise.race([
108
+ channel.nextMessage(),
109
+ finished,
110
+ timedOut,
111
+ ]);
112
+ if (message.type === 'timeout') {
119
113
  await terminate();
120
114
  emit('script_failed', { error: 'timeout' });
121
115
  return { ok: false, error: 'timeout' };
122
116
  }
123
- channel.send({ type: 'verdict', id: message.id, value, control: readControl() });
124
- continue;
125
- }
126
- if (message.type === 'control-point') {
127
- channel.send({ type: 'control-state', id: message.id, control: readControl() });
128
- continue;
129
- }
130
- if (message.type === 'progress') {
131
- if (jsonBytes(message.event) > spec.policy.maxProgressBytes) {
132
- await terminate();
133
- emit('script_failed', { error: 'progress_too_large' });
134
- return { ok: false, error: 'progress_too_large' };
117
+ if (message.type === 'call') {
118
+ const value = await rpc(host.call(message.command, message.args, message.options));
119
+ if (value && value.type === 'timeout') {
120
+ await terminate();
121
+ emit('script_failed', { error: 'timeout' });
122
+ return { ok: false, error: 'timeout' };
123
+ }
124
+ channel.send({ type: 'result', id: message.id, value, control: readControl() });
125
+ continue;
135
126
  }
136
- emit('progress', message.event);
137
- continue;
138
- }
139
- if (message.type === 'checkpoint') {
140
- if (jsonBytes(message.state) > spec.policy.maxOutputBytes) {
141
- await terminate();
142
- emit('script_failed', { error: 'checkpoint_too_large' });
143
- return { ok: false, error: 'checkpoint_too_large' };
127
+ if (message.type === 'assert') {
128
+ const value = await rpc(host.assert(message.assertion));
129
+ if (value && value.type === 'timeout') {
130
+ await terminate();
131
+ emit('script_failed', { error: 'timeout' });
132
+ return { ok: false, error: 'timeout' };
133
+ }
134
+ channel.send({ type: 'verdict', id: message.id, value, control: readControl() });
135
+ continue;
144
136
  }
145
- const receipt = await rpc(Promise.resolve(emit('checkpoint', { name: message.name, state: message.state })));
146
- if (receipt && receipt.type === 'timeout') {
147
- await terminate();
148
- emit('script_failed', { error: 'timeout' });
149
- return { ok: false, error: 'timeout' };
137
+ if (message.type === 'control-point') {
138
+ channel.send({ type: 'control-state', id: message.id, control: readControl() });
139
+ continue;
150
140
  }
151
- if (receipt && receipt.ok === false) {
152
- await terminate();
153
- const error = receipt.error || 'checkpoint_not_persisted';
154
- emit('script_failed', { error });
155
- return { ok: false, error };
141
+ if (message.type === 'progress') {
142
+ if (jsonBytes(message.event) > spec.policy.maxProgressBytes) {
143
+ await terminate();
144
+ emit('script_failed', { error: 'progress_too_large' });
145
+ return { ok: false, error: 'progress_too_large' };
146
+ }
147
+ emit('progress', message.event);
148
+ continue;
156
149
  }
157
- channel.send({ type: 'checkpoint-receipt', id: message.id, value: receipt, control: readControl() });
158
- continue;
159
- }
160
- if (message.type === 'ask') {
161
- const value = await rpc(agent.askAgent(message.request));
162
- if (value && value.type === 'timeout') {
163
- await terminate();
164
- emit('script_failed', { error: 'timeout' });
165
- return { ok: false, error: 'timeout' };
150
+ if (message.type === 'checkpoint') {
151
+ if (jsonBytes(message.state) > spec.policy.maxOutputBytes) {
152
+ await terminate();
153
+ emit('script_failed', { error: 'checkpoint_too_large' });
154
+ return { ok: false, error: 'checkpoint_too_large' };
155
+ }
156
+ const receipt = await rpc(Promise.resolve(emit('checkpoint', { name: message.name, state: message.state })));
157
+ if (receipt && receipt.type === 'timeout') {
158
+ await terminate();
159
+ emit('script_failed', { error: 'timeout' });
160
+ return { ok: false, error: 'timeout' };
161
+ }
162
+ if (receipt && receipt.ok === false) {
163
+ await terminate();
164
+ const error = receipt.error || 'checkpoint_not_persisted';
165
+ emit('script_failed', { error });
166
+ return { ok: false, error };
167
+ }
168
+ channel.send({ type: 'checkpoint-receipt', id: message.id, value: receipt, control: readControl() });
169
+ continue;
166
170
  }
167
- channel.send({ type: 'decision', id: message.id, value, control: readControl() });
168
- continue;
169
- }
170
- if (message.type === 'return') {
171
- if (jsonBytes(message.result) > spec.policy.maxOutputBytes) {
171
+ if (message.type === 'ask') {
172
+ const value = await rpc(agent.askAgent(message.request));
173
+ if (value && value.type === 'timeout') {
174
+ await terminate();
175
+ emit('script_failed', { error: 'timeout' });
176
+ return { ok: false, error: 'timeout' };
177
+ }
178
+ channel.send({ type: 'decision', id: message.id, value, control: readControl() });
179
+ continue;
180
+ }
181
+ if (message.type === 'return') {
182
+ if (jsonBytes(message.result) > spec.policy.maxOutputBytes) {
183
+ await terminate();
184
+ emit('script_failed', { error: 'output_too_large' });
185
+ return { ok: false, error: 'output_too_large' };
186
+ }
187
+ child.stdin.end();
188
+ const ended = await Promise.race([finished, timedOut]);
189
+ if (ended && ended.type === 'timeout') {
190
+ await terminate();
191
+ } else {
192
+ await finished;
193
+ }
194
+ emit('script_completed', { result: message.result });
195
+ return { ok: true, result: message.result };
196
+ }
197
+ if (message.type === 'fail') {
172
198
  await terminate();
173
- emit('script_failed', { error: 'output_too_large' });
174
- return { ok: false, error: 'output_too_large' };
199
+ const error = message.error === 'channel_closed' || message.error === 'stopped'
200
+ ? 'child_crashed'
201
+ : (message.error || 'script_failed');
202
+ const captured = channel.diagnostics();
203
+ const diagnostics = { ...captured, ...(message.diagnostic ? { error: message.diagnostic } : {}) };
204
+ const extra = Object.keys(diagnostics).length ? { diagnostics } : {};
205
+ emit('script_failed', { error, ...extra });
206
+ return { ok: false, error, ...extra };
175
207
  }
176
- child.stdin.end();
177
- const ended = await Promise.race([finished, timedOut]);
178
- if (ended && ended.type === 'timeout') {
208
+ if (message.type === 'exit') {
179
209
  await terminate();
180
- } else {
181
- await finished;
210
+ emit('script_failed', { error: 'child_crashed' });
211
+ return { ok: false, error: 'child_crashed' };
182
212
  }
183
- emit('script_completed', { result: message.result });
184
- return { ok: true, result: message.result };
185
- }
186
- if (message.type === 'fail') {
187
- await terminate();
188
- const error = message.error === 'channel_closed' || message.error === 'stopped'
189
- ? 'child_crashed'
190
- : (message.error || 'script_failed');
191
- emit('script_failed', { error });
192
- return { ok: false, error };
193
- }
194
- if (message.type === 'exit') {
195
213
  await terminate();
196
214
  emit('script_failed', { error: 'child_crashed' });
197
215
  return { ok: false, error: 'child_crashed' };
198
216
  }
199
- await terminate();
200
- emit('script_failed', { error: 'child_crashed' });
201
- return { ok: false, error: 'child_crashed' };
202
- }
203
217
  } catch {
204
218
  await terminate();
205
219
  emit('script_failed', { error: 'child_crashed' });
206
220
  return { ok: false, error: 'child_crashed' };
207
221
  } finally {
208
222
  if (timer) clearTimeout(timer);
223
+ await terminate();
224
+ if (directory) fs.rmSync(directory, { recursive: true, force: true });
209
225
  }
210
226
  }
211
227
 
@@ -35,7 +35,7 @@ function pythonCandidates({ pythonPath, env = process.env } = {}) {
35
35
  }
36
36
 
37
37
  function probePython(executable, env) {
38
- const options = { encoding: 'utf8', shell: false };
38
+ const options = { encoding: 'utf8', shell: false, timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 16384 };
39
39
  if (env) options.env = env;
40
40
  const probe = spawnSync(executable, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], options);
41
41
  if (probe.status !== 0 || !probe.stdout) {
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ function boundedText(value, maxBytes) {
4
+ return Buffer.from(String(value)).subarray(0, maxBytes).toString('utf8').replace(/\uFFFD$/, '');
5
+ }
6
+
7
+ function errorDiagnostic(error) {
8
+ const stack = typeof error?.stack === 'string' ? error.stack : '';
9
+ let frameCount = 0;
10
+ const limitedStack = stack.split('\n').filter(line => !/^\s*at /.test(line) || ++frameCount <= 20).join('\n');
11
+ const frame = stack.split('\n').find(line => /:(\d+):(\d+)\)?$/.test(line));
12
+ const location = frame?.match(/(?:at .*?\(|at )(.*):(\d+):(\d+)\)?$/);
13
+ const syntaxLocation = error?.name === 'SyntaxError' ? stack.split('\n')[0].match(/^(.*):(\d+)$/) : null;
14
+ return { type: boundedText(error?.name || 'Error', 128), message: boundedText(error?.message ?? error, 2048),
15
+ ...(stack ? { stack: boundedText(limitedStack, 8192), stackTruncated: frameCount > 20 || Buffer.byteLength(limitedStack) > 8192 } : {}),
16
+ ...(syntaxLocation ? { location: { file: boundedText(syntaxLocation[1], 1024), line: Number(syntaxLocation[2]) } }
17
+ : location ? { location: { file: boundedText(location[1], 1024), line: Number(location[2]), column: Number(location[3]) } } : {}),
18
+ ...(typeof error?.valuePath === 'string' ? { valuePath: boundedText(error.valuePath, 1024) } : {}) };
19
+ }
20
+
21
+ module.exports = { boundedText, errorDiagnostic };
@@ -157,6 +157,7 @@ async function restoreUnknownOperation(args = {}) {
157
157
  operationId,
158
158
  status: terminal ? checkpoint.status : 'failed',
159
159
  error: terminal ? checkpoint.error || null : 'runtime_lost',
160
+ ...(checkpoint.diagnostics ? { diagnostics: checkpoint.diagnostics } : {}),
160
161
  persisted: true,
161
162
  restored: true,
162
163
  resumeMode: gate.ok ? 'checkpoint' : 'none',
@@ -229,7 +229,7 @@ async function mutationResult(command, args, options, ctx) {
229
229
  const actionId = options.dispatchActionId || mutationActionId(ctx.executionId, ctx.callId);
230
230
  const bound = dispatchArgs(args, actionId);
231
231
  try {
232
- return await admitExecutionMutation(ctx.target, ctx.mutationLease, async () => {
232
+ const execute = async () => {
233
233
  try { return await actionOnce(command, bound, options, {
234
234
  actions: ctx.actions,
235
235
  target: ctx.target,
@@ -238,7 +238,8 @@ async function mutationResult(command, args, options, ctx) {
238
238
  actionId,
239
239
  onAction: ctx.onAction,
240
240
  }); } finally { ctx.onSettled(); }
241
- });
241
+ };
242
+ return command === 'executor-prepare' ? await execute() : await admitExecutionMutation(ctx.target, ctx.mutationLease, execute);
242
243
  } catch (error) {
243
244
  return unavailableEnvelope({ command, error: error.code || error.message, executionId: ctx.executionId, callId: ctx.callId });
244
245
  }
@@ -2,8 +2,9 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const readline = require('node:readline');
5
+ const { boundedText, errorDiagnostic } = require('./script-diagnostics');
5
6
 
6
- const artifactPath = process.argv[2];
7
+ const artifactPath = fs.realpathSync(process.argv[2]);
7
8
  const replies = new Map();
8
9
  const controlWaiters = [];
9
10
  let nextId = 0;
@@ -37,6 +38,7 @@ function waitReply(id) {
37
38
  rl.on('line', (line) => {
38
39
  if (!line) return;
39
40
  let message;
41
+ const parseStarted = performance.now();
40
42
  try {
41
43
  message = JSON.parse(line);
42
44
  } catch {
@@ -44,10 +46,21 @@ rl.on('line', (line) => {
44
46
  process.exit(1);
45
47
  return;
46
48
  }
49
+ const inputParseMs = performance.now() - parseStarted;
47
50
  if (message.type === 'start') {
48
51
  applyControl(message.control);
49
- run(message).catch((error) => {
50
- send({ type: 'fail', error: error.message || String(error) });
52
+ run(message, inputParseMs).catch((error) => {
53
+ const diagnostic = errorDiagnostic(error);
54
+ if (message.sourceName && message.sourceName !== artifactPath) {
55
+ if (diagnostic.stack) {
56
+ const mapped = diagnostic.stack.replaceAll(artifactPath, message.sourceName);
57
+ diagnostic.stack = boundedText(mapped, 8192);
58
+ diagnostic.stackTruncated ||= Buffer.byteLength(mapped) > 8192;
59
+ }
60
+ if (diagnostic.location?.file === artifactPath) diagnostic.location.file = boundedText(message.sourceName, 1024);
61
+ }
62
+ send({ type: 'fail', error: message.extraction ? (error.code || 'extraction_failed') : diagnostic.message,
63
+ message: diagnostic.message, diagnostic });
51
64
  process.exit(1);
52
65
  });
53
66
  return;
@@ -92,7 +105,29 @@ function holdIfPaused() {
92
105
  });
93
106
  }
94
107
 
95
- async function run(start) {
108
+ async function run(start, inputParseMs) {
109
+ if (start.extraction) {
110
+ const executionStarted = performance.now();
111
+ let result;
112
+ if (start.extraction.mode === 'regex') result = require('../extraction/regex').extractRegex(start.inputs.response, start.extraction);
113
+ else {
114
+ const Module = require('node:module');
115
+ const path = require('node:path');
116
+ const filename = path.resolve(start.sourceName || artifactPath);
117
+ const loaded = new Module(filename, module);
118
+ loaded.filename = filename;
119
+ loaded.paths = Module._nodeModulePaths(path.dirname(filename));
120
+ loaded._compile(fs.readFileSync(artifactPath, 'utf8'), filename);
121
+ const main = typeof loaded.exports === 'function' ? loaded.exports : loaded.exports.main;
122
+ if (typeof main !== 'function') throw new TypeError('Extraction source must export main(ctx).');
123
+ result = await main({ inputs: start.inputs });
124
+ }
125
+ require('../extraction/json-value').validateJsonValue(result);
126
+ if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) throw Object.assign(new Error('Extraction result exceeds 256 KiB.'), { code: 'extraction_output_too_large' });
127
+ send({ type: 'return', result, timings: { inputParseMs, executionMs: performance.now() - executionStarted } });
128
+ process.exit(0);
129
+ return;
130
+ }
96
131
  const loaded = require(artifactPath);
97
132
  const entry = start.entrypoint || 'main';
98
133
  const main = typeof loaded === 'function' && entry === 'main' ? loaded : loaded[entry];
@@ -1,9 +1,14 @@
1
1
  import asyncio
2
2
  import json
3
3
  import sys
4
+ import math
5
+ import traceback
6
+ import time
7
+ from types import SimpleNamespace
4
8
 
5
9
 
6
10
  _real_stdout = sys.stdout
11
+ _input_parse_ms = 0
7
12
  HELD = {
8
13
  "pause_requested",
9
14
  "paused_manual",
@@ -20,10 +25,14 @@ def send(message):
20
25
 
21
26
 
22
27
  def read_message():
28
+ global _input_parse_ms
23
29
  line = sys.stdin.readline()
24
30
  if not line:
25
31
  return None
26
- return json.loads(line)
32
+ started = time.perf_counter()
33
+ message = json.loads(line)
34
+ _input_parse_ms = (time.perf_counter() - started) * 1000
35
+ return message
27
36
 
28
37
 
29
38
  class HostContext:
@@ -120,22 +129,74 @@ class HostContext:
120
129
  return checkpoint["state"]
121
130
 
122
131
 
123
- def load_namespace(path):
132
+ def load_namespace(path, source_name=None):
124
133
  namespace = {}
125
134
  with open(path, "r", encoding="utf-8") as handle:
126
- exec(handle.read(), namespace, namespace)
135
+ exec(compile(handle.read(), source_name or path, "exec"), namespace, namespace)
127
136
  return namespace
128
137
 
129
138
 
139
+ def validate_json(value, location="", parents=None):
140
+ parents = set() if parents is None else parents
141
+
142
+ def invalid(reason):
143
+ error = TypeError(f"{location or '/'}: {reason}")
144
+ error.code = "extraction_type_error"
145
+ error.value_path = location
146
+ raise error
147
+
148
+ if value is None or isinstance(value, (str, bool)):
149
+ return
150
+ if isinstance(value, (int, float)):
151
+ if (isinstance(value, int) and abs(value) > 9007199254740991) or (isinstance(value, float) and
152
+ (not math.isfinite(value) or value.is_integer() and abs(value) > 9007199254740991)):
153
+ invalid("expected a finite number in the safe integer range")
154
+ return
155
+ if type(value) not in (list, dict):
156
+ invalid(f"unsupported JSON type: {type(value).__name__}")
157
+ if id(value) in parents:
158
+ invalid("cyclic value")
159
+ parents.add(id(value))
160
+ entries = enumerate(value) if isinstance(value, list) else value.items()
161
+ for key, item in entries:
162
+ if isinstance(value, dict) and not isinstance(key, str):
163
+ invalid("object keys must be strings")
164
+ suffix = str(key).replace("~", "~0").replace("/", "~1")
165
+ validate_json(item, f"{location}/{suffix}", parents)
166
+ parents.remove(id(value))
167
+
168
+
169
+ def bounded(value, size):
170
+ return str(value).encode("utf-8", errors="replace")[:size].decode("utf-8", errors="ignore")
171
+
172
+
173
+ def diagnostic(error):
174
+ frames = traceback.extract_tb(error.__traceback__)
175
+ stack = "Traceback (most recent call last):\n" + "".join(traceback.format_list(frames[-20:])) + "".join(traceback.format_exception_only(type(error), error))
176
+ result = {"type": type(error).__name__, "message": bounded(error, 2048),
177
+ "stack": bounded(stack, 8192), "stackTruncated": len(frames) > 20 or len(stack.encode("utf-8")) > 8192}
178
+ if isinstance(error, SyntaxError) and error.filename:
179
+ result["location"] = {"file": bounded(error.filename, 1024), "line": error.lineno, "column": error.offset}
180
+ elif frames:
181
+ frame = frames[-1]
182
+ result["location"] = {"file": bounded(frame.filename, 1024), "line": frame.lineno}
183
+ if hasattr(error, "value_path"):
184
+ result["valuePath"] = bounded(error.value_path, 1024)
185
+ return result
186
+
187
+
130
188
  def main():
131
189
  artifact_path = sys.argv[1]
132
190
  send({"type": "ready"})
133
191
  start = read_message()
134
192
  sys.stdout = sys.stderr
135
- ctx = HostContext(start.get("inputs") or {}, start.get("control") or {"status": "running", "pauseReason": None})
193
+ extraction = start.get("extraction") is not None
194
+ ctx = SimpleNamespace(inputs=start["inputs"]) if extraction else HostContext(start.get("inputs") or {}, start.get("control") or {"status": "running", "pauseReason": None})
136
195
  entry = start.get("entrypoint") or "main"
137
196
  try:
138
- namespace = load_namespace(artifact_path)
197
+ execution_started = time.perf_counter()
198
+ input_parse_ms = _input_parse_ms
199
+ namespace = load_namespace(artifact_path, start.get("sourceName"))
139
200
  loaded = namespace.get(entry)
140
201
  if not callable(loaded):
141
202
  send({"type": "fail", "error": "unsupported_entrypoint"})
@@ -143,9 +204,20 @@ def main():
143
204
  result = loaded(ctx)
144
205
  if asyncio.iscoroutine(result):
145
206
  result = asyncio.run(result)
146
- send({"type": "return", "result": result})
207
+ if extraction:
208
+ validate_json(result)
209
+ if len(json.dumps(result, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")) > 256 * 1024:
210
+ error = ValueError("Extraction result exceeds 256 KiB.")
211
+ error.code = "extraction_output_too_large"
212
+ raise error
213
+ frame = {"type": "return", "result": result}
214
+ if extraction:
215
+ frame["timings"] = {"inputParseMs": input_parse_ms, "executionMs": (time.perf_counter() - execution_started) * 1000}
216
+ send(frame)
147
217
  except Exception as error:
148
- send({"type": "fail", "error": str(error)})
218
+ detail = diagnostic(error)
219
+ send({"type": "fail", "error": getattr(error, "code", "extraction_failed") if extraction else detail["message"],
220
+ "message": detail["message"], "diagnostic": detail})
149
221
  sys.exit(1)
150
222
 
151
223