@eightstate/escli 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/commands/notion/block/trash.js +71 -0
  2. package/dist/commands/notion/comments/add.js +48 -0
  3. package/dist/commands/notion/comments/get.js +42 -0
  4. package/dist/commands/notion/comments/list.js +55 -0
  5. package/dist/commands/notion/comments/reply.js +45 -0
  6. package/dist/commands/notion/comments/thread.js +87 -0
  7. package/dist/commands/notion/db/create.js +555 -0
  8. package/dist/commands/notion/db/query.js +451 -0
  9. package/dist/commands/notion/db/row/create.js +74 -0
  10. package/dist/commands/notion/db/row/update.js +165 -0
  11. package/dist/commands/notion/ds/create.js +73 -0
  12. package/dist/commands/notion/enroll.js +302 -0
  13. package/dist/commands/notion/index.js +24 -0
  14. package/dist/commands/notion/page/edit.js +73 -0
  15. package/dist/commands/notion/page/move.js +59 -0
  16. package/dist/commands/notion/page/read.js +60 -0
  17. package/dist/commands/notion/page/replace-content.js +80 -0
  18. package/dist/commands/notion/page/replace-text.js +80 -0
  19. package/dist/commands/notion/page/replace.js +63 -0
  20. package/dist/commands/notion/page/trash.js +79 -0
  21. package/dist/commands/notion/search.js +207 -0
  22. package/dist/commands/notion/upload/attach.js +105 -0
  23. package/dist/commands/notion/upload/index.js +129 -0
  24. package/dist/commands/notion/upload/list.js +78 -0
  25. package/dist/commands/notion/upload/status.js +76 -0
  26. package/dist/commands/notion/view/create.js +78 -0
  27. package/dist/commands/notion/whoami.js +96 -0
  28. package/dist/commands/research.js +11 -0
  29. package/dist/entry.js +16 -9
  30. package/dist/io/render-kv.js +12 -0
  31. package/dist/io/render-labeled.js +16 -0
  32. package/dist/io/render-table.js +19 -0
  33. package/dist/io/render-trailer.js +7 -0
  34. package/dist/lib/manifest.js +3 -2
  35. package/dist/lib/notion/comments/shared.js +366 -0
  36. package/dist/lib/notion/db-row/common.js +367 -0
  37. package/dist/lib/notion/manifest-pass.js +4 -0
  38. package/dist/lib/notion/page/content-common.js +473 -0
  39. package/dist/lib/notion/trash-move/support.js +300 -0
  40. package/dist/lib/notion/upload/shared.js +372 -0
  41. package/dist/lib/registry.js +118 -25
  42. package/dist/services/notion.js +274 -0
  43. package/dist/services/research.js +31 -10
  44. package/oclif.manifest.json +4084 -1
  45. package/package.json +22 -17
@@ -24,34 +24,89 @@ import Research, { ResearchDataSchema } from '../commands/research.js';
24
24
  import Social, { SocialDataSchema } from '../commands/social.js';
25
25
  import Usage, { UsageDataSchema } from '../commands/usage.js';
26
26
  import Version, { VersionDataSchema } from '../commands/version.js';
