@mondaydotcomorg/z2h-cli 0.33.0 → 0.33.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 (41) hide show
  1. package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  2. package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  3. package/dist/backend/client.d.ts +32 -2
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +8 -5
  6. package/dist/backend/format-sandbox-run.d.ts +4 -0
  7. package/dist/backend/format-sandbox-run.d.ts.map +1 -0
  8. package/dist/backend/format-sandbox-run.js +49 -0
  9. package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
  10. package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  11. package/dist/commands/backend.d.ts +6 -1
  12. package/dist/commands/backend.d.ts.map +1 -1
  13. package/dist/commands/backend.js +35 -3
  14. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  15. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  16. package/dist/esm/backend/client.d.ts +32 -2
  17. package/dist/esm/backend/client.d.ts.map +1 -1
  18. package/dist/esm/backend/client.mjs +8 -5
  19. package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
  20. package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
  21. package/dist/esm/backend/format-sandbox-run.mjs +47 -0
  22. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
  23. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  24. package/dist/esm/commands/backend.d.ts +6 -1
  25. package/dist/esm/commands/backend.d.ts.map +1 -1
  26. package/dist/esm/commands/backend.mjs +36 -4
  27. package/dist/esm/index.mjs +9 -4
  28. package/dist/index.js +9 -4
  29. package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
  30. package/handler-api/backend-runner/backend-runner.types.ts +3 -0
  31. package/handler-api/backend-runner/channels/channel-map.ts +12 -3
  32. package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
  33. package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
  34. package/package.json +2 -2
  35. package/src/backend/__tests__/client.test.ts +17 -13
  36. package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
  37. package/src/backend/client.ts +27 -10
  38. package/src/backend/format-sandbox-run.ts +51 -0
  39. package/src/commands/__tests__/backend-invoke.test.ts +125 -0
  40. package/src/commands/backend.ts +43 -4
  41. package/src/index.ts +13 -5
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=format-sandbox-run.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-sandbox-run.test.d.ts","sourceRoot":"","sources":["../../../src/backend/__tests__/format-sandbox-run.test.ts"],"names":[],"mappings":""}
@@ -1,10 +1,40 @@
1
1
  /** `Z2H_BACKEND_URL` overrides for staging or a locally-run runner. */
2
2
  export declare function backendBaseUrl(): string;
3
- export interface InvokeResponse {
3
+ export interface SandboxRunRequest {
4
+ code: string;
5
+ input: unknown;
6
+ handlerName?: string;
7
+ }
8
+ /** One channel call the handler made, as the runner traced it. */
9
+ export interface SandboxCall {
10
+ method: string;
11
+ args: unknown[];
12
+ durationMs: number;
13
+ ok: boolean;
14
+ error?: {
15
+ status: number;
16
+ code?: string;
17
+ message: string;
18
+ };
19
+ result?: unknown;
20
+ }
21
+ export interface SandboxRunResponse {
22
+ ok: boolean;
23
+ error?: {
24
+ status: number;
25
+ code?: string;
26
+ message: string;
27
+ };
28
+ calls: SandboxCall[];
4
29
  result: unknown;
5
30
  durationMs: number;
6
31
  }
7
- export declare function invokeBackend(appName: string, handlerName: string, input: unknown): Promise<InvokeResponse>;
32
+ /**
33
+ * Runs handler source from the working tree on the runner's sandbox route —
34
+ * against the app's real channels, without deploying it. A handler failure
35
+ * comes back as `ok: false` with the trace; only a refused request throws.
36
+ */
37
+ export declare function runSandbox(appName: string, body: SandboxRunRequest): Promise<SandboxRunResponse>;
8
38
  export interface AppIntegrationsResponse {
9
39
  integrations: string[];
10
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/backend/client.ts"],"names":[],"mappings":"AAWA,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AA+CD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAS3G;AAED,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAErF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/backend/client.ts"],"names":[],"mappings":"AAWA,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AA+CD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,kEAAkE;AAClE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAEhG;AAED,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAErF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
@@ -56,10 +56,13 @@ async function request(method, baseUrl, path, body) {
56
56
  }
57
57
  return (parsed ?? {});
58
58
  }
59
- function invokeBackend(appName, handlerName, input) {
60
- // The runner resolves the caller from the request or 401s, so an anonymous
61
- // invoke can never reach a handler.
62
- return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/invoke/${encodeURIComponent(handlerName)}`, { input });
59
+ /**
60
+ * Runs handler source from the working tree on the runner's sandbox route —
61
+ * against the app's real channels, without deploying it. A handler failure
62
+ * comes back as `ok: false` with the trace; only a refused request throws.
63
+ */
64
+ function runSandbox(appName, body) {
65
+ return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/sandbox/run`, body);
63
66
  }
