@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.
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/backend/client.d.ts +32 -2
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +8 -5
- package/dist/backend/format-sandbox-run.d.ts +4 -0
- package/dist/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/backend/format-sandbox-run.js +49 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -1
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +35 -3
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/esm/backend/client.d.ts +32 -2
- package/dist/esm/backend/client.d.ts.map +1 -1
- package/dist/esm/backend/client.mjs +8 -5
- package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
- package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/esm/backend/format-sandbox-run.mjs +47 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -1
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +36 -4
- package/dist/esm/index.mjs +9 -4
- package/dist/index.js +9 -4
- package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
- package/handler-api/backend-runner/backend-runner.types.ts +3 -0
- package/handler-api/backend-runner/channels/channel-map.ts +12 -3
- package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
- package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
- package/package.json +2 -2
- package/src/backend/__tests__/client.test.ts +17 -13
- package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
- package/src/backend/client.ts +27 -10
- package/src/backend/format-sandbox-run.ts +51 -0
- package/src/commands/__tests__/backend-invoke.test.ts +125 -0
- package/src/commands/backend.ts +43 -4
- package/src/index.ts +13 -5
|
@@ -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":""}
|
package/dist/backend/client.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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"}
|
package/dist/backend/client.js
CHANGED
|
@@ -56,10 +56,13 @@ async function request(method, baseUrl, path, body) {
|
|
|
56
56
|
}
|
|
57
57
|
return (parsed ?? {});
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/dist/commands/backend.js
CHANGED
|
@@ -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
|
|
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
|
|
126
|
-
|
|
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 @@
|
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
75
|
+
export { backendBaseUrl, declareIntegration, listAppIntegrations, removeIntegration, runSandbox };
|
|
@@ -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 @@
|
|
|
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
|
-
|
|
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":"
|
|
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 {
|
|
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
|
|
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
|
|
120
|
-
|
|
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 };
|
package/dist/esm/index.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
197
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
46
|
-
|
|
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/
|
|
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(
|
|
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
|
|
79
|
+
await runSandbox('mf-a/../b', SANDBOX_BODY);
|
|
76
80
|
|
|
77
|
-
expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/
|
|
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":"
|
|
92
|
+
vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
|
|
89
93
|
|
|
90
|
-
await expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
});
|
package/src/backend/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|
package/src/commands/backend.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|