27
+ import NotionTopic from '../commands/notion/index.js';
28
+ import NotionEnroll from '../commands/notion/enroll.js';
29
+ import NotionWhoami from '../commands/notion/whoami.js';
30
+ import NotionPageRead from '../commands/notion/page/read.js';
31
+ import NotionPageEdit from '../commands/notion/page/edit.js';
32
+ import NotionPageReplace from '../commands/notion/page/replace.js';
33
+ import NotionPageTrash from '../commands/notion/page/trash.js';
34
+ import NotionPageMove from '../commands/notion/page/move.js';
35
+ import NotionPageReplaceText from '../commands/notion/page/replace-text.js';
36
+ import NotionPageReplaceContent from '../commands/notion/page/replace-content.js';
37
+ import NotionBlockTrash from '../commands/notion/block/trash.js';
38
+ import NotionDbQuery from '../commands/notion/db/query.js';
39
+ import NotionDbCreate from '../commands/notion/db/create.js';
40
+ import NotionDbRowCreate from '../commands/notion/db/row/create.js';
41
+ import NotionDbRowUpdate from '../commands/notion/db/row/update.js';
42
+ import NotionDsCreate from '../commands/notion/ds/create.js';
43
+ import NotionViewCreate from '../commands/notion/view/create.js';
44
+ import NotionSearch from '../commands/notion/search.js';
45
+ import NotionCommentsList from '../commands/notion/comments/list.js';
46
+ import NotionCommentsGet from '../commands/notion/comments/get.js';
47
+ import NotionCommentsThread from '../commands/notion/comments/thread.js';
48
+ import NotionCommentsAdd from '../commands/notion/comments/add.js';
49
+ import NotionCommentsReply from '../commands/notion/comments/reply.js';
50
+ import NotionUpload from '../commands/notion/upload/index.js';
51
+ import NotionUploadAttach from '../commands/notion/upload/attach.js';
52
+ import NotionUploadStatus from '../commands/notion/upload/status.js';
53
+ import NotionUploadList from '../commands/notion/upload/list.js';
27
54
  import { registerCommandMetadata } from './command-metadata.js';
28
55
  import { renderAuthLogin, renderAuthLogout, renderAuthProfiles, renderAuthStatus, renderAuthSwitch, renderDocsContent, renderDocsSearch, renderFetch, renderModelList, renderResearch, renderSearch, renderSocial, renderUsage, renderVersion, } from '../io/io.js';
