@notis_ai/cli 0.2.6 → 0.2.7
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/README.md +2 -0
- package/dist/scaffolds/notis-affiliate-prospects/CHANGELOG.md +7 -0
- package/dist/scaffolds/notis-affiliate-prospects/README.md +20 -0
- package/dist/scaffolds/notis-affiliate-prospects/app/globals.css +42 -0
- package/dist/scaffolds/notis-affiliate-prospects/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-affiliate-prospects/app/page.tsx +248 -0
- package/dist/scaffolds/notis-affiliate-prospects/app/prospects/page.tsx +134 -0
- package/dist/scaffolds/notis-affiliate-prospects/app/segments/page.tsx +108 -0
- package/dist/scaffolds/notis-affiliate-prospects/components/ui/card.tsx +35 -0
- package/dist/scaffolds/notis-affiliate-prospects/components.json +20 -0
- package/dist/scaffolds/notis-affiliate-prospects/index.html +12 -0
- package/dist/scaffolds/notis-affiliate-prospects/lib/affiliate-data.ts +218 -0
- package/dist/scaffolds/notis-affiliate-prospects/lib/demo-prospects.ts +52 -0
- package/dist/scaffolds/notis-affiliate-prospects/lib/notis-tools.ts +100 -0
- package/dist/scaffolds/notis-affiliate-prospects/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-fixtures.json +187 -0
- package/dist/scaffolds/notis-affiliate-prospects/notis.config.ts +65 -0
- package/dist/scaffolds/notis-affiliate-prospects/package.json +32 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/package.json +32 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/config.ts +90 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useDatabase.ts +76 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/index.ts +54 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/runtime.ts +161 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/styles.css +38 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-affiliate-prospects/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-affiliate-prospects/src/dev-main.tsx +72 -0
- package/dist/scaffolds/notis-affiliate-prospects/src/mock-runtime.ts +242 -0
- package/dist/scaffolds/notis-affiliate-prospects/tailwind.config.ts +51 -0
- package/dist/scaffolds/notis-affiliate-prospects/tsconfig.json +23 -0
- package/dist/scaffolds/notis-affiliate-prospects/vite.config.ts +11 -0
- package/dist/scaffolds.json +11 -0
- package/package.json +1 -1
- package/src/cli.js +8 -1
- package/src/command-specs/diagnostics.js +674 -0
- package/src/command-specs/helpers.js +4 -1
- package/src/command-specs/index.js +6 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +187 -7
- package/src/runtime/output.js +12 -1
- package/src/runtime/profiles.js +35 -1
- package/src/runtime/transport.js +3 -0
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { appsCommandSpecs } from './apps.js';
|
|
2
2
|
import { toolsCommandSpecs } from './tools.js';
|
|
3
3
|
import { metaCommandSpecs } from './meta.js';
|
|
4
|
+
import { diagnosticCommandSpecs } from './diagnostics.js';
|
|
5
|
+
import { smokeCommandSpecs } from './smoke.js';
|
|
4
6
|
|
|
5
7
|
export const GROUP_SUMMARIES = {
|
|
6
8
|
apps: 'Develop, deploy, and submit Notis Apps.',
|
|
7
9
|
tools: 'Discover and execute generic tools exposed through Notis.',
|
|
10
|
+
debug: 'Inspect effective runtime context, worker identity, and trace costs.',
|
|
11
|
+
smoke: 'Run deterministic connected-service smoke tests with guaranteed cleanup.',
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
export const COMMAND_SPECS = [
|
|
11
15
|
...appsCommandSpecs,
|
|
12
16
|
...toolsCommandSpecs,
|
|
17
|
+
...diagnosticCommandSpecs,
|
|
18
|
+
...smokeCommandSpecs,
|
|
13
19
|
...metaCommandSpecs,
|
|
14
20
|
];
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
11
|
+
import { usageError } from '../runtime/errors.js';
|
|
12
|
+
import {
|
|
13
|
+
COMPOSIO_MULTI_EXECUTE_TOOL,
|
|
14
|
+
fetchToolDiscovery,
|
|
15
|
+
fetchToolSchema,
|
|
16
|
+
nextIdempotencyKey,
|
|
17
|
+
runToolCommand,
|
|
18
|
+
} from './helpers.js';
|
|
19
|
+
import { mintEntitlementOverride, unwrapToolExecutionPayload } from './diagnostics.js';
|
|
20
|
+
import { hashFileSha256, parseFileBindings } from './tools.js';
|
|
21
|
+
|
|
22
|
+
function discoveryNames(payload) {
|
|
23
|
+
return [...new Set(
|
|
24
|
+
(payload.results || []).flatMap((result) => [
|
|
25
|
+
...(result.primary_tool_slugs || []),
|
|
26
|
+
...(result.related_tool_slugs || []),
|
|
27
|
+
]).filter(Boolean),
|
|
28
|
+
)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function selectDropboxTools(payloads) {
|
|
32
|
+
const names = [...new Set(payloads.flatMap(discoveryNames))];
|
|
33
|
+
const find = (preferred, ...needles) => (
|
|
34
|
+
names.find((name) => name.toUpperCase() === preferred)
|
|
35
|
+
|| names.find((name) => {
|
|
36
|
+
const upper = name.toUpperCase();
|
|
37
|
+
return upper.includes('DROPBOX')
|
|
38
|
+
&& !upper.includes('ALPHA')
|
|
39
|
+
&& needles.every((needle) => upper.includes(needle));
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
const tools = {
|
|
43
|
+
upload: find('DROPBOX_UPLOAD_FILE', 'UPLOAD', 'FILE'),
|
|
44
|
+
metadata: find('DROPBOX_GET_METADATA', 'GET', 'METADATA'),
|
|
45
|
+
read: find('DROPBOX_READ_FILE', 'READ', 'FILE')
|
|
46
|
+
|| find('DROPBOX_DOWNLOAD_FILE', 'DOWNLOAD', 'FILE'),
|
|
47
|
+
delete: find('DROPBOX_DELETE_FILE', 'DELETE', 'FILE'),
|
|
48
|
+
};
|
|
49
|
+
const missing = Object.entries(tools).filter(([, value]) => !value).map(([key]) => key);
|
|
50
|
+
if (missing.length) {
|
|
51
|
+
throw usageError(`Dropbox smoke could not discover: ${missing.join(', ')}.`);
|
|
52
|
+
}
|
|
53
|
+
return tools;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchDiscoveryWithRetry(ctx, useCase, knownFields) {
|
|
57
|
+
const maxAttempts = 3;
|
|
58
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
59
|
+
try {
|
|
60
|
+
return await fetchToolDiscovery(ctx.runtime, useCase, knownFields);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!error?.retryable || attempt === maxAttempts) throw error;
|
|
63
|
+
ctx.output.emitProgress({
|
|
64
|
+
phase: 'discover-retry',
|
|
65
|
+
message: `Tool discovery transiently failed; retrying (${attempt}/${maxAttempts - 1})`,
|
|
66
|
+
requestId: error?.details?.request_id || null,
|
|
67
|
+
});
|
|
68
|
+
await delay(250 * (2 ** (attempt - 1)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw usageError('Dropbox tool discovery exhausted its retries.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function targetResponse(payload) {
|
|
75
|
+
const unwrapped = unwrapToolExecutionPayload(payload);
|
|
76
|
+
if (unwrapped?.results?.[0]?.response) return unwrapped.results[0].response;
|
|
77
|
+
if (payload?.data?.results?.[0]?.response) return payload.data.results[0].response;
|
|
78
|
+
if (payload?.results?.[0]?.response) return payload.results[0].response;
|
|
79
|
+
return unwrapped;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireSuccessfulResponse(payload, phase) {
|
|
83
|
+
const response = targetResponse(payload);
|
|
84
|
+
if (!response || response.successful === false || payload?.successful === false) {
|
|
85
|
+
throw usageError(`Dropbox ${phase} failed.`, { phase, response });
|
|
86
|
+
}
|
|
87
|
+
return response.data ?? response;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function executeDropboxTool(ctx, toolName, args, {
|
|
91
|
+
mutating,
|
|
92
|
+
fileBindings = [],
|
|
93
|
+
phase,
|
|
94
|
+
}) {
|
|
95
|
+
ctx.output.emitProgress({ phase, message: `${phase} via ${toolName}` });
|
|
96
|
+
const baseIdempotencyKey = mutating ? nextIdempotencyKey(ctx.globalOptions) : null;
|
|
97
|
+
const result = await runToolCommand({
|
|
98
|
+
runtime: ctx.runtime,
|
|
99
|
+
toolName: COMPOSIO_MULTI_EXECUTE_TOOL,
|
|
100
|
+
arguments_: { tools: [{ tool_slug: toolName, arguments: args }] },
|
|
101
|
+
mutating,
|
|
102
|
+
idempotencyKey: baseIdempotencyKey ? `${baseIdempotencyKey}:${phase}` : null,
|
|
103
|
+
fileBindings,
|
|
104
|
+
});
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function remotePath(folder, name) {
|
|
109
|
+
const normalizedFolder = `/${String(folder || '/Notis Tests/cli-file-upload')
|
|
110
|
+
.split('/')
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join('/')}`;
|
|
113
|
+
return `${normalizedFolder}/${name}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function smokeFileUploadHandler(ctx) {
|
|
117
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'notis-smoke-file-upload-'));
|
|
118
|
+
const marker = `${new Date().toISOString().replaceAll(/[-:.]/g, '')}-${randomUUID().slice(0, 8)}`;
|
|
119
|
+
const generatedFixture = !ctx.options.sourceFile;
|
|
120
|
+
const fixturePath = ctx.options.sourceFile || join(tempDir, `notis-file-upload-${marker}.txt`);
|
|
121
|
+
if (generatedFixture) {
|
|
122
|
+
writeFileSync(
|
|
123
|
+
fixturePath,
|
|
124
|
+
`Notis Dropbox file-upload smoke\nmarker=${marker}\nunicode=été Zürich 東京\n`,
|
|
125
|
+
'utf-8',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const localHash = await hashFileSha256(fixturePath);
|
|
129
|
+
const localBytes = readFileSync(fixturePath);
|
|
130
|
+
const destination = remotePath(
|
|
131
|
+
ctx.options.remoteFolder,
|
|
132
|
+
`notis-file-upload-${marker}-${basename(fixturePath)}`,
|
|
133
|
+
);
|
|
134
|
+
const result = {
|
|
135
|
+
marker,
|
|
136
|
+
destination,
|
|
137
|
+
local: { bytes: localBytes.length, sha256: localHash },
|
|
138
|
+
phases: [],
|
|
139
|
+
request_ids: {},
|
|
140
|
+
cleanup: { attempted: false, deleted: false, not_found_verified: false },
|
|
141
|
+
parity: null,
|
|
142
|
+
};
|
|
143
|
+
let uploadAttempted = false;
|
|
144
|
+
let deleted = false;
|
|
145
|
+
let revision = null;
|
|
146
|
+
let tools;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
ctx.output.emitProgress({ phase: 'discover', message: 'Resolving Dropbox upload, metadata, read, and delete capabilities' });
|
|
150
|
+
// Discover sequentially. Both calls can materialize the same provider session,
|
|
151
|
+
// and racing that initialization has caused otherwise healthy smoke runs to
|
|
152
|
+
// fail with a transient "Server disconnected" before any upload occurred.
|
|
153
|
+
const discoveries = [
|
|
154
|
+
await fetchDiscoveryWithRetry(
|
|
155
|
+
ctx,
|
|
156
|
+
'Upload a local file to Dropbox, get its exact metadata, and read the same file bytes back',
|
|
157
|
+
'path, content, strict_conflict',
|
|
158
|
+
),
|
|
159
|
+
await fetchDiscoveryWithRetry(
|
|
160
|
+
ctx,
|
|
161
|
+
'Delete one Dropbox file by exact canonical path and parent revision, then verify metadata reports path not found',
|
|
162
|
+
'path, parent_rev',
|
|
163
|
+
),
|
|
164
|
+
];
|
|
165
|
+
tools = selectDropboxTools(discoveries);
|
|
166
|
+
const uploadSchema = await fetchToolSchema(ctx.runtime, tools.upload);
|
|
167
|
+
if (uploadSchema.parameters?.properties?.content?.file_uploadable !== true) {
|
|
168
|
+
throw usageError(`${tools.upload} content is not marked file_uploadable.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (ctx.options.parityUser) {
|
|
172
|
+
const minted = await mintEntitlementOverride(ctx, ctx.options.parityUser);
|
|
173
|
+
ctx.runtime.debugEntitlementOverride = minted.override;
|
|
174
|
+
result.parity = {
|
|
175
|
+
requested: true,
|
|
176
|
+
expected_parity_hash: minted.parity_hash,
|
|
177
|
+
reference_scope_type: minted.reference_scope_type,
|
|
178
|
+
persistent: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fileBindings = await parseFileBindings([`content=${fixturePath}`]);
|
|
183
|
+
uploadAttempted = true;
|
|
184
|
+
const upload = await executeDropboxTool(
|
|
185
|
+
ctx,
|
|
186
|
+
tools.upload,
|
|
187
|
+
{ path: destination, strict_conflict: true, mode: 'add', autorename: false },
|
|
188
|
+
{ mutating: true, fileBindings, phase: 'upload' },
|
|
189
|
+
);
|
|
190
|
+
result.request_ids.upload = upload.requestId;
|
|
191
|
+
const uploadData = requireSuccessfulResponse(upload.payload, 'upload');
|
|
192
|
+
const appliedParity = upload.payload?.debug_entitlement_override;
|
|
193
|
+
if (result.parity) {
|
|
194
|
+
if (!appliedParity?.applied || appliedParity.parity_hash !== result.parity.expected_parity_hash) {
|
|
195
|
+
throw usageError('Backend did not assert the requested entitlement parity before upload.');
|
|
196
|
+
}
|
|
197
|
+
result.parity.applied = true;
|
|
198
|
+
result.parity.expires_at = appliedParity.expires_at;
|
|
199
|
+
}
|
|
200
|
+
revision = uploadData.rev;
|
|
201
|
+
result.upload = {
|
|
202
|
+
path: uploadData.path_display,
|
|
203
|
+
revision,
|
|
204
|
+
bytes: uploadData.size,
|
|
205
|
+
provider_content_hash: uploadData.content_hash,
|
|
206
|
+
};
|
|
207
|
+
result.phases.push('upload');
|
|
208
|
+
|
|
209
|
+
const metadata = await executeDropboxTool(
|
|
210
|
+
ctx,
|
|
211
|
+
tools.metadata,
|
|
212
|
+
{ path: uploadData.path_display || destination },
|
|
213
|
+
{ mutating: false, phase: 'metadata' },
|
|
214
|
+
);
|
|
215
|
+
result.request_ids.metadata = metadata.requestId;
|
|
216
|
+
const metadataData = requireSuccessfulResponse(metadata.payload, 'metadata').metadata;
|
|
217
|
+
if (!metadataData || metadataData.rev !== revision || Number(metadataData.size) !== localBytes.length) {
|
|
218
|
+
throw usageError('Dropbox metadata did not match the uploaded file revision and byte count.');
|
|
219
|
+
}
|
|
220
|
+
result.metadata = {
|
|
221
|
+
path: metadataData.path_display,
|
|
222
|
+
revision: metadataData.rev,
|
|
223
|
+
bytes: metadataData.size,
|
|
224
|
+
};
|
|
225
|
+
result.phases.push('metadata');
|
|
226
|
+
|
|
227
|
+
const read = await executeDropboxTool(
|
|
228
|
+
ctx,
|
|
229
|
+
tools.read,
|
|
230
|
+
{ path: metadataData.path_display },
|
|
231
|
+
{ mutating: false, phase: 'download' },
|
|
232
|
+
);
|
|
233
|
+
result.request_ids.download = read.requestId;
|
|
234
|
+
const readData = requireSuccessfulResponse(read.payload, 'read');
|
|
235
|
+
const signedUrl = readData.content?.s3url;
|
|
236
|
+
if (!signedUrl) throw usageError('Dropbox read did not return a temporary download URL.');
|
|
237
|
+
const downloadResponse = await fetch(signedUrl);
|
|
238
|
+
if (!downloadResponse.ok) {
|
|
239
|
+
throw usageError(`Dropbox temporary download returned HTTP ${downloadResponse.status}.`);
|
|
240
|
+
}
|
|
241
|
+
const downloaded = Buffer.from(await downloadResponse.arrayBuffer());
|
|
242
|
+
const downloadedHash = createHash('sha256').update(downloaded).digest('hex');
|
|
243
|
+
if (downloadedHash !== localHash) {
|
|
244
|
+
throw usageError('Downloaded Dropbox bytes did not match the uploaded fixture hash.');
|
|
245
|
+
}
|
|
246
|
+
result.download = {
|
|
247
|
+
bytes: downloaded.length,
|
|
248
|
+
sha256: downloadedHash,
|
|
249
|
+
hash_match: true,
|
|
250
|
+
};
|
|
251
|
+
result.phases.push('download');
|
|
252
|
+
|
|
253
|
+
result.cleanup.attempted = true;
|
|
254
|
+
const deletion = await executeDropboxTool(
|
|
255
|
+
ctx,
|
|
256
|
+
tools.delete,
|
|
257
|
+
{ path: metadataData.path_display, parent_rev: revision },
|
|
258
|
+
{ mutating: true, phase: 'delete' },
|
|
259
|
+
);
|
|
260
|
+
result.request_ids.delete = deletion.requestId;
|
|
261
|
+
requireSuccessfulResponse(deletion.payload, 'delete');
|
|
262
|
+
result.cleanup.deleted = true;
|
|
263
|
+
result.phases.push('delete');
|
|
264
|
+
|
|
265
|
+
const finalMetadata = await executeDropboxTool(
|
|
266
|
+
ctx,
|
|
267
|
+
tools.metadata,
|
|
268
|
+
{ path: metadataData.path_display },
|
|
269
|
+
{ mutating: false, phase: 'verify-not-found' },
|
|
270
|
+
);
|
|
271
|
+
result.request_ids.verify_not_found = finalMetadata.requestId;
|
|
272
|
+
const finalResponse = targetResponse(finalMetadata.payload);
|
|
273
|
+
const finalText = JSON.stringify(finalResponse);
|
|
274
|
+
if (finalResponse?.successful !== false || !finalText.includes('path/not_found')) {
|
|
275
|
+
throw usageError('Dropbox cleanup verification did not return path/not_found.');
|
|
276
|
+
}
|
|
277
|
+
result.cleanup.not_found_verified = true;
|
|
278
|
+
deleted = true;
|
|
279
|
+
result.phases.push('verify-not-found');
|
|
280
|
+
ctx.output.emitProgress({ phase: 'complete', message: 'Dropbox file-upload smoke passed with verified cleanup' });
|
|
281
|
+
return ctx.output.emitSuccess({
|
|
282
|
+
command: ctx.spec.command_path.join(' '),
|
|
283
|
+
data: { ...result, tools },
|
|
284
|
+
humanSummary: `Dropbox file-upload smoke passed for ${destination}; SHA-256 matched and cleanup was verified.`,
|
|
285
|
+
requestId: result.request_ids.verify_not_found,
|
|
286
|
+
meta: { mutating: true, cleaned_up: true },
|
|
287
|
+
renderHuman: () => JSON.stringify({ ...result, tools }, null, 2),
|
|
288
|
+
});
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error && typeof error === 'object') {
|
|
291
|
+
error.details = {
|
|
292
|
+
...(error.details || {}),
|
|
293
|
+
smoke_result: { ...result, tools },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
} finally {
|
|
298
|
+
if (uploadAttempted && !result.cleanup.not_found_verified && tools) {
|
|
299
|
+
result.cleanup.attempted = true;
|
|
300
|
+
try {
|
|
301
|
+
let cleanupRevision = revision;
|
|
302
|
+
if (!cleanupRevision) {
|
|
303
|
+
const recoveryMetadata = await executeDropboxTool(
|
|
304
|
+
ctx,
|
|
305
|
+
tools.metadata,
|
|
306
|
+
{ path: destination },
|
|
307
|
+
{ mutating: false, phase: 'recover-cleanup-metadata' },
|
|
308
|
+
);
|
|
309
|
+
result.request_ids.recover_cleanup_metadata = recoveryMetadata.requestId;
|
|
310
|
+
const recoveryResponse = targetResponse(recoveryMetadata.payload);
|
|
311
|
+
const recoveryText = JSON.stringify(recoveryResponse);
|
|
312
|
+
if (recoveryResponse?.successful === false && recoveryText.includes('path/not_found')) {
|
|
313
|
+
result.cleanup.deleted = true;
|
|
314
|
+
result.cleanup.not_found_verified = true;
|
|
315
|
+
} else {
|
|
316
|
+
const recoveryData = requireSuccessfulResponse(recoveryMetadata.payload, 'cleanup metadata');
|
|
317
|
+
cleanupRevision = recoveryData.metadata?.rev || recoveryData.rev;
|
|
318
|
+
if (!cleanupRevision) {
|
|
319
|
+
throw usageError('Dropbox cleanup metadata did not return the uploaded file revision.');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!result.cleanup.not_found_verified) {
|
|
324
|
+
const cleanup = await executeDropboxTool(
|
|
325
|
+
ctx,
|
|
326
|
+
tools.delete,
|
|
327
|
+
{ path: destination, parent_rev: cleanupRevision },
|
|
328
|
+
{ mutating: true, phase: 'cleanup-after-failure' },
|
|
329
|
+
);
|
|
330
|
+
result.request_ids.cleanup_after_failure = cleanup.requestId;
|
|
331
|
+
const cleanupResponse = targetResponse(cleanup.payload);
|
|
332
|
+
const cleanupText = JSON.stringify(cleanupResponse);
|
|
333
|
+
result.cleanup.deleted = (
|
|
334
|
+
cleanupResponse?.successful === true
|
|
335
|
+
|| cleanupText.includes('path/not_found')
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const verification = await executeDropboxTool(
|
|
339
|
+
ctx,
|
|
340
|
+
tools.metadata,
|
|
341
|
+
{ path: destination },
|
|
342
|
+
{ mutating: false, phase: 'verify-cleanup-after-failure' },
|
|
343
|
+
);
|
|
344
|
+
result.request_ids.verify_cleanup_after_failure = verification.requestId;
|
|
345
|
+
const verificationResponse = targetResponse(verification.payload);
|
|
346
|
+
const verificationText = JSON.stringify(verificationResponse);
|
|
347
|
+
result.cleanup.not_found_verified = (
|
|
348
|
+
verificationResponse?.successful === false
|
|
349
|
+
&& verificationText.includes('path/not_found')
|
|
350
|
+
);
|
|
351
|
+
deleted = result.cleanup.not_found_verified;
|
|
352
|
+
result.cleanup.deleted = deleted;
|
|
353
|
+
} catch {
|
|
354
|
+
deleted = false;
|
|
355
|
+
result.cleanup.deleted = false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const smokeCommandSpecs = [
|
|
363
|
+
{
|
|
364
|
+
command_path: ['smoke', 'file-upload'],
|
|
365
|
+
summary: 'Run a complete Dropbox upload/read/hash/delete smoke test.',
|
|
366
|
+
when_to_use: 'Use to prove the local-file bridge and connected Dropbox behavior under the current or parity entitlement context.',
|
|
367
|
+
args_schema: {
|
|
368
|
+
arguments: [],
|
|
369
|
+
options: [
|
|
370
|
+
{ flags: '--source-file <path>', description: 'Use an existing local fixture instead of generating one.' },
|
|
371
|
+
{ flags: '--remote-folder <path>', description: 'Unique Dropbox parent folder (default /Notis Tests/cli-file-upload).' },
|
|
372
|
+
{ flags: '--parity-user <user>', description: 'Apply this reference user’s effective plan for only the smoke requests.' },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
examples: [
|
|
376
|
+
'notis smoke file-upload --json',
|
|
377
|
+
'notis smoke file-upload --parity-user user@example.com --json',
|
|
378
|
+
],
|
|
379
|
+
output_schema: 'Returns structured phase results, request ids, metadata, hashes, parity proof, and cleanup verification.',
|
|
380
|
+
mutates: true,
|
|
381
|
+
idempotent: true,
|
|
382
|
+
related_commands: ['notis debug user-context <user>', 'notis debug entitlement-override <reference-user>'],
|
|
383
|
+
backend_call: { type: 'tool-discovery', name: 'Dropbox upload/read/delete capabilities' },
|
|
384
|
+
handler: smokeFileUploadHandler,
|
|
385
|
+
},
|
|
386
|
+
];
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
validateArguments,
|
|
18
18
|
} from './helpers.js';
|
|
19
19
|
|
|
20
|
-
async function resolveJsonInput(value, label) {
|
|
20
|
+
export async function resolveJsonInput(value, label) {
|
|
21
21
|
if (!value) return undefined;
|
|
22
22
|
|
|
23
23
|
if (value === '-') {
|
|
@@ -39,6 +39,159 @@ async function resolveJsonInput(value, label) {
|
|
|
39
39
|
return parseJson(value, label);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const READ_ACTIONS = new Set([
|
|
43
|
+
'CHECK',
|
|
44
|
+
'DESCRIBE',
|
|
45
|
+
'DOWNLOAD',
|
|
46
|
+
'FETCH',
|
|
47
|
+
'FIND',
|
|
48
|
+
'GET',
|
|
49
|
+
'INSPECT',
|
|
50
|
+
'LIST',
|
|
51
|
+
'LOOKUP',
|
|
52
|
+
'PREVIEW',
|
|
53
|
+
'QUERY',
|
|
54
|
+
'READ',
|
|
55
|
+
'SEARCH',
|
|
56
|
+
'STATUS',
|
|
57
|
+
'VERIFY',
|
|
58
|
+
]);
|
|
59
|
+
const WRITE_ACTIONS = new Set([
|
|
60
|
+
'AUTHENTIFY',
|
|
61
|
+
'CANCEL',
|
|
62
|
+
'CONNECT',
|
|
63
|
+
'COPY',
|
|
64
|
+
'CREATE',
|
|
65
|
+
'DELETE',
|
|
66
|
+
'DEPLOY',
|
|
67
|
+
'DISCARD',
|
|
68
|
+
'EDIT',
|
|
69
|
+
'GRANT',
|
|
70
|
+
'INCREMENT',
|
|
71
|
+
'INSERT',
|
|
72
|
+
'LINK',
|
|
73
|
+
'MOVE',
|
|
74
|
+
'POST',
|
|
75
|
+
'PUBLISH',
|
|
76
|
+
'REFUND',
|
|
77
|
+
'REMOVE',
|
|
78
|
+
'RENAME',
|
|
79
|
+
'SAVE',
|
|
80
|
+
'SEND',
|
|
81
|
+
'SUBMIT',
|
|
82
|
+
'UPDATE',
|
|
83
|
+
'UPLOAD',
|
|
84
|
+
'UPSERT',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
function splitSqlStatements(query) {
|
|
88
|
+
const statements = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
let quote = null;
|
|
91
|
+
let dollarTag = null;
|
|
92
|
+
for (let index = 0; index < query.length; index += 1) {
|
|
93
|
+
const char = query[index];
|
|
94
|
+
if (dollarTag) {
|
|
95
|
+
if (query.startsWith(dollarTag, index)) {
|
|
96
|
+
current += dollarTag;
|
|
97
|
+
index += dollarTag.length - 1;
|
|
98
|
+
dollarTag = null;
|
|
99
|
+
} else {
|
|
100
|
+
current += char;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (quote) {
|
|
105
|
+
current += char;
|
|
106
|
+
if (char === quote) {
|
|
107
|
+
if (query[index + 1] === quote) {
|
|
108
|
+
current += query[index + 1];
|
|
109
|
+
index += 1;
|
|
110
|
+
} else {
|
|
111
|
+
quote = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (char === "'" || char === '"') {
|
|
117
|
+
quote = char;
|
|
118
|
+
current += char;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (char === '$') {
|
|
122
|
+
const match = query.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/);
|
|
123
|
+
if (match) {
|
|
124
|
+
dollarTag = match[0];
|
|
125
|
+
current += dollarTag;
|
|
126
|
+
index += dollarTag.length - 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (char === ';') {
|
|
131
|
+
if (current.trim()) statements.push(current.trim());
|
|
132
|
+
current = '';
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
current += char;
|
|
136
|
+
}
|
|
137
|
+
if (current.trim()) statements.push(current.trim());
|
|
138
|
+
return statements;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function classifySqlMutation(query) {
|
|
142
|
+
const statements = splitSqlStatements(query);
|
|
143
|
+
if (statements.length > 1) {
|
|
144
|
+
const classifications = statements.map(classifySqlMutation);
|
|
145
|
+
if (classifications.includes(true)) return true;
|
|
146
|
+
if (classifications.every((classification) => classification === false)) return false;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const statement = statements[0] || '';
|
|
150
|
+
if (statement) {
|
|
151
|
+
const normalizedStatement = statement.toUpperCase();
|
|
152
|
+
if (/^EXPLAIN\b/.test(normalizedStatement)) {
|
|
153
|
+
const executes = /^EXPLAIN\s+(?:\([^)]*\bANALYZE\b[^)]*\)\s*|ANALYZE\s+)/.test(normalizedStatement);
|
|
154
|
+
if (!executes) return false;
|
|
155
|
+
return /\b(ALTER|CREATE|DELETE|DROP|GRANT|INSERT|MERGE|REVOKE|TRUNCATE|UPDATE)\b/.test(normalizedStatement);
|
|
156
|
+
}
|
|
157
|
+
if (/^SHOW\b/.test(normalizedStatement)) return false;
|
|
158
|
+
// PostgreSQL SELECT can call side-effecting functions. Without catalog
|
|
159
|
+
// knowledge it is not provably read-only, so preserve idempotency and report
|
|
160
|
+
// an unknown classification instead of claiming mutating=false.
|
|
161
|
+
if (/^SELECT\b/.test(normalizedStatement)) return null;
|
|
162
|
+
if (/^WITH\b/.test(normalizedStatement)) {
|
|
163
|
+
return /\b(ALTER|CREATE|DELETE|DROP|GRANT|INSERT|MERGE|REVOKE|TRUNCATE|UPDATE)\b/.test(normalizedStatement)
|
|
164
|
+
? true
|
|
165
|
+
: null;
|
|
166
|
+
}
|
|
167
|
+
if (/^(ALTER|CREATE|DELETE|DROP|GRANT|INSERT|MERGE|REVOKE|TRUNCATE|UPDATE)\b/.test(normalizedStatement)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function classifyToolMutation(toolName, args = {}) {
|
|
175
|
+
const normalized = localNotisToolSlug(toolName).toUpperCase();
|
|
176
|
+
if (normalized.includes('EXECUTE_SQL') && typeof args.query === 'string') {
|
|
177
|
+
const query = args.query
|
|
178
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
179
|
+
.replace(/--[^\n]*/g, ' ')
|
|
180
|
+
.trim();
|
|
181
|
+
const sqlClassification = classifySqlMutation(query);
|
|
182
|
+
if (sqlClassification !== null) return sqlClassification;
|
|
183
|
+
}
|
|
184
|
+
if (/(?:^|_)(?:FIND|GET|LOOKUP|SEARCH)_OR_(?:CREATE|INSERT|SAVE|UPDATE|UPSERT)(?:_|$)/.test(normalized)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
const tokens = normalized.split(/[^A-Z0-9]+/).filter(Boolean);
|
|
188
|
+
for (const token of tokens) {
|
|
189
|
+
if (WRITE_ACTIONS.has(token)) return true;
|
|
190
|
+
if (READ_ACTIONS.has(token)) return false;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
42
195
|
const LONG_RUNNING_TOOL_TIMEOUT_MS = 600000;
|
|
43
196
|
const LONG_RUNNING_TOOL_NAMES = new Set([
|
|
44
197
|
'LOCAL_NOTIS_GENERATE_IMAGE_OPENAI',
|
|
@@ -67,7 +220,7 @@ function guessContentType(filePath) {
|
|
|
67
220
|
return EXTENSION_CONTENT_TYPES.get(lower.slice(dotIndex)) || 'application/octet-stream';
|
|
68
221
|
}
|
|
69
222
|
|
|
70
|
-
async function hashFileSha256(localPath) {
|
|
223
|
+
export async function hashFileSha256(localPath) {
|
|
71
224
|
const hash = createHash('sha256');
|
|
72
225
|
for await (const chunk of createReadStream(localPath)) {
|
|
73
226
|
hash.update(chunk);
|
|
@@ -123,7 +276,7 @@ async function parseFileBindingSpec(spec, index) {
|
|
|
123
276
|
};
|
|
124
277
|
}
|
|
125
278
|
|
|
126
|
-
async function parseFileBindings(rawFileOptions) {
|
|
279
|
+
export async function parseFileBindings(rawFileOptions) {
|
|
127
280
|
const values = Array.isArray(rawFileOptions)
|
|
128
281
|
? rawFileOptions
|
|
129
282
|
: (rawFileOptions ? [rawFileOptions] : []);
|
|
@@ -227,7 +380,12 @@ async function toolsDescribeHandler(ctx) {
|
|
|
227
380
|
|
|
228
381
|
async function toolsExecHandler(ctx) {
|
|
229
382
|
const requestedToolName = localNotisToolSlug(ctx.args.toolName);
|
|
383
|
+
let targetMutating = classifyToolMutation(requestedToolName);
|
|
230
384
|
ensureLongRunningToolTimeout(ctx, requestedToolName);
|
|
385
|
+
ctx.output.emitProgress({
|
|
386
|
+
phase: 'prepare',
|
|
387
|
+
message: `Resolving ${requestedToolName}`,
|
|
388
|
+
});
|
|
231
389
|
const fileBindings = await parseFileBindings(ctx.options.file);
|
|
232
390
|
|
|
233
391
|
if (ctx.options.getSchema) {
|
|
@@ -243,10 +401,16 @@ async function toolsExecHandler(ctx) {
|
|
|
243
401
|
});
|
|
244
402
|
}
|
|
245
403
|
|
|
246
|
-
|
|
404
|
+
if (ctx.options.argumentsFile && ctx.options.arguments) {
|
|
405
|
+
throw usageError('Use either --arguments or --arguments-file, not both.');
|
|
406
|
+
}
|
|
407
|
+
const rawArguments = ctx.options.argumentsFile
|
|
408
|
+
? `@${ctx.options.argumentsFile}`
|
|
409
|
+
: (ctx.options.arguments || '{}');
|
|
247
410
|
const args = rawArguments === '-' || rawArguments.startsWith('@')
|
|
248
411
|
? await resolveJsonInput(rawArguments, 'arguments') || {}
|
|
249
412
|
: parseMaybeJson(rawArguments, 'arguments') || {};
|
|
413
|
+
targetMutating = classifyToolMutation(requestedToolName, args);
|
|
250
414
|
|
|
251
415
|
if (ctx.options.dryRun) {
|
|
252
416
|
if (fileBindings.length) {
|
|
@@ -278,6 +442,10 @@ async function toolsExecHandler(ctx) {
|
|
|
278
442
|
}
|
|
279
443
|
|
|
280
444
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
445
|
+
ctx.output.emitProgress({
|
|
446
|
+
phase: 'execute',
|
|
447
|
+
message: `Calling ${requestedToolName}`,
|
|
448
|
+
});
|
|
281
449
|
|
|
282
450
|
const result = await runToolCommand({
|
|
283
451
|
runtime: ctx.runtime,
|
|
@@ -285,16 +453,26 @@ async function toolsExecHandler(ctx) {
|
|
|
285
453
|
arguments_: {
|
|
286
454
|
tools: [{ tool_slug: requestedToolName, arguments: args }],
|
|
287
455
|
},
|
|
288
|
-
mutating:
|
|
289
|
-
idempotencyKey,
|
|
456
|
+
mutating: targetMutating !== false,
|
|
457
|
+
idempotencyKey: targetMutating === false ? null : idempotencyKey,
|
|
290
458
|
fileBindings,
|
|
291
459
|
});
|
|
460
|
+
ctx.output.emitProgress({
|
|
461
|
+
phase: 'complete',
|
|
462
|
+
message: `Finished ${requestedToolName}`,
|
|
463
|
+
requestId: result.requestId,
|
|
464
|
+
});
|
|
292
465
|
|
|
293
466
|
return ctx.output.emitSuccess({
|
|
294
467
|
command: ctx.spec.command_path.join(' '),
|
|
295
468
|
data: result.payload,
|
|
296
469
|
humanSummary: `Executed tool ${requestedToolName}`,
|
|
297
|
-
|
|
470
|
+
requestId: result.requestId,
|
|
471
|
+
meta: {
|
|
472
|
+
mutating: targetMutating,
|
|
473
|
+
mutation_classification: targetMutating === null ? 'unknown' : 'inferred',
|
|
474
|
+
idempotency_key: targetMutating === false ? null : idempotencyKey,
|
|
475
|
+
},
|
|
298
476
|
renderHuman: () => JSON.stringify(result.payload, null, 2),
|
|
299
477
|
});
|
|
300
478
|
}
|
|
@@ -441,6 +619,7 @@ export const toolsCommandSpecs = [
|
|
|
441
619
|
arguments: [{ token: '<tool-name>', description: 'Tool name returned by `notis tools search`.' }],
|
|
442
620
|
options: [
|
|
443
621
|
{ flags: '--arguments <json>', description: 'JSON object, @file path, or - for stdin.' },
|
|
622
|
+
{ flags: '--arguments-file <path>', description: 'Read the JSON arguments object from a file.' },
|
|
444
623
|
{ flags: '--file <argument-path=local-path>', description: 'Upload a local file into a file-uploadable tool argument. Repeatable.', collect: true },
|
|
445
624
|
{ flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
|
|
446
625
|
{ flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
|
|
@@ -452,6 +631,7 @@ export const toolsCommandSpecs = [
|
|
|
452
631
|
'notis tools exec LOCAL_NOTIS_DATABASE_QUERY --get-schema',
|
|
453
632
|
'notis tools exec LOCAL_NOTIS_DATABASE_QUERY --dry-run --arguments \'{"database_slug":"tasks","query":{}}\'',
|
|
454
633
|
'notis tools exec LOCAL_NOTIS_DATABASE_QUERY --arguments @query.json',
|
|
634
|
+
'notis tools exec LOCAL_NOTIS_DATABASE_QUERY --arguments-file query.json',
|
|
455
635
|
'notis tools exec LOCAL_NOTIS_DATABASE_QUERY --arguments - < query.json',
|
|
456
636
|
'notis tools exec composio-dropbox-upload_file --arguments \'{"path":"/target/in/dropbox.pdf"}\' --file content=./Invoice.pdf',
|
|
457
637
|
],
|