64
67
  function listAppIntegrations(appName) {
65
68
  return request('GET', constants.brokerBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/integrations`);
@@ -73,6 +76,6 @@ function removeIntegration(appName, integration) {
73
76
 
74
77
  exports.backendBaseUrl = backendBaseUrl;
75
78
  exports.declareIntegration = declareIntegration;
76
- exports.invokeBackend = invokeBackend;
77
79
  exports.listAppIntegrations = listAppIntegrations;
78
80
  exports.removeIntegration = removeIntegration;
81
+ exports.runSandbox = runSandbox;
@@ -0,0 +1,4 @@
1
+ import type { SandboxRunResponse } from './client';
2
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
3
+ export declare function formatSandboxRun(res: SandboxRunResponse): string;
4
+ //# sourceMappingURL=format-sandbox-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-sandbox-run.d.ts","sourceRoot":"","sources":["../../src/backend/format-sandbox-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAmChE,uFAAuF;AACvF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,kBAAkB,GAAG,MAAM,CAchE"}
@@ -0,0 +1,49 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const MAX_SQL_CHARS = 400;
4
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
5
+ const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
6
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max)}…` : text);
7
+ /** The first arg is the SQL / GraphQL text for query channels — shown on its own line so it can be read. */
8
+ function describeArgs(args) {
9
+ const [first, ...rest] = args;
10
+ const lines = [];
11
+ if (typeof first === 'string') {
12
+ lines.push(clip(oneLine(first), MAX_SQL_CHARS));
13
+ }
14
+ else if (first !== undefined) {
15
+ lines.push(clip(JSON.stringify(first), MAX_SQL_CHARS));
16
+ }
17
+ const params = rest.filter(arg => arg !== undefined && arg !== null);
18
+ if (params.length > 0) {
19
+ lines.push(`params: ${clip(params.map(p => JSON.stringify(p)).join(', '), MAX_SQL_CHARS)}`);
20
+ }
21
+ return lines;
22
+ }
23
+ function describeCall(call) {
24
+ const rows = Array.isArray(call.result) ? ` ${call.result.length} rows` : '';
25
+ const head = ` ${call.ok ? '✓' : '✗'} ${call.method} ${Math.round(call.durationMs)}ms${rows}`;
26
+ const detail = describeArgs(call.args);
27
+ if (call.error) {
28
+ const code = call.error.code ? ` ${call.error.code}` : '';
29
+ detail.push(`${call.error.status}${code}: ${call.error.message}`);
30
+ }
31
+ return [head, ...detail.map(line => ` ${line}`)];
32
+ }
33
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
34
+ function formatSandboxRun(res) {
35
+ const lines = [`[z2h-cli] sandbox run ${res.ok ? '✓' : '✗'} ${seconds(res.durationMs)}`, ''];
36
+ lines.push(res.calls.length > 0 ? 'calls:' : 'calls: none');
37
+ res.calls.forEach(call => lines.push(...describeCall(call)));
38
+ lines.push('');
39
+ if (res.ok) {
40
+ lines.push('result:', JSON.stringify(res.result, null, 2));
41
+ }
42
+ else if (res.error) {
43
+ const code = res.error.code ? ` ${res.error.code}` : '';
44
+ lines.push(`error: ${res.error.status}${code} — ${res.error.message}`);
45
+ }
46
+ return lines.join('\n');
47
+ }
48
+
49
+ exports.formatSandboxRun = formatSandboxRun;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=backend-invoke.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-invoke.test.d.ts","sourceRoot":"","sources":["../../../src/commands/__tests__/backend-invoke.test.ts"],"names":[],"mappings":""}
@@ -23,7 +23,12 @@ export declare function deployBackend(opts: {
23
23
  * local `usage.json` — no upload to S3. Throws on the first invalid handler.
24
24
  */
25
25
  export declare function backendValidateCommand(appDir?: string): Promise<void>;
26
+ /**
27
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
28
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
29
+ */
26
30
  export declare function backendInvokeCommand(handlerName: string, opts?: {
27
31
  input?: string;
28
- }): Promise<void>;
32
+ code?: string;
33
+ }, appDir?: string): Promise<void>;
29
34
  //# sourceMappingURL=backend.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../src/commands/backend.ts"],"names":[],"mappings":"AAsBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAED,wBAAsB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa5G"}
1
+ {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -2,6 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
 
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
5
+ const promises = require('node:fs/promises');
5
6
  const fs$1 = require('fs-extra');
6
7
  const clientS3 = require('@aws-sdk/client-s3');
7
8
  const util_logger = require('../util/logger.js');
@@ -12,6 +13,8 @@ const backend_detectIntegrations = require('../backend/detect-integrations.js');
12
13
  const util_s3_client = require('../util/s3/client.js');
13
14
  const constants = require('../constants.js');
14
15
  const backend_client = require('../backend/client.js');
16
+ const backend_formatSandboxRun = require('../backend/format-sandbox-run.js');
17
+ const backend_validate = require('../backend/validate.js');
15
18
  const util_errors = require('../util/errors.js');
16
19
 
17
20
  const _interopDefault = e => e && e.__esModule ? e : { default: e };
@@ -111,7 +114,29 @@ async function backendValidateCommand(appDir = process.cwd()) {
111
114
  integrations,
112
115
  });
113
116
  }