56
+ const asCommandClass = (c) => c;
29
57
  const commandClasses = [
30
- { id: 'image', command: ImageTopic },
31
- { id: 'image generate', command: ImageGenerate },
32
- { id: 'image edit', command: ImageEdit },
33
- { id: 'audio', command: AudioTopic },
34
- { id: 'audio transcribe', command: AudioTranscribe },
35
- { id: 'audio status', command: AudioStatus },
36
- { id: 'audio get', command: AudioGet },
37
- { id: 'audio list', command: AudioList },
38
- { id: 'auth', command: AuthTopic },
39
- { id: 'auth login', command: AuthLogin },
40
- { id: 'auth logout', command: AuthLogout },
41
- { id: 'auth status', command: AuthStatus },
42
- { id: 'auth profiles', command: AuthProfiles },
43
- { id: 'auth switch', command: AuthSwitch },
44
- { id: 'docs', command: DocsTopic },
45
- { id: 'docs search', command: DocsSearch },
46
- { id: 'docs get', command: DocsGet },
47
- { id: 'docs fetch', command: DocsFetch },
48
- { id: 'fetch', command: Fetch },
49
- { id: 'models', command: Models },
50
- { id: 'search', command: Search },
51
- { id: 'research', command: Research },
52
- { id: 'social', command: Social },
53
- { id: 'usage', command: Usage },
54
- { id: 'version', command: Version },
58
+ { id: 'image', command: asCommandClass(ImageTopic) },
59
+ { id: 'image generate', command: asCommandClass(ImageGenerate) },
60
+ { id: 'image edit', command: asCommandClass(ImageEdit) },
61
+ { id: 'audio', command: asCommandClass(AudioTopic) },
62
+ { id: 'audio transcribe', command: asCommandClass(AudioTranscribe) },
63
+ { id: 'audio status', command: asCommandClass(AudioStatus) },
64
+ { id: 'audio get', command: asCommandClass(AudioGet) },
65
+ { id: 'audio list', command: asCommandClass(AudioList) },
66
+ { id: 'auth', command: asCommandClass(AuthTopic) },
67
+ { id: 'auth login', command: asCommandClass(AuthLogin) },
68
+ { id: 'auth logout', command: asCommandClass(AuthLogout) },
69
+ { id: 'auth status', command: asCommandClass(AuthStatus) },
70
+ { id: 'auth profiles', command: asCommandClass(AuthProfiles) },
71
+ { id: 'auth switch', command: asCommandClass(AuthSwitch) },
72
+ { id: 'docs', command: asCommandClass(DocsTopic) },
73
+ { id: 'docs search', command: asCommandClass(DocsSearch) },
74
+ { id: 'docs get', command: asCommandClass(DocsGet) },
75
+ { id: 'docs fetch', command: asCommandClass(DocsFetch) },
76
+ { id: 'fetch', command: asCommandClass(Fetch) },
77
+ { id: 'models', command: asCommandClass(Models) },
78
+ { id: 'search', command: asCommandClass(Search) },
79
+ { id: 'research', command: asCommandClass(Research) },
80
+ { id: 'social', command: asCommandClass(Social) },
81
+ { id: 'usage', command: asCommandClass(Usage) },
82
+ { id: 'version', command: asCommandClass(Version) },
83
+ { id: 'notion', command: asCommandClass(NotionTopic) },
84
+ { id: 'notion enroll', command: asCommandClass(NotionEnroll) },
85
+ { id: 'notion whoami', command: asCommandClass(NotionWhoami) },
86
+ { id: 'notion page read', command: asCommandClass(NotionPageRead) },
87
+ { id: 'notion page edit', command: asCommandClass(NotionPageEdit) },
88
+ { id: 'notion page replace', command: asCommandClass(NotionPageReplace) },
89
+ { id: 'notion page trash', command: asCommandClass(NotionPageTrash) },
90
+ { id: 'notion page move', command: asCommandClass(NotionPageMove) },
91
+ { id: 'notion page replace-text', command: asCommandClass(NotionPageReplaceText) },
92
+ { id: 'notion page replace-content', command: asCommandClass(NotionPageReplaceContent) },
93
+ { id: 'notion block trash', command: asCommandClass(NotionBlockTrash) },
94
+ { id: 'notion db query', command: asCommandClass(NotionDbQuery) },
95
+ { id: 'notion db create', command: asCommandClass(NotionDbCreate) },
96
+ { id: 'notion db row create', command: asCommandClass(NotionDbRowCreate) },
97
+ { id: 'notion db row update', command: asCommandClass(NotionDbRowUpdate) },
98
+ { id: 'notion ds create', command: asCommandClass(NotionDsCreate) },
99
+ { id: 'notion view create', command: asCommandClass(NotionViewCreate) },
100
+ { id: 'notion search', command: asCommandClass(NotionSearch) },
101
+ { id: 'notion comments list', command: asCommandClass(NotionCommentsList) },
102
+ { id: 'notion comments get', command: asCommandClass(NotionCommentsGet) },
103
+ { id: 'notion comments thread', command: asCommandClass(NotionCommentsThread) },
104
+ { id: 'notion comments add', command: asCommandClass(NotionCommentsAdd) },
105
+ { id: 'notion comments reply', command: asCommandClass(NotionCommentsReply) },
106
+ { id: 'notion upload', command: asCommandClass(NotionUpload) },
107
+ { id: 'notion upload attach', command: asCommandClass(NotionUploadAttach) },
108
+ { id: 'notion upload status', command: asCommandClass(NotionUploadStatus) },
109
+ { id: 'notion upload list', command: asCommandClass(NotionUploadList) },
55
110
  ];
56
111
  const aliasOverrides = new Map([
57
112
  ['image', ['img', 'i']],
@@ -79,6 +134,15 @@ const aliasOverrides = new Map([
79
134
  ['social', []],
80
135
  ['usage', ['u']],
81
136
  ['version', []],
137
+ ['notion', ['n']],
138
+ // file-upload alias surface: every help text, "next" hint, and example
139
+ // advertises `notion file-upload upload|attach|status|list` but the registry
140
+ // ids are `notion upload …` — wire the file-upload forms in so users typing
141
+ // the advertised commands actually resolve. `ls` kept as a list shorthand.
142
+ ['notion upload', ['notion file-upload', 'notion file-upload upload']],
143
+ ['notion upload attach', ['notion file-upload attach']],
144
+ ['notion upload status', ['notion file-upload status']],
145
+ ['notion upload list', ['notion file-upload list', 'notion file-upload ls', 'notion upload ls']],
82
146
  ]);
83
147
  const metadataEntries = [
84
148
  ['image', { inputSchema: z.object({}), dataSchema: z.object({}) }],
@@ -172,6 +236,8 @@ const metadataEntries = [
172
236
  metadata: z.string().optional(),
173
237
  'follow-up': z.string().optional(),
174
238
  'no-basis': z.boolean().optional(),
239
+ 'max-wait': z.number().int().optional(),
240
+ async: z.boolean().optional(),
175
241
  }), dataSchema: ResearchDataSchema, render: renderResearch }],
176
242
  ['social', { inputSchema: z.object({
177
243
  query: z.array(z.string()).min(1),
@@ -191,6 +257,33 @@ const metadataEntries = [
191
257
  pricing: z.boolean().optional(),
192
258
  }), dataSchema: UsageDataSchema, render: renderUsage }],
193
259
  ['version', { inputSchema: z.object({}), dataSchema: VersionDataSchema, render: renderVersion }],
260
+ ['notion', { inputSchema: z.object({}), dataSchema: z.object({}) }],
261
+ ['notion enroll', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
262
+ ['notion whoami', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
263
+ ['notion page read', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
264
+ ['notion page edit', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
265
+ ['notion page replace', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
266
+ ['notion page trash', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
267
+ ['notion page move', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
268
+ ['notion page replace-text', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
269
+ ['notion page replace-content', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
270
+ ['notion block trash', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
271
+ ['notion db query', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
272
+ ['notion db create', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
273
+ ['notion db row create', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
274
+ ['notion db row update', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
275
+ ['notion ds create', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
276
+ ['notion view create', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
277
+ ['notion search', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
278
+ ['notion comments list', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
279
+ ['notion comments get', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
280
+ ['notion comments thread', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
281
+ ['notion comments add', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
282
+ ['notion comments reply', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
283
+ ['notion upload', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
284
+ ['notion upload attach', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
285
+ ['notion upload status', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
286
+ ['notion upload list', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
194
287
  ];
195
288
  const metadata = new Map(metadataEntries);
196
289
  export const commandRegistry = commandClasses.map(({ id, command }) => buildRegistration(id, command));
@@ -0,0 +1,274 @@
1
+ import { ErrorCode } from '@eightstate/contracts/errors';
2
+ import { ExitCodes } from '@eightstate/contracts/exit-codes';
3
+ import { exitCodeForErrorCode } from '@eightstate/contracts/failure-taxonomy';
4
+ import { EscliError } from '../lib/escli-error.js';
5
+ import { resolveCliToken } from './credentials.js';
6
+ export const NOTION_VERSION = '2026-03-11';
7
+ const DEFAULT_GATE_URL = 'https://internal.eightstate.co';
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ export async function whoami() {
10
+ return gateRequest('GET', '/v1/notion/whoami');
11
+ }
12
+ export async function enroll(token, options = {}) {
13
+ return gateRequest('POST', '/v1/notion/enroll', withDefined({ token, force: options.force === true ? true : undefined }));
14
+ }
15
+ export async function rotate(token, options = {}) {
16
+ return gateRequest('POST', '/v1/notion/rotate', withDefined({ token, force: options.force === true ? true : undefined }));
17
+ }
18
+ export async function deleteEnrollment() {
19
+ return gateRequest('DELETE', '/v1/notion/enrollment');
20
+ }
21
+ export async function proxy(method, path, opts = {}) {
22
+ const idempotent = opts.idempotent ?? method === 'GET';
23
+ try {
24
+ return await gateRequest('POST', '/v1/notion/proxy', { method, path, query: opts.query, body: opts.body, idempotent });
25
+ }
26
+ catch (error) {
27
+ // A CLI-local timeout on a write is genuinely ambiguous: the gate may have
28
+ // already accepted the request and forwarded it to Notion. POST read
29
+ // endpoints such as search/query opt into idempotent=true and keep the
30
+ // normal retryable network.timeout classification.
31
+ if (error instanceof EscliError && error.code === ErrorCode.NetworkTimeout && !idempotent) {
32
+ throw new EscliError('uncertain — local timeout on a Notion write; verify the operation in Notion before retrying', {
33
+ code: ErrorCode.NotionTimeoutUncertain,
34
+ exitCode: ExitCodes.Error,
35
+ details: error.details,
36
+ causeCode: 'notion.timeout_uncertain',
37
+ });
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+ export function notionFetchPage(id) {
43
+ return proxy('GET', `/v1/pages/${encodeURIComponent(id)}/markdown`);
44
+ }
45
+ export function notionPagePatchMarkdown(id, ops) {
46
+ return proxy('PATCH', `/v1/pages/${encodeURIComponent(id)}/markdown`, { body: ops });
47
+ }
48
+ export function notionDataSourceQuery(dsId, filter, sorts, pageSize, nextCursor) {
49
+ return proxy('POST', `/v1/data_sources/${encodeURIComponent(dsId)}/query`, {
50
+ body: withDefined({ filter, sorts, page_size: pageSize, start_cursor: nextCursor }),
51
+ idempotent: true,
52
+ });
53
+ }
54
+ export function notionSearch(query, opts = {}) {
55
+ return proxy('POST', '/v1/search', { body: { query, ...opts }, idempotent: true });
56
+ }
57
+ export function notionCommentsList(pageId, nextCursor) {
58
+ return proxy('GET', '/v1/comments', { query: withDefined({ block_id: pageId, start_cursor: nextCursor }) });
59
+ }
60
+ export function notionCommentsCreate(input) {
61
+ const body = input.body ?? withDefined({ parent: input.pageId ? { page_id: input.pageId } : undefined, discussion_id: input.discussionId, rich_text: input.richText });
62
+ return proxy('POST', '/v1/comments', { body });
63
+ }
64
+ export function notionFileUploadCreate(body) {
65
+ return proxy('POST', '/v1/file_uploads', { body });
66
+ }
67
+ export function notionFileUploadSend(fileUploadId, options) {
68
+ return proxy('POST', `/v1/file_uploads/${encodeURIComponent(fileUploadId)}/send`, {
69
+ body: withDefined({ file: options.file, filename: options.filename, content_type: options.contentType }),
70
+ });
71
+ }
72
+ export function notionFileUploadComplete(fileUploadId) {
73
+ return proxy('POST', `/v1/file_uploads/${encodeURIComponent(fileUploadId)}/complete`);
74
+ }
75
+ export function notionPagesCreate(parent, props, markdown) {
76
+ return proxy('POST', '/v1/pages', { body: withDefined({ parent, properties: props, markdown }) });
77
+ }
78
+ export function notionPagesPatchProps(pageId, props) {
79
+ return proxy('PATCH', `/v1/pages/${encodeURIComponent(pageId)}`, { body: { properties: props } });
80
+ }
81
+ export function notionPagesMove(pageId, parent) {
82
+ return proxy('POST', `/v1/pages/${encodeURIComponent(pageId)}/move`, { body: { parent } });
83
+ }
84
+ export function notionPagesTrash(pageId, inTrash) {
85
+ return proxy('PATCH', `/v1/pages/${encodeURIComponent(pageId)}`, { body: { in_trash: inTrash } });
86
+ }
87
+ export function notionUsersMe() {
88
+ return proxy('GET', '/v1/users/me');
89
+ }
90
+ export function notionBlocksGet(blockId, nextCursor) {
91
+ return proxy('GET', `/v1/blocks/${encodeURIComponent(blockId)}/children`, { query: withDefined({ start_cursor: nextCursor }) });
92
+ }
93
+ export function notionBlocksUpdate(blockId, body) {
94
+ return proxy('PATCH', `/v1/blocks/${encodeURIComponent(blockId)}`, { body });
95
+ }
96
+ export function notionBlocksDelete(blockId) {
97
+ return proxy('DELETE', `/v1/blocks/${encodeURIComponent(blockId)}`);
98
+ }
99
+ export function notionBlocksAppendChildren(blockId, children, position) {
100
+ return proxy('PATCH', `/v1/blocks/${encodeURIComponent(blockId)}/children`, { body: withDefined({ children, position }) });
101
+ }
102
+ async function gateRequest(method, path, body) {
103
+ const token = resolveCliToken();
104
+ if (!token) {
105
+ throw new EscliError('not authenticated', {
106
+ code: ErrorCode.AuthRequired,
107
+ exitCode: ExitCodes.Auth,
108
+ remediation: { command: 'escli auth login' },
109
+ });
110
+ }
111
+ const controller = new AbortController();
112
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
113
+ try {
114
+ const response = await fetch(new URL(path, gateUrl()), {
115
+ method,
116
+ headers: { 'authorization': `Bearer ${token}`, 'accept': 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
117
+ body: body === undefined ? undefined : JSON.stringify(body),
118
+ signal: controller.signal,
119
+ });
120
+ const payload = await parseJson(response);
121
+ if (!response.ok)
122
+ throw mapGateFailure(response.status, payload);
123
+ return normalizeGatePayload(payload);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof EscliError)
127
+ throw error;
128
+ throw mapNetworkError(error);
129
+ }
130
+ finally {
131
+ clearTimeout(timeout);
132
+ }
133
+ }
134
+ async function parseJson(response) {
135
+ const text = await response.text();
136
+ if (!text)
137
+ return null;
138
+ try {
139
+ return JSON.parse(text);
140
+ }
141
+ catch (error) {
142
+ throw new EscliError(`invalid JSON response: ${error instanceof Error ? error.message : String(error)}`, {
143
+ code: ErrorCode.GateInvalidResponse,
144
+ details: text,
145
+ });
146
+ }
147
+ }
148
+ function normalizeGatePayload(payload) {
149
+ const record = recordValue(payload);
150
+ if (record && typeof record.ok === 'boolean') {
151
+ const envelope = record;
152
+ if (envelope.ok === false)
153
+ throw mapGateErrorObject(envelope.error, payload);
154
+ const data = envelope.data;
155
+ return { data, raw: data, meta: notionMeta(envelope.meta, data) };
156
+ }
157
+ return { data: payload, raw: payload, meta: notionMeta(undefined, payload) };
158
+ }
159
+ function notionMeta(meta, data) {
160
+ return {
161
+ ...(meta ?? {}),
162
+ next: stringValue(meta?.next) ?? stringValue(recordValue(data)?.next_cursor) ?? null,
163
+ notion_version: NOTION_VERSION,
164
+ };
165
+ }
166
+ function mapGateFailure(status, body) {
167
+ const envelope = recordValue(body);
168
+ if (envelope?.ok === false)
169
+ return mapGateErrorObject(envelope.error, body);
170
+ const code = stringValue(recordValue(body)?.code) ?? stringValue(recordValue(recordValue(body)?.error)?.code);
171
+ const message = extractErrorMessage(body) ?? `Notion broker request failed with HTTP ${status}`;
172
+ return new EscliError(message, { code: mapErrorCode(code, status), exitCode: exitCodeForStatusAndCode(status, code), details: body, causeCode: code });
173
+ }
174
+ function mapGateErrorObject(error, body) {
175
+ const code = error?.code;
176
+ return new EscliError(error?.message ?? 'Notion broker request failed', {
177
+ code: mapErrorCode(code),
178
+ exitCode: exitCodeForCode(code),
179
+ details: error?.details ?? body,
180
+ causeCode: code,
181
+ });
182
+ }
183
+ function mapErrorCode(code, status) {
184
+ switch (code) {
185
+ case 'notion.unauthorized':
186
+ case 'unauthorized':
187
+ return ErrorCode.NotionUnauthorized;
188
+ case 'notion.restricted':
189
+ case 'restricted_resource':
190
+ return ErrorCode.NotionRestricted;
191
+ case 'notion.not_found':
192
+ case 'object_not_found':
193
+ return ErrorCode.NotionNotFound;
194
+ case 'notion.rate_limited':
195
+ case 'rate_limited':
196
+ return ErrorCode.NotionRateLimited;
197
+ case 'notion.validation':
198
+ case 'notion.payload_too_large':
199
+ case 'notion.method_not_allowed':
200
+ case 'notion.invalid_token_type':
201
+ case 'validation_error':
202
+ case 'invalid_request':
203
+ return ErrorCode.NotionValidation;
204
+ case 'notion.conflict':
205
+ case 'conflict_error':
206
+ case 'notion.already_enrolled':
207
+ case 'notion.identity_mismatch':
208
+ return ErrorCode.NotionConflict;
209
+ case 'notion.timeout_uncertain':
210
+ return ErrorCode.NotionTimeoutUncertain;
211
+ case 'notion.enrollment_required':
212
+ return ErrorCode.NotionEnrollmentRequired;
213
+ case 'notion.batch_partial':
214
+ return ErrorCode.NotionBatchPartial;
215
+ case 'notion.upstream_unreachable':
216
+ return ErrorCode.ServiceUnavailable;
217
+ case 'notion.invalid_json':
218
+ return ErrorCode.GateInvalidResponse;
219
+ case 'notion.invalid_request_url':
220
+ return ErrorCode.UsageInvalid;
221
+ default:
222
+ if (status === 401)
223
+ return ErrorCode.NotionUnauthorized;
224
+ if (status === 403)
225
+ return ErrorCode.NotionRestricted;
226
+ if (status === 404)
227
+ return ErrorCode.NotionNotFound;
228
+ if (status === 409)
229
+ return ErrorCode.NotionConflict;
230
+ if (status === 429)
231
+ return ErrorCode.NotionRateLimited;
232
+ if (status !== undefined && status >= 500)
233
+ return ErrorCode.ServiceUnavailable;
234
+ return ErrorCode.ApiError;
235
+ }
236
+ }
237
+ function exitCodeForStatusAndCode(status, code) {
238
+ if (code)
239
+ return exitCodeForCode(code);
240
+ if (status >= 500)
241
+ return ExitCodes.Transient;
242
+ return exitCodeForCode(code);
243
+ }
244
+ function exitCodeForCode(code) {
245
+ // Single source of truth: contracts/failure-taxonomy. Removing the local
246
+ // switch fixes the case where notion.conflict mapped to NotFound here but
247
+ // Error in contracts — same code, different exit per surface.
248
+ return exitCodeForErrorCode(mapErrorCode(code));
249
+ }
250
+ function mapNetworkError(error) {
251
+ const isAbort = error instanceof Error && error.name === 'AbortError';
252
+ return new EscliError(isAbort ? 'network error: request timed out' : `network error: ${error instanceof Error ? error.message : String(error)}`, {
253
+ code: isAbort ? ErrorCode.NetworkTimeout : ErrorCode.NetworkError,
254
+ exitCode: ExitCodes.Transient,
255
+ details: error instanceof Error ? error.message : error,
256
+ });
257
+ }
258
+ function extractErrorMessage(body) {
259
+ const root = recordValue(body);
260
+ return stringValue(root?.message) ?? stringValue(root?.error) ?? stringValue(recordValue(root?.error)?.message);
261
+ }
262
+ function withDefined(input) {
263
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
264
+ }
265
+ function gateUrl() {
266
+ return process.env.ESCLI_GATE_URL ?? DEFAULT_GATE_URL;
267
+ }
268
+ function recordValue(value) {
269
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
270
+ }
271
+ function stringValue(value) {
272
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
273
+ }
274
+ //# sourceMappingURL=notion.js.map
@@ -28,8 +28,11 @@ export async function runResearch(options) {
28
28
  throw new EscliError('task creation response missing run_id', { code: ErrorCode.GateInvalidResponse });
29
29
  if (captured.key)
30
30
  await rememberRunOwner(runId, captured.key.fingerprint);
31
+ if (options.async) {
32
+ return { run_id: runId, processor: options.processor, status: stringValue(task.status) ?? 'submitted' };
33
+ }
31
34
  const apiKey = captured.key?.apiKey ?? await getResearchApiKeyForRun(runId);
32
- const output = await pollUntilComplete(apiKey, runId, options.timeoutSeconds);
35
+ const output = await pollUntilComplete(apiKey, runId, options.timeoutSeconds, options.maxWaitSeconds);
33
36
  const data = { run_id: runId, processor: options.processor, output };
34
37
  if (options.output) {
35
38
  data.path = resolve(options.output);
@@ -219,22 +222,40 @@ async function requestText(url, options) {
219
222
  clearTimeout(timeout);
220
223
  }
221
224
  }
222
- async function pollUntilComplete(apiKey, runId, timeoutSeconds) {
225
+ async function pollUntilComplete(apiKey, runId, requestTimeoutSeconds, maxWaitSeconds) {
223
226
  const started = Date.now();
224
- const maxMs = pollMaxMs(timeoutSeconds);
227
+ const maxMs = pollMaxMs(maxWaitSeconds);
228
+ let lastStatus = 'unknown';
225
229
  while (Date.now() - started < maxMs) {
226
- const result = await apiRequest('GET', `/v1/tasks/runs/${runId}`, apiKey, undefined, undefined, timeoutSeconds);
227
- const status = result.status;
230
+ const remainingMs = maxMs - (Date.now() - started);
231
+ const requestTimeout = boundedRequestTimeoutSeconds(requestTimeoutSeconds, remainingMs);
232
+ const result = await apiRequest('GET', `/v1/tasks/runs/${runId}`, apiKey, undefined, undefined, requestTimeout);
233
+ const status = stringValue(result.status) ?? 'unknown';
234
+ lastStatus = status;
228
235
  if (status === 'completed') {
229
- const output = await apiRequest('GET', `/v1/tasks/runs/${runId}/result`, apiKey, undefined, undefined, timeoutSeconds);
236
+ const output = await apiRequest('GET', `/v1/tasks/runs/${runId}/result`, apiKey, undefined, undefined, requestTimeoutSeconds);
230
237
  return normalizeOutput(output.output);
231
238
  }
232
239
  if (status === 'failed' || status === 'cancelled') {
233
- throw new EscliError(`task ${String(status)}: ${String(result.error ?? '')}`, { code: ErrorCode.ResearchTaskFailed, details: result.error });
240
+ throw new EscliError(`task ${status}: ${String(result.error ?? '')}`, { code: ErrorCode.ResearchTaskFailed, details: result.error });
234
241
  }
235
242
  await sleep(pollIntervalMs());
236
243
  }
237
- throw new EscliError('research task timed out', { code: ErrorCode.ResearchTimeout });
244
+ const elapsedMs = Date.now() - started;
245
+ throw new EscliError(`research task timed out; last status: ${lastStatus}`, {
246
+ code: ErrorCode.ResearchTimeout,
247
+ details: { run_id: runId, status: lastStatus, elapsed_ms: elapsedMs, max_wait_ms: maxMs },
248
+ remediation: {
249
+ command: `escli research --status ${runId}`,
250
+ hint: `Task did not complete within ${Math.round(maxMs / 1000)}s. Poll with \`escli research --status ${runId}\` and fetch with \`escli research --result ${runId} -o <path>\` once it completes.`,
251
+ },
252
+ });
253
+ }
254
+ function boundedRequestTimeoutSeconds(requestTimeoutSeconds, remainingMs) {
255
+ const remainingSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
256
+ if (requestTimeoutSeconds === undefined)
257
+ return remainingSeconds;
258
+ return Math.max(1, Math.min(requestTimeoutSeconds, remainingSeconds));
238
259
  }
239
260
  async function writeResearchOutput(path, query, processor, runId, output, createdAt, noBasis) {
240
261
  try {
@@ -431,13 +452,13 @@ async function rememberRunOwner(runId, fingerprint) {
431
452
  function pollIntervalMs() {
432
453
  return Math.max(1, Number(process.env.ESCLI_RESEARCH_POLL_INTERVAL_MS ?? DEFAULT_POLL_INTERVAL_MS));
433
454
  }
434
- function pollMaxMs(timeoutSeconds) {
455
+ function pollMaxMs(maxWaitSeconds) {
435
456
  if (typeof __ESCLI_TEST__ === 'undefined' || __ESCLI_TEST__) {
436
457
  const value = Number(process.env.ESCLI_RESEARCH_MAX_POLL_MS);
437
458
  if (Number.isFinite(value) && value > 0)
438
459
  return Math.max(1, value);
439
460
  }
440
- return timeoutSeconds ? Math.max(1, timeoutSeconds * 1000) : DEFAULT_MAX_POLL_MS;
461
+ return maxWaitSeconds ? Math.max(1, maxWaitSeconds * 1000) : DEFAULT_MAX_POLL_MS;
441
462
  }
442
463
  function timeoutMs(timeoutSeconds, defaultMs = DEFAULT_TIMEOUT_MS) {
443
464
  return Math.max(1, timeoutSeconds ? timeoutSeconds * 1000 : defaultMs);