114
- async function backendInvokeCommand(handlerName, opts = {}) {
117
+ async function readHandlerSource(appDir, handlerName) {
118
+ const dir = path__default.default.join(appDir, 'backend', 'handlers');
119
+ try {
120
+ return await promises.readFile(path__default.default.join(dir, `${handlerName}.js`), 'utf8');
121
+ }
122
+ catch (err) {
123
+ if (err.code !== 'ENOENT') {
124
+ throw err;
125
+ }
126
+ const files = hasHandlers(appDir) ? await promises.readdir(dir) : [];
127
+ const available = files
128
+ .filter(f => f.endsWith('.js'))
129
+ .map(f => path__default.default.parse(f).name)
130
+ .sort();
131
+ throw new Error(`no handler "${handlerName}" in backend/handlers/` +
132
+ (available.length ? ` (available: ${available.join(', ')})` : ' (no handlers found)'));
133
+ }
134
+ }
135
+ /**
136
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
137
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
138
+ */
139
+ async function backendInvokeCommand(handlerName, opts = {}, appDir = process.cwd()) {
115
140
  const appName = currentAppName();
116
141
  let input = null;
117
142
  if (opts.input) {
@@ -122,8 +147,15 @@ async function backendInvokeCommand(handlerName, opts = {}) {
122
147
  throw new Error(`--input must be valid JSON (got: ${opts.input})`);
123
148
  }
124
149
  }
125
- const res = await backend_client.invokeBackend(appName, handlerName, input);
126
- util_logger.output(`[z2h-cli] result (${Math.round(res.durationMs)}ms):\n${JSON.stringify(res.result, null, 2)}`, res);
150
+ const file = `${handlerName}.js`;
151
+ const code = opts.code ?? (await readHandlerSource(appDir, handlerName));
152
+ backend_validate.validateHandlerSource(file, code);
153
+ util_logger.info(`validated ${opts.code ? '--code' : file}`);
154
+ const res = await backend_client.runSandbox(appName, { code, input, handlerName });
155
+ util_logger.output(backend_formatSandboxRun.formatSandboxRun(res), res);
156
+ if (!res.ok) {
157
+ process.exitCode = 1;
158
+ }
127
159
  }
128
160
 
129
161
  exports.backendInvokeCommand = backendInvokeCommand;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=format-sandbox-run.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-sandbox-run.test.d.ts","sourceRoot":"","sources":["../../../../src/backend/__tests__/format-sandbox-run.test.ts"],"names":[],"mappings":""}
@@ -1,10 +1,40 @@
1
1
  /** `Z2H_BACKEND_URL` overrides for staging or a locally-run runner. */
2
2
  export declare function backendBaseUrl(): string;
3
- export interface InvokeResponse {
3
+ export interface SandboxRunRequest {
4
+ code: string;
5
+ input: unknown;
6
+ handlerName?: string;
7
+ }
8
+ /** One channel call the handler made, as the runner traced it. */
9
+ export interface SandboxCall {
10
+ method: string;
11
+ args: unknown[];
12
+ durationMs: number;
13
+ ok: boolean;
14
+ error?: {
15
+ status: number;
16
+ code?: string;
17
+ message: string;
18
+ };
19
+ result?: unknown;
20
+ }
21
+ export interface SandboxRunResponse {
22
+ ok: boolean;
23
+ error?: {
24
+ status: number;
25
+ code?: string;
26
+ message: string;
27
+ };
28
+ calls: SandboxCall[];
4
29
  result: unknown;
5
30
  durationMs: number;
6
31
  }
7
- export declare function invokeBackend(appName: string, handlerName: string, input: unknown): Promise<InvokeResponse>;
32
+ /**
33
+ * Runs handler source from the working tree on the runner's sandbox route —
34
+ * against the app's real channels, without deploying it. A handler failure
35
+ * comes back as `ok: false` with the trace; only a refused request throws.
36
+ */
37
+ export declare function runSandbox(appName: string, body: SandboxRunRequest): Promise<SandboxRunResponse>;
8
38
  export interface AppIntegrationsResponse {
9
39
  integrations: string[];
10
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/backend/client.ts"],"names":[],"mappings":"AAWA,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AA+CD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAS3G;AAED,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAErF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/backend/client.ts"],"names":[],"mappings":"AAWA,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AA+CD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,kEAAkE;AAClE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAEhG;AAED,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAErF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
@@ -54,10 +54,13 @@ async function request(method, baseUrl, path, body) {
54
54
  }
55
55
  return (parsed ?? {});
56
56
  }
57
- function invokeBackend(appName, handlerName, input) {
58
- // The runner resolves the caller from the request or 401s, so an anonymous
59
- // invoke can never reach a handler.
60
- return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/invoke/${encodeURIComponent(handlerName)}`, { input });
57
+ /**
58
+ * Runs handler source from the working tree on the runner's sandbox route —
59
+ * against the app's real channels, without deploying it. A handler failure
60
+ * comes back as `ok: false` with the trace; only a refused request throws.
61
+ */
62
+ function runSandbox(appName, body) {
63
+ return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/sandbox/run`, body);
61
64
  }
62
65
  function listAppIntegrations(appName) {
63
66
  return request('GET', brokerBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/integrations`);
@@ -69,4 +72,4 @@ function removeIntegration(appName, integration) {
69
72
  return request('DELETE', brokerBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/integrations/${encodeURIComponent(integration)}`);
70
73
  }
71
74
 
72
- export { backendBaseUrl, declareIntegration, invokeBackend, listAppIntegrations, removeIntegration };
75
+ export { backendBaseUrl, declareIntegration, listAppIntegrations, removeIntegration, runSandbox };
@@ -0,0 +1,4 @@
1
+ import type { SandboxRunResponse } from './client';
2
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
3
+ export declare function formatSandboxRun(res: SandboxRunResponse): string;
4
+ //# sourceMappingURL=format-sandbox-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-sandbox-run.d.ts","sourceRoot":"","sources":["../../../src/backend/format-sandbox-run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAmChE,uFAAuF;AACvF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,kBAAkB,GAAG,MAAM,CAchE"}
@@ -0,0 +1,47 @@
1
+ const MAX_SQL_CHARS = 400;
2
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
3
+ const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
4
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max)}…` : text);
5
+ /** The first arg is the SQL / GraphQL text for query channels — shown on its own line so it can be read. */
6
+ function describeArgs(args) {
7
+ const [first, ...rest] = args;
8
+ const lines = [];
9
+ if (typeof first === 'string') {
10
+ lines.push(clip(oneLine(first), MAX_SQL_CHARS));
11
+ }
12
+ else if (first !== undefined) {
13
+ lines.push(clip(JSON.stringify(first), MAX_SQL_CHARS));
14
+ }
15
+ const params = rest.filter(arg => arg !== undefined && arg !== null);
16
+ if (params.length > 0) {
17
+ lines.push(`params: ${clip(params.map(p => JSON.stringify(p)).join(', '), MAX_SQL_CHARS)}`);
18
+ }
19
+ return lines;
20
+ }
21
+ function describeCall(call) {
22
+ const rows = Array.isArray(call.result) ? ` ${call.result.length} rows` : '';
23
+ const head = ` ${call.ok ? '✓' : '✗'} ${call.method} ${Math.round(call.durationMs)}ms${rows}`;
24
+ const detail = describeArgs(call.args);
25
+ if (call.error) {
26
+ const code = call.error.code ? ` ${call.error.code}` : '';
27
+ detail.push(`${call.error.status}${code}: ${call.error.message}`);
28
+ }
29
+ return [head, ...detail.map(line => ` ${line}`)];
30
+ }
31
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
32
+ function formatSandboxRun(res) {
33
+ const lines = [`[z2h-cli] sandbox run ${res.ok ? '✓' : '✗'} ${seconds(res.durationMs)}`, ''];
34
+ lines.push(res.calls.length > 0 ? 'calls:' : 'calls: none');
35
+ res.calls.forEach(call => lines.push(...describeCall(call)));
36
+ lines.push('');
37
+ if (res.ok) {
38
+ lines.push('result:', JSON.stringify(res.result, null, 2));
39
+ }
40
+ else if (res.error) {
41
+ const code = res.error.code ? ` ${res.error.code}` : '';
42
+ lines.push(`error: ${res.error.status}${code} — ${res.error.message}`);
43
+ }
44
+ return lines.join('\n');
45
+ }
46
+
47
+ export { formatSandboxRun };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=backend-invoke.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-invoke.test.d.ts","sourceRoot":"","sources":["../../../../src/commands/__tests__/backend-invoke.test.ts"],"names":[],"mappings":""}
@@ -23,7 +23,12 @@ export declare function deployBackend(opts: {
23
23
  * local `usage.json` — no upload to S3. Throws on the first invalid handler.
24
24
  */
25
25
  export declare function backendValidateCommand(appDir?: string): Promise<void>;
26
+ /**
27
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
28
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
29
+ */
26
30
  export declare function backendInvokeCommand(handlerName: string, opts?: {
27
31
  input?: string;
28
- }): Promise<void>;
32
+ code?: string;
33
+ }, appDir?: string): Promise<void>;
29
34
  //# sourceMappingURL=backend.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../../src/commands/backend.ts"],"names":[],"mappings":"AAsBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAED,wBAAsB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa5G"}
1
+ {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readFile, readdir } from 'node:fs/promises';
3
4
  import { readJson, writeJson } from 'fs-extra';
4
5
  import { PutObjectCommand } from '@aws-sdk/client-s3';
5
6
  import { output, info } from '../util/logger.mjs';
@@ -9,7 +10,9 @@ import { readHandlers } from '../backend/handlers.mjs';
9
10
  import { detectIntegrations } from '../backend/detect-integrations.mjs';
10
11
  import { getS3Client } from '../util/s3/client.mjs';
11
12
  import { BUCKET_WRITE_NAME } from '../constants.mjs';
12
- import { invokeBackend } from '../backend/client.mjs';
13
+ import { runSandbox } from '../backend/client.mjs';
14
+ import { formatSandboxRun } from '../backend/format-sandbox-run.mjs';
15
+ import { validateHandlerSource } from '../backend/validate.mjs';
13
16
  import { tagDeployError } from '../util/errors.mjs';
14
17
 
15
18
  function currentAppName() {
@@ -105,7 +108,29 @@ async function backendValidateCommand(appDir = process.cwd()) {
105
108
  integrations,
106
109
  });
107
110
  }
108
- async function backendInvokeCommand(handlerName, opts = {}) {
111
+ async function readHandlerSource(appDir, handlerName) {
112
+ const dir = path.join(appDir, 'backend', 'handlers');
113
+ try {
114
+ return await readFile(path.join(dir, `${handlerName}.js`), 'utf8');
115
+ }
116
+ catch (err) {
117
+ if (err.code !== 'ENOENT') {
118
+ throw err;
119
+ }
120
+ const files = hasHandlers(appDir) ? await readdir(dir) : [];
121
+ const available = files
122
+ .filter(f => f.endsWith('.js'))
123
+ .map(f => path.parse(f).name)
124
+ .sort();
125
+ throw new Error(`no handler "${handlerName}" in backend/handlers/` +
126
+ (available.length ? ` (available: ${available.join(', ')})` : ' (no handlers found)'));
127
+ }
128
+ }
129
+ /**
130
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
131
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
132
+ */
133
+ async function backendInvokeCommand(handlerName, opts = {}, appDir = process.cwd()) {
109
134
  const appName = currentAppName();
110
135
  let input = null;
111
136
  if (opts.input) {
@@ -116,8 +141,15 @@ async function backendInvokeCommand(handlerName, opts = {}) {
116
141
  throw new Error(`--input must be valid JSON (got: ${opts.input})`);
117
142
  }
118
143
  }
119
- const res = await invokeBackend(appName, handlerName, input);
120
- output(`[z2h-cli] result (${Math.round(res.durationMs)}ms):\n${JSON.stringify(res.result, null, 2)}`, res);
144
+ const file = `${handlerName}.js`;
145
+ const code = opts.code ?? (await readHandlerSource(appDir, handlerName));
146
+ validateHandlerSource(file, code);
147
+ info(`validated ${opts.code ? '--code' : file}`);
148
+ const res = await runSandbox(appName, { code, input, handlerName });
149
+ output(formatSandboxRun(res), res);
150
+ if (!res.ok) {
151
+ process.exitCode = 1;
152
+ }
121
153
  }
122
154
 
123
155
  export { backendInvokeCommand, backendValidateCommand, deployBackend, hasHandlers };
@@ -180,15 +180,18 @@ async function main() {
180
180
  .action(runCommand({ data: cwdApp }, (opts) => deleteCommand(opts)));
181
181
  const backend = program
182
182
  .command('backend')
183
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
183
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
184
184
  backend
185
185
  .command('validate')
186
186
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
187
187
  .action(runCommand({ data: cwdApp }, () => backendValidateCommand()));
188
188
  backend
189
189
  .command('invoke <handlerName>')
190
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
191
- .option('--input <json>', 'JSON payload passed as ctx.input')
190
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
191
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
192
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
193
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
194
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
192
195
  .action(runCommand({
193
196
  data: ([handlerName]) => ({ app_name: getCwdAppName(), handler: handlerName }),
194
197
  }, (handlerName, opts) => backendInvokeCommand(handlerName, opts)));
@@ -215,7 +218,9 @@ void main()
215
218
  if (getOutputMode() === 'human') {
216
219
  selfUpdate();
217
220
  }
218
- process.exit(0);
221
+ // A command can finish normally and still report a non-zero code (e.g.
222
+ // `backend invoke` when the handler failed) via process.exitCode.
223
+ process.exit(process.exitCode ?? 0);
219
224
  })
220
225
  .catch(async (err) => {
221
226
  const errorMsg = err instanceof Error ? err.message : String(err);
package/dist/index.js CHANGED
@@ -186,15 +186,18 @@ async function main() {
186
186
  .action(util_runCommand.runCommand({ data: cwdApp }, (opts) => commands_delete.deleteCommand(opts)));
187
187
  const backend = program
188
188
  .command('backend')
189
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
189
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
190
190
  backend
191
191
  .command('validate')
192
192
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
193
193
  .action(util_runCommand.runCommand({ data: cwdApp }, () => commands_backend.backendValidateCommand()));
194
194
  backend
195
195
  .command('invoke <handlerName>')
196
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
197
- .option('--input <json>', 'JSON payload passed as ctx.input')
196
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
197
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
198
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
199
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
200
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
198
201
  .action(util_runCommand.runCommand({
199
202
  data: ([handlerName]) => ({ app_name: util_appName.getCwdAppName(), handler: handlerName }),
200
203
  }, (handlerName, opts) => commands_backend.backendInvokeCommand(handlerName, opts)));
@@ -221,7 +224,9 @@ void main()
221
224
  if (util_logger.getOutputMode() === 'human') {
222
225
  selfUpdate();
223
226
  }
224
- process.exit(0);
227
+ // A command can finish normally and still report a non-zero code (e.g.
228
+ // `backend invoke` when the handler failed) via process.exitCode.
229
+ process.exit(process.exitCode ?? 0);
225
230
  })
226
231
  .catch(async (err) => {
227
232
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -54,3 +54,15 @@ export class ChannelError extends BackendRunnerError {
54
54
  this.name = 'ChannelError';
55
55
  }
56
56
  }
57
+
58
+ /**
59
+ * A sandbox run request the runner refuses before any code runs — the code is
60
+ * unusable or the app doesn't exist. Carries a `code` so
61
+ * the CLI can say exactly which, the same way a channel error does.
62
+ */
63
+ export class SandboxRequestError extends BackendRunnerError {
64
+ constructor(statusCode: number, message: string, public readonly code: string) {
65
+ super(statusCode, message);
66
+ this.name = 'SandboxRequestError';
67
+ }
68
+ }
@@ -34,6 +34,9 @@ export type ChannelHandler = (args: unknown[]) => Promise<unknown> | unknown;
34
34
  */
35
35
  export type ChannelMap = Record<string, ChannelHandler>;
36
36
 
37
+ /** Which path built the channels — a deployed handler (`invoke`) or source sent to the sandbox. Tags channel telemetry. */
38
+ export type RunMode = 'invoke' | 'sandbox';
39
+
37
40
  /**
38
41
  * A breakdown of where the isolate wall-clock went, in ms. Lets us see whether a
39
42
  * slow invoke is the V8 setup (create + compile) or the guest's own work + its
@@ -6,13 +6,19 @@ import { makeDbChannel } from './db-channel/db.channel';
6
6
  import { makeLlmChannel } from './llm-channel/llm.channel';
7
7
  import { makeMondayChannel } from './monday-channel/monday.channel';
8
8
  import { makeSnowflakeChannel } from './snowflake-channel/snowflake.channel';
9
- import type { Caller, ChannelMap } from '../backend-runner.types';
9
+ import type { Caller, ChannelMap, RunMode } from '../backend-runner.types';
10
10
  import type { RunnerCustomIntegration } from '../runner-data.store';
11
11
 
12
12
  const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
13
13
  monday: (caller) => makeMondayChannel(caller),
14
14
  };
15
15
 
16
+ /**
17
+ * Every integration with a registered channel. Sandbox runs build all of them —
18
+ * safe because each fetches the caller's token only when a method is called.
19
+ */
20
+ export const OAUTH_INTEGRATIONS = Object.keys(OAUTH_CHANNELS);
21
+
16
22
  /**
17
23
  * Builds the complete channel surface for one invocation — the only way out of
18
24
  * the isolate.
@@ -63,7 +69,7 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
63
69
  * setup) is wrong and retrying won't help; `502` means the upstream failed.
64
70
  *
65
71
  * Each key emits a unified call counter, duration distribution, and BI event tagged
66
- * by app, channel, method, and outcome. Uncaught failures also reach BI at the
72
+ * by app, mode (`invoke` or `sandbox`), channel, method, and outcome. Uncaught failures also reach BI at the
67
73
  * invocation level — see `runner.service.ts`.
68
74
  */
69
75
  export function buildChannels(
@@ -71,6 +77,7 @@ export function buildChannels(
71
77
  caller: Caller,
72
78
  declaredIntegrations: string[],
73
79
  customIntegrations: RunnerCustomIntegration[],
80
+ mode: RunMode,
74
81
  ): ChannelMap {
75
82
  const channels: ChannelMap = {
76
83
  ...makeDbChannel(appName),
@@ -91,5 +98,7 @@ export function buildChannels(
91
98
  }
92
99
  }
93
100
 
94
- return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, key, fn)]));
101
+ return Object.fromEntries(
102
+ Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, mode, key, fn)]),
103
+ );
95
104
  }
@@ -4,7 +4,7 @@ import { trackEvent } from '@mondaydotcomorg/z2h-shared-utils/observability/trid
4
4
 
5
5
  import { ChannelError } from '../backend-runner.errors';
6
6
  import { BI_SOURCE } from '../constants';
7
- import type { ChannelHandler } from '../backend-runner.types';
7
+ import type { ChannelHandler, RunMode } from '../backend-runner.types';
8
8
 
9
9
  type ChannelOutcome = 'success' | 'failure';
10
10
 
@@ -26,6 +26,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
26
26
 
27
27
  function recordChannelCall(
28
28
  appName: string,
29
+ mode: RunMode,
29
30
  channel: string,
30
31
  method: string,
31
32
  outcome: ChannelOutcome,
@@ -33,7 +34,7 @@ function recordChannelCall(
33
34
  error?: unknown,
34
35
  ): void {
35
36
  const durationMs = performance.now() - startedAt;
36
- const dimensions = { appName, channel, method };
37
+ const dimensions = { appName, mode, channel, method };
37
38
 
38
39
  metric.increment(BackendRunnerEvents.channel.callMetric, { ...dimensions, outcome });
39
40
  metric.distribution(BackendRunnerEvents.channel.durationMetric, durationMs, dimensions);
@@ -49,7 +50,12 @@ function recordChannelCall(
49
50
  );
50
51
  }
51
52
 
52
- export function withChannelTelemetry(appName: string, key: string, handler: ChannelHandler): ChannelHandler {
53
+ export function withChannelTelemetry(
54
+ appName: string,
55
+ mode: RunMode,
56
+ key: string,
57
+ handler: ChannelHandler,
58
+ ): ChannelHandler {
53
59
  const { channel, method } = parseChannelKey(key);
54
60
 
55
61
  return (args) => {
@@ -59,20 +65,20 @@ export function withChannelTelemetry(appName: string, key: string, handler: Chan
59
65
  if (isPromiseLike(result)) {
60
66
  return Promise.resolve(result).then(
61
67
  (value) => {
62
- recordChannelCall(appName, channel, method, 'success', startedAt);
68
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
63
69
  return value;
64
70
  },
65
71
  (error: unknown) => {
66
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
72
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
67
73
  throw error;
68
74
  },
69
75
  );
70
76
  }
71
77
 
72
- recordChannelCall(appName, channel, method, 'success', startedAt);
78
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
73
79
  return result;
74
80
  } catch (error) {
75
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
81
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
76
82
  throw error;
77
83
  }
78
84
  };
@@ -0,0 +1,52 @@
1
+ import { ChannelError } from '../backend-runner.errors';
2
+ import type { ChannelHandler, ChannelMap } from '../backend-runner.types';
3
+
4
+ /**
5
+ * One channel call as the sandbox reports it back to the author. Raw args are
6
+ * recorded — for `snowflake.query` that is the SQL text and its params, not the
7
+ * bound SQL the channel builds from them.
8
+ */
9
+ export interface ChannelCallTrace {
10
+ method: string;
11
+ args: unknown[];
12
+ durationMs: number;
13
+ ok: boolean;
14
+ /** Why the call failed — same shape as the run's `error`. A non-`ChannelError` is a 500 with no code. */
15
+ error?: { status: number; code?: string; message: string };
16
+ /** What the channel returned to the handler, as-is — e.g. Snowflake rows or monday's GraphQL `data`. */
17
+ result?: unknown;
18
+ }
19
+
20
+ type CallOutcome = Pick<ChannelCallTrace, 'ok' | 'error' | 'result'>;
21
+
22
+ function withChannelTrace(calls: ChannelCallTrace[], method: string, handler: ChannelHandler): ChannelHandler {
23
+ return async (args) => {
24
+ const startedAt = performance.now();
25
+ const record = (outcome: CallOutcome): void => {
26
+ calls.push({ method, args, durationMs: performance.now() - startedAt, ...outcome });
27
+ };
28
+
29
+ try {
30
+ const result = await handler(args);
31
+ record({ ok: true, result });
32
+ return result;
33
+ } catch (err) {
34
+ record({
35
+ ok: false,
36
+ error:
37
+ err instanceof ChannelError
38
+ ? { status: err.statusCode, code: err.code, message: err.message }
39
+ : { status: 500, message: err instanceof Error ? err.message : String(err) },
40
+ });
41
+ throw err;
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Wraps every channel so each call is appended to `calls` in the order it
48
+ * finished. Sandbox-only: the deployed invoke path never records a trace.
49
+ */
50
+ export function traceChannels(channels: ChannelMap, calls: ChannelCallTrace[]): ChannelMap {
51
+ return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTrace(calls, key, fn)]));
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-s3": "^3.706.0",
54
54
  "@mondaydotcomorg/z2h-dashboard-templates": "^0.4.0",
55
- "@mondaydotcomorg/z2h-shared-utils": "^0.7.0",
55
+ "@mondaydotcomorg/z2h-shared-utils": "^0.7.1",
56
56
  "acorn": "^8.14.0",
57
57
  "acorn-walk": "^8.3.4",
58
58
  "chokidar": "^3.6.0",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
2
2
 
3
- import { backendBaseUrl, declareIntegration, invokeBackend, listAppIntegrations, removeIntegration } from '../client';
3
+ import { backendBaseUrl, declareIntegration, listAppIntegrations, removeIntegration, runSandbox } from '../client';
4
4
 
5
5
  vi.mock('../../util/auth/auth-store', () => ({
6
6
  readZthAuth: vi.fn().mockResolvedValue({ token: 'jwt-token' }),
@@ -21,6 +21,8 @@ const respond = (init: { ok?: boolean; status?: number; statusText?: string; bod
21
21
 
22
22
  const lastCall = () => (fetch as Mock).mock.calls[0];
23
23
 
24
+ const SANDBOX_BODY = { code: 'function handler() {}', input: null };
25
+
24
26
  beforeEach(() => {
25
27
  vi.stubGlobal('fetch', respond({ body: '{"result":1,"durationMs":5}' }));
26
28
  });
@@ -42,13 +44,15 @@ describe('backendBaseUrl', () => {
42
44
  });
43
45
 
44
46
  describe('where each call goes', () => {
45
- it('sends an invoke to the runner, authenticated', async () => {
46
- await invokeBackend('mf-reports', 'get_items', { n: 1 });
47
+ it('sends a sandbox run to the runner, authenticated, with the code in the body', async () => {
48
+ const body = { code: 'function handler() {}', input: { n: 1 }, handlerName: 'get_items' };
49
+ await runSandbox('mf-reports', body);
47
50
 
48
51
  const [url, opts] = lastCall();
49
- expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/invoke/get_items');
52
+ expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/sandbox/run');
53
+ expect(opts.method).toBe('POST');
50
54
  expect(opts.headers).toMatchObject({ Authorization: 'Bearer jwt-token', 'x-route': 'prod' });
51
- expect(JSON.parse(opts.body)).toEqual({ input: { n: 1 } });
55
+ expect(JSON.parse(opts.body)).toEqual(body);
52
56
  });
53
57
 
54
58
  // Integrations live in the platform's registry, which the broker owns — not the runner.
@@ -72,9 +76,9 @@ describe('where each call goes', () => {
72
76
  });
73
77
 
74
78
  it('escapes an app name rather than letting it alter the path', async () => {
75
- await invokeBackend('mf-a/../b', 'get_items', null);
79
+ await runSandbox('mf-a/../b', SANDBOX_BODY);
76
80
 
77
- expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/invoke/get_items');
81
+ expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/sandbox/run');
78
82
  });
79
83
  });
80
84
 
@@ -85,27 +89,27 @@ describe('where each call goes', () => {
85
89
  */
86
90
  describe('what the author is told when it fails', () => {
87
91
  it('uses the server’s own message when there is one', async () => {
88
- vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"No backend deployed for \\"x\\""}' }));
92
+ vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
89
93
 
90
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow('No backend deployed for "x"');
94
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow('app "x" is not registered');
91
95
  });
92
96
 
93
97
  it('falls back to the status plus a snippet when the body is HTML', async () => {
94
98
  vi.stubGlobal('fetch', respond({ ok: false, status: 502, statusText: 'Bad Gateway', body: '<html>nginx</html>' }));
95
99
 
96
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
100
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
97
101
  });
98
102
 
99
103
  it('collapses whitespace in that snippet so the message stays one line', async () => {
100
104
  vi.stubGlobal('fetch', respond({ ok: false, status: 500, body: '<html>\n a\n\n b\n</html>' }));
101
105
 
102
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/<html> a b <\/html>/);
106
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/<html> a b <\/html>/);
103
107
  });
104
108
 
105
109
  it('names the URL it could not reach when the request never lands', async () => {
106
110
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
107
111
 
108
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(
112
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(
109
113
  /cannot reach https:\/\/zth-backend-runner.*ECONNREFUSED/
110
114
  );
111
115
  });
@@ -113,7 +117,7 @@ describe('what the author is told when it fails', () => {
113
117
  it('reports a 200 whose body is not JSON rather than returning nothing', async () => {
114
118
  vi.stubGlobal('fetch', respond({ body: '<html>login</html>' }));
115
119
 
116
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/non-JSON response/);
120
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/non-JSON response/);
117
121
  });
118
122
 
119
123
  // DELETE returns 204 with no body, which is success, not a parse failure.
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { formatSandboxRun } from '../format-sandbox-run';
4
+
5
+ describe('formatSandboxRun', () => {
6
+ it('shows each call with its SQL on one line, params, and row count, then the result', () => {
7
+ const text = formatSandboxRun({
8
+ ok: true,
9
+ calls: [
10
+ {
11
+ method: 'snowflake.query',
12
+ args: ['SELECT region\n FROM accounts\n WHERE d >= :since', { since: '2026-01-01' }],
13
+ durationMs: 812.4,
14
+ ok: true,
15
+ result: [{ r: 1 }, { r: 2 }, { r: 3 }, { r: 4 }],
16
+ },
17
+ ],
18
+ result: { regions: [] },
19
+ durationMs: 1200,
20
+ });
21
+
22
+ expect(text).toContain('sandbox run ✓ 1.2s');
23
+ expect(text).toContain('✓ snowflake.query 812ms 4 rows');
24
+ expect(text).toContain('SELECT region FROM accounts WHERE d >= :since');
25
+ expect(text).toContain('params: {"since":"2026-01-01"}');
26
+ expect(text).toContain('result:\n{\n "regions": []\n}');
27
+ });
28
+
29
+ it('ends with the error instead of a result on failure', () => {
30
+ const text = formatSandboxRun({
31
+ ok: false,
32
+ error: { status: 504, code: 'isolate_timeout', message: 'Script execution timed out.' },
33
+ calls: [],
34
+ result: null,
35
+ durationMs: 5000,
36
+ });
37
+
38
+ expect(text).toContain('sandbox run ✗ 5.0s');
39
+ expect(text).toContain('error: 504 isolate_timeout — Script execution timed out.');
40
+ expect(text).not.toContain('result:');
41
+ });
42
+ });
@@ -59,20 +59,37 @@ async function request<T>(method: string, baseUrl: string, path: string, body?:
59
59
  return (parsed ?? ({} as T)) as T;
60
60
  }
61
61
 
62
- export interface InvokeResponse {
62
+ export interface SandboxRunRequest {
63
+ code: string;
64
+ input: unknown;
65
+ handlerName?: string;
66
+ }
67
+
68
+ /** One channel call the handler made, as the runner traced it. */
69
+ export interface SandboxCall {
70
+ method: string;
71
+ args: unknown[];
72
+ durationMs: number;
73
+ ok: boolean;
74
+ error?: { status: number; code?: string; message: string };
75
+ result?: unknown;
76
+ }
77
+
78
+ export interface SandboxRunResponse {
79
+ ok: boolean;
80
+ error?: { status: number; code?: string; message: string };
81
+ calls: SandboxCall[];
63
82
  result: unknown;
64
83
  durationMs: number;
65
84
  }
66
85
 
67
- export function invokeBackend(appName: string, handlerName: string, input: unknown): Promise<InvokeResponse> {
68
- // The runner resolves the caller from the request or 401s, so an anonymous
69
- // invoke can never reach a handler.
70
- return request(
71
- 'POST',
72
- backendBaseUrl(),
73
- `/z2h-apps/${encodeURIComponent(appName)}/invoke/${encodeURIComponent(handlerName)}`,
74
- { input }
75
- );
86
+ /**
87
+ * Runs handler source from the working tree on the runner's sandbox route —
88
+ * against the app's real channels, without deploying it. A handler failure
89
+ * comes back as `ok: false` with the trace; only a refused request throws.
90
+ */
91
+ export function runSandbox(appName: string, body: SandboxRunRequest): Promise<SandboxRunResponse> {
92
+ return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/sandbox/run`, body);
76
93
  }
77
94
 
78
95
  export interface AppIntegrationsResponse {
@@ -0,0 +1,51 @@
1
+ import type { SandboxCall, SandboxRunResponse } from './client';
2
+
3
+ const MAX_SQL_CHARS = 400;
4
+
5
+ const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
6
+ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
7
+ const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max)}…` : text);
8
+
9
+ /** The first arg is the SQL / GraphQL text for query channels — shown on its own line so it can be read. */
10
+ function describeArgs(args: unknown[]): string[] {
11
+ const [first, ...rest] = args;
12
+ const lines: string[] = [];
13
+ if (typeof first === 'string') {
14
+ lines.push(clip(oneLine(first), MAX_SQL_CHARS));
15
+ } else if (first !== undefined) {
16
+ lines.push(clip(JSON.stringify(first), MAX_SQL_CHARS));
17
+ }
18
+ const params = rest.filter(arg => arg !== undefined && arg !== null);
19
+ if (params.length > 0) {
20
+ lines.push(`params: ${clip(params.map(p => JSON.stringify(p)).join(', '), MAX_SQL_CHARS)}`);
21
+ }
22
+ return lines;
23
+ }
24
+
25
+ function describeCall(call: SandboxCall): string[] {
26
+ const rows = Array.isArray(call.result) ? ` ${call.result.length} rows` : '';
27
+ const head = ` ${call.ok ? '✓' : '✗'} ${call.method} ${Math.round(call.durationMs)}ms${rows}`;
28
+ const detail = describeArgs(call.args);
29
+ if (call.error) {
30
+ const code = call.error.code ? ` ${call.error.code}` : '';
31
+ detail.push(`${call.error.status}${code}: ${call.error.message}`);
32
+ }
33
+ return [head, ...detail.map(line => ` ${line}`)];
34
+ }
35
+
36
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
37
+ export function formatSandboxRun(res: SandboxRunResponse): string {
38
+ const lines = [`[z2h-cli] sandbox run ${res.ok ? '✓' : '✗'} ${seconds(res.durationMs)}`, ''];
39
+
40
+ lines.push(res.calls.length > 0 ? 'calls:' : 'calls: none');
41
+ res.calls.forEach(call => lines.push(...describeCall(call)));
42
+
43
+ lines.push('');
44
+ if (res.ok) {
45
+ lines.push('result:', JSON.stringify(res.result, null, 2));
46
+ } else if (res.error) {
47
+ const code = res.error.code ? ` ${res.error.code}` : '';
48
+ lines.push(`error: ${res.error.status}${code} — ${res.error.message}`);
49
+ }
50
+ return lines.join('\n');
51
+ }
@@ -0,0 +1,125 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { backendInvokeCommand } from '../backend';
7
+ import { runSandbox } from '../../backend/client';
8
+ import { output } from '../../util/logger';
9
+
10
+ vi.mock('../../util/logger', () => ({
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ output: vi.fn(),
14
+ }));
15
+ vi.mock('../../util/tracker', () => ({
16
+ trackEvent: vi.fn().mockResolvedValue(undefined),
17
+ trackDuration: vi.fn(),
18
+ }));
19
+ vi.mock('../../util/s3/client', () => ({ getS3Client: vi.fn() }));
20
+ vi.mock('../../backend/client', () => ({ runSandbox: vi.fn() }));
21
+ vi.mock('../../util/app-name', async importOriginal => ({
22
+ ...(await importOriginal<typeof import('../../util/app-name')>()),
23
+ getCwdAppName: () => 'sales-kpis',
24
+ }));
25
+
26
+ const runSandboxMock = vi.mocked(runSandbox);
27
+ const outputMock = vi.mocked(output);
28
+
29
+ const OK = { ok: true, calls: [], result: { n: 1 }, durationMs: 12 };
30
+
31
+ describe('backendInvokeCommand', () => {
32
+ let tmp: string;
33
+
34
+ const writeHandler = async (name: string, source: string) => {
35
+ const dir = path.join(tmp, 'backend', 'handlers');
36
+ await ensureDir(dir);
37
+ await writeFile(path.join(dir, `${name}.js`), source);
38
+ };
39
+
40
+ beforeEach(async () => {
41
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-backend-invoke-'));
42
+ runSandboxMock.mockReset();
43
+ runSandboxMock.mockResolvedValue(OK);
44
+ outputMock.mockClear();
45
+ process.exitCode = undefined;
46
+ });
47
+
48
+ afterEach(async () => {
49
+ process.exitCode = undefined;
50
+ await rm(tmp, { recursive: true, force: true });
51
+ });
52
+
53
+ it('sends the handler file from the working tree', async () => {
54
+ const source = 'async function handler(ctx) { return ctx.api.v1.monday.query("query { me { id } }"); }';
55
+ await writeHandler('get_kpis', source);
56
+
57
+ await backendInvokeCommand('get_kpis', { input: '{"since":"2026-01-01"}' }, tmp);
58
+
59
+ expect(runSandboxMock).toHaveBeenCalledWith('mf-sales-kpis', {
60
+ code: source,
61
+ input: { since: '2026-01-01' },
62
+ handlerName: 'get_kpis',
63
+ });
64
+ expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('sandbox run ✓'), OK);
65
+ expect(process.exitCode).toBeUndefined();
66
+ });
67
+
68
+ it('runs --code instead of the file', async () => {
69
+ await backendInvokeCommand('scratch', { code: 'function handler() { return 1; }' }, tmp);
70
+
71
+ expect(runSandboxMock.mock.calls[0][1]).toMatchObject({ code: 'function handler() { return 1; }', input: null });
72
+ });
73
+
74
+ it('fails locally, without a request, when the source breaks an isolate rule', async () => {
75
+ await writeHandler('get_kpis', "import x from 'y';\nfunction handler() {}");
76
+
77
+ await expect(backendInvokeCommand('get_kpis', {}, tmp)).rejects.toThrow();
78
+ expect(runSandboxMock).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('names the available handlers when the one asked for does not exist', async () => {
82
+ await writeHandler('get_kpis', 'function handler() {}');
83
+ await writeHandler('list_items', 'function handler() {}');
84
+
85
+ await expect(backendInvokeCommand('get_kpi', {}, tmp)).rejects.toThrow(
86
+ 'no handler "get_kpi" in backend/handlers/ (available: get_kpis, list_items)'
87
+ );
88
+ });
89
+
90
+ it('rejects --input that is not JSON', async () => {
91
+ await expect(backendInvokeCommand('get_kpis', { input: '{bad' }, tmp)).rejects.toThrow(
92
+ /--input must be valid JSON/
93
+ );
94
+ });
95
+
96
+ it('prints the trace and exits 1 when the handler fails', async () => {
97
+ await writeHandler('get_kpis', 'async function handler(ctx) { return ctx.api.v1.snowflake.query("SELECT ar"); }');
98
+ const failed = {
99
+ ok: false,
100
+ error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
101
+ calls: [
102
+ {
103
+ method: 'snowflake.query',
104
+ args: ['SELECT ar'],
105
+ durationMs: 640,
106
+ ok: false,
107
+ error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
108
+ },
109
+ ],
110
+ result: null,
111
+ durationMs: 900,
112
+ };
113
+ runSandboxMock.mockResolvedValue(failed);
114
+
115
+ await backendInvokeCommand('get_kpis', {}, tmp);
116
+
117
+ const [human, data] = outputMock.mock.calls[0];
118
+ expect(data).toBe(failed);
119
+ expect(human).toContain('✗ snowflake.query 640ms');
120
+ expect(human).toContain('SELECT ar');
121
+ expect(human).toContain("400 snowflake_query_error: invalid identifier 'AR'");
122
+ expect(human).toContain("error: 400 snowflake_query_error — invalid identifier 'AR'");
123
+ expect(process.exitCode).toBe(1);
124
+ });
125
+ });
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readdir, readFile } from 'node:fs/promises';
3
4
  import { readJson, writeJson } from 'fs-extra';
4
5
  import { PutObjectCommand } from '@aws-sdk/client-s3';
5
6
  import { info, output } from '../util/logger';
@@ -9,7 +10,9 @@ import { readHandlers } from '../backend/handlers';
9
10
  import { detectIntegrations } from '../backend/detect-integrations';
10
11
  import { getS3Client } from '../util/s3/client';
11
12
  import { BUCKET_WRITE_NAME } from '../constants';
12
- import { invokeBackend } from '../backend/client';
13
+ import { runSandbox } from '../backend/client';
14
+ import { formatSandboxRun } from '../backend/format-sandbox-run';
15
+ import { validateHandlerSource } from '../backend/validate';
13
16
  import { tagDeployError } from '../util/errors';
14
17
 
15
18
  function currentAppName(): string {
@@ -125,7 +128,35 @@ export async function backendValidateCommand(appDir: string = process.cwd()): Pr
125
128
  });
126
129
  }
127
130
 
128
- export async function backendInvokeCommand(handlerName: string, opts: { input?: string } = {}): Promise<void> {
131
+ async function readHandlerSource(appDir: string, handlerName: string): Promise<string> {
132
+ const dir = path.join(appDir, 'backend', 'handlers');
133
+ try {
134
+ return await readFile(path.join(dir, `${handlerName}.js`), 'utf8');
135
+ } catch (err) {
136
+ if ((err as { code?: string }).code !== 'ENOENT') {
137
+ throw err;
138
+ }
139
+ const files = hasHandlers(appDir) ? await readdir(dir) : [];
140
+ const available = files
141
+ .filter(f => f.endsWith('.js'))
142
+ .map(f => path.parse(f).name)
143
+ .sort();
144
+ throw new Error(
145
+ `no handler "${handlerName}" in backend/handlers/` +
146
+ (available.length ? ` (available: ${available.join(', ')})` : ' (no handlers found)')
147
+ );
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
153
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
154
+ */
155
+ export async function backendInvokeCommand(
156
+ handlerName: string,
157
+ opts: { input?: string; code?: string } = {},
158
+ appDir: string = process.cwd()
159
+ ): Promise<void> {
129
160
  const appName = currentAppName();
130
161
  let input: unknown = null;
131
162
  if (opts.input) {
@@ -136,6 +167,14 @@ export async function backendInvokeCommand(handlerName: string, opts: { input?:
136
167
  }
137
168
  }
138
169
 
139
- const res = await invokeBackend(appName, handlerName, input);
140
- output(`[z2h-cli] result (${Math.round(res.durationMs)}ms):\n${JSON.stringify(res.result, null, 2)}`, res);
170
+ const file = `${handlerName}.js`;
171
+ const code = opts.code ?? (await readHandlerSource(appDir, handlerName));
172
+ validateHandlerSource(file, code);
173
+ info(`validated ${opts.code ? '--code' : file}`);
174
+
175
+ const res = await runSandbox(appName, { code, input, handlerName });
176
+ output(formatSandboxRun(res), res);
177
+ if (!res.ok) {
178
+ process.exitCode = 1;
179
+ }
141
180
  }
package/src/index.ts CHANGED
@@ -279,7 +279,7 @@ async function main(): Promise<void> {
279
279
 
280
280
  const backend = program
281
281
  .command('backend')
282
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
282
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
283
283
 
284
284
  backend
285
285
  .command('validate')
@@ -291,15 +291,21 @@ async function main(): Promise<void> {
291
291
  backend
292
292
  .command('invoke <handlerName>')
293
293
  .description(
294
- 'Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend'
294
+ "Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
295
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
296
+ 'result. Nothing is deployed. Exits 1 when the handler fails.'
297
+ )
298
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
299
+ .option(
300
+ '--code <source>',
301
+ 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label'
295
302
  )
296
- .option('--input <json>', 'JSON payload passed as ctx.input')
297
303
  .action(
298
304
  runCommand(
299
305
  {
300
306
  data: ([handlerName]: [string, unknown]) => ({ app_name: getCwdAppName(), handler: handlerName }),
301
307
  },
302
- (handlerName: string, opts: { input?: string }) => backendInvokeCommand(handlerName, opts)
308
+ (handlerName: string, opts: { input?: string; code?: string }) => backendInvokeCommand(handlerName, opts)
303
309
  )
304
310
  );
305
311
 
@@ -330,7 +336,9 @@ void main()
330
336
  if (getOutputMode() === 'human') {
331
337
  selfUpdate();
332
338
  }
333
- process.exit(0);
339
+ // A command can finish normally and still report a non-zero code (e.g.
340
+ // `backend invoke` when the handler failed) via process.exitCode.
341
+ process.exit(process.exitCode ?? 0);
334
342
  })
335
343
  .catch(async (err: unknown) => {
336
344
  const errorMsg = err instanceof Error ? err.message : String(err);