diff --git a/ERRORS.md b/ERRORS.md index 8fd9f49..6093d3b 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -123,7 +123,7 @@ Hint: polylane workspace use These are the non-obvious command-level behaviours an agent should know about. Specific error strings are subject to change — use these as categories you can rely on. -### Destructive operations (`auth logout`, `integration disconnect`, `cloud disconnect`, `memory delete`, …) +### Destructive operations (`auth logout`, `integration disconnect`, `cloud disconnect`, …) - Prompt for confirmation in interactive mode. - Require `--yes` in non-interactive mode, else exit `2`. diff --git a/README.md b/README.md index 20cbb61..0a5a550 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ polylane issue note "rolled back deploy" polylane service logs --since 1h --grep error polylane tools run findNodes --params '{"query":"api"}' polylane thread ask "" -polylane memory save "" +polylane memory list # what the agent has learned (git-backed) ``` ## Discovering commands diff --git a/skill/SKILL.md b/skill/SKILL.md index 6e8b2c9..cad980a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -179,7 +179,7 @@ polylane issue note "rolled back deploy ABC" polylane issue milestone "Mitigated" # Drill into services -polylane service find "" +polylane service find # matches id, name or alternate name polylane service logs --since 1h --grep error polylane service metrics --metric --since 1h polylane service graph --direction both --depth 1 @@ -187,8 +187,10 @@ polylane service graph --direction both --depth 1 # Find the repository, then search it in a local clone polylane repo find "" -# Save what you learned -polylane memory save "" +# Read what the agent has learned about this workspace +polylane memory list [--path ] +polylane memory show +polylane memory history ``` ### Running agent tools directly @@ -238,7 +240,7 @@ Attach resources as context by passing their IDs — the CLI infers the resource polylane workspace list --output json | jq '.items[0].id' # Chain: find a service → check its logs -SVC=$(polylane service find "payment lambda" --output json --quiet | jq -r '.items[0].id') +SVC=$(polylane service find payments --output json --quiet | jq -r '.items[0].id') polylane service logs "$SVC" --since 1h --grep error # Fire-and-forget + later poll diff --git a/src/commands/feed/index.ts b/src/commands/feed/index.ts deleted file mode 100644 index 8265a56..0000000 --- a/src/commands/feed/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { feedListCommand } from './list'; -import { feedSummaryCommand } from './summary'; - -export const feedCommands = [feedListCommand, feedSummaryCommand]; diff --git a/src/commands/feed/list.ts b/src/commands/feed/list.ts deleted file mode 100644 index 5c46eae..0000000 --- a/src/commands/feed/list.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatList, outputJson } from '../../output/formatter'; -import { projectItems } from '../../output/project'; -import { - requireWorkspace, - getArgString, - getArgNumber, - getArgBoolean, - parseDuration, -} from '../helpers'; -import { CLIError } from '../../errors/base'; -import { ExitCode } from '../../errors/codes'; - -type Category = NonNullable[1]>['category']>; - -// Retired category; the Exclude keeps this compiling until the API drops it too. -const CATEGORY_COVERAGE: Record, true> = { - autofix: true, - change: true, - release: true, - issue: true, - digest: true, - tier: true, -}; - -const VALID_CATEGORIES = Object.keys(CATEGORY_COVERAGE) as Category[]; - -const FIELDS = [ - 'occurredAt', - 'category', - 'action', - 'title', - 'subtitle', - 'actorName', - 'actorType', - 'resourceType', - 'resourceId', - 'childCount', - '_html_url', -]; - -export const feedListCommand: Command = { - name: 'feed list', - description: 'Workspace agent activity feed (autofixes, changes, releases, issues…)', - operationId: 'feed.list', - options: [ - { flag: '--category ', description: VALID_CATEGORIES.join(' | '), type: 'string' }, - { flag: '--since ', description: 'Only entries from the last (e.g. 24h, 7d, 30m)', type: 'string' }, - { flag: '--from ', description: 'Occurred at or after (unix ms)', type: 'number' }, - { flag: '--to ', description: 'Occurred at or before (unix ms)', type: 'number' }, - { flag: '--limit ', description: 'Max items, 1–200 (default 20)', type: 'number' }, - { flag: '--cursor ', description: 'Pagination cursor from a previous nextCursor', type: 'string' }, - { flag: '--full', description: 'Return full entries (metadata + nested children)', type: 'boolean' }, - ], - examples: [ - 'polylane feed list', - 'polylane feed list --since 24h', - 'polylane feed list --category issue --limit 50', - 'polylane feed list --cursor ', - ], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - - const category = getArgString(args, 'category'); - if (category && !VALID_CATEGORIES.includes(category as Category)) { - throw new CLIError( - `Invalid --category: ${category}`, - ExitCode.USAGE, - `Must be one of: ${VALID_CATEGORIES.join(', ')}` - ); - } - - const limit = getArgNumber(args, 'limit') ?? 20; - if (limit < 1 || limit > 200) { - throw new CLIError(`Invalid --limit: ${limit}`, ExitCode.USAGE, 'Use 1–200'); - } - - const since = getArgString(args, 'since'); - const fromMs = getArgNumber(args, 'from') ?? (since ? Date.now() - parseDuration(since) : undefined); - const toMs = getArgNumber(args, 'to'); - const cursor = getArgString(args, 'cursor'); - const full = getArgBoolean(args, 'full') === true; - - const api = new PolylaneAPI(config); - const result = await api.feedList(workspaceId, { - limit, - ...(category ? { category: category as Category } : {}), - ...(fromMs !== undefined ? { from: fromMs } : {}), - ...(toMs !== undefined ? { to: toMs } : {}), - ...(cursor ? { cursor } : {}), - }); - - const entries = result.entries ?? []; - - if (config.output === 'json') { - outputJson({ items: entries, count: entries.length, nextCursor: result.nextCursor }); - return; - } - - if (full) { - formatList(config, { items: entries as unknown as Array>, count: entries.length }); - writeCursorHint(config, result.nextCursor); - return; - } - - const withChildCount = entries.map((e) => ({ - ...(e as unknown as Record), - childCount: e.children?.length ?? 0, - })); - - formatList( - config, - { - items: projectItems(withChildCount, FIELDS), - count: entries.length, - }, - { - headers: ['When', 'Category', 'Action', 'Title', 'Actor'], - rows: (item) => [ - String(item.occurredAt ?? ''), - String(item.category ?? ''), - String(item.action ?? ''), - truncate(withGroup(String(item.title ?? ''), Number(item.childCount ?? 0)), 60), - String(item.actorName ?? item.actorType ?? ''), - ], - } - ); - writeCursorHint(config, result.nextCursor); - }, -}; - -function withGroup(title: string, childCount: number): string { - return childCount > 0 ? `${title} (+${childCount})` : title; -} - -function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max - 1) + '…' : s; -} - -function writeCursorHint(config: Config, nextCursor: string | null): void { - if (config.quiet || !nextCursor) return; - process.stdout.write(`\nMore: polylane feed list --cursor ${nextCursor}\n`); -} diff --git a/src/commands/feed/summary.ts b/src/commands/feed/summary.ts deleted file mode 100644 index 80cd662..0000000 --- a/src/commands/feed/summary.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { outputJson, outputTable } from '../../output/formatter'; -import { requireWorkspace, getArgString, getArgNumber, parseDuration } from '../helpers'; - -export const feedSummaryCommand: Command = { - name: 'feed summary', - description: 'Activity aggregate over time (autofix PR counts per day)', - operationId: 'feed.summary', - options: [ - { flag: '--since ', description: 'Window to aggregate over (e.g. 7d, 30d)', type: 'string' }, - { flag: '--from ', description: 'Start of window (unix ms)', type: 'number' }, - { flag: '--to ', description: 'End of window (unix ms)', type: 'number' }, - ], - examples: ['polylane feed summary', 'polylane feed summary --since 30d'], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - - const since = getArgString(args, 'since'); - const fromMs = getArgNumber(args, 'from') ?? (since ? Date.now() - parseDuration(since) : undefined); - const toMs = getArgNumber(args, 'to'); - - const api = new PolylaneAPI(config); - const result = await api.feedSummary(workspaceId, { - ...(fromMs !== undefined ? { from: fromMs } : {}), - ...(toMs !== undefined ? { to: toMs } : {}), - }); - - if (config.output === 'json') { - outputJson(result); - return; - } - - process.stdout.write(`Autofix PRs: ${result.autofixPrs}\n\n`); - const daily = result.autofixDaily ?? []; - if (daily.length === 0) { - process.stdout.write('(no daily activity)\n'); - return; - } - outputTable( - ['Date', 'Autofix PRs'], - daily.map((d) => [String(d.date), String(d.count)]) - ); - }, -}; diff --git a/src/commands/index.ts b/src/commands/index.ts index ffbc79a..733edb2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,7 +4,6 @@ import { configCommands } from './config'; import { helpCommand } from './help'; import { setupCommand } from './setup'; import { updateCommand } from './update'; -import { feedCommands } from './feed'; import { issueCommands } from './issue'; import { serviceCommands } from './service'; import { repoCommands } from './repo'; @@ -16,7 +15,6 @@ import { workspaceCommands } from './workspace'; import { subscriptionCommands } from './subscription'; import { apiCommands } from './api'; import { telemetryCommands } from './telemetry'; -import { noteCommands } from './note'; import { autofixCommands } from './autofix'; import { artifactCommands } from './artifact'; import { toolsCommands } from './tools'; @@ -28,7 +26,6 @@ export function registerAllCommands(): void { registered = true; const all = [ - ...feedCommands, ...issueCommands, ...serviceCommands, ...repoCommands, @@ -38,7 +35,6 @@ export function registerAllCommands(): void { ...cloudCommands, ...workspaceCommands, ...subscriptionCommands, - ...noteCommands, ...autofixCommands, ...artifactCommands, ...toolsCommands, diff --git a/src/commands/memory/delete.ts b/src/commands/memory/delete.ts deleted file mode 100644 index 8e2e005..0000000 --- a/src/commands/memory/delete.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { requireWorkspace, requirePositional, getArgBoolean } from '../helpers'; -import { promptConfirm } from '../../utils/prompt'; -import { isInteractive } from '../../utils/env'; - -export const memoryDeleteCommand: Command = { - name: 'memory delete', - description: 'Delete a memory', - operationId: 'memories.del', - positional: [{ name: 'memory-id', description: 'The memory ID' }], - options: [{ flag: '--yes', description: 'Skip confirmation', type: 'boolean' }], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const id = requirePositional(args, 0, 'memory-id'); - - if (getArgBoolean(args, 'yes') !== true && isInteractive(config.nonInteractive)) { - const ok = await promptConfirm( - { nonInteractive: config.nonInteractive }, - `Delete memory ${id}?`, - false - ); - if (!ok) { - process.stderr.write('Cancelled. Nothing changed.\n'); - return; - } - } - - const api = new PolylaneAPI(config); - const result = await api.memoriesDel(workspaceId, id); - process.stdout.write(`Deleted ${result.id}\n`); - }, -}; diff --git a/src/commands/memory/find.ts b/src/commands/memory/find.ts deleted file mode 100644 index 0bdb2c9..0000000 --- a/src/commands/memory/find.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatList } from '../../output/formatter'; -import { projectItems } from '../../output/project'; -import { requireWorkspace, requirePositional, getAllPositional } from '../helpers'; - -const FIELDS = ['id', 'title', 'labels', 'sourceType', 'created']; - -export const memoryFindCommand: Command = { - name: 'memory find', - description: 'Search memories by prompt', - operationId: 'memories.search', - positional: [{ name: 'prompt', description: 'Search query', variadic: true }], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - requirePositional(args, 0, 'prompt'); - const prompt = getAllPositional(args).join(' '); - - const api = new PolylaneAPI(config); - const items = await api.memoriesSearch({ workspaceId, prompt }); - const projected = projectItems(items as unknown as Array>, FIELDS); - - formatList( - config, - { items: projected, count: projected.length }, - { - headers: ['ID', 'Title', 'Labels', 'Source', 'Created'], - rows: (item) => [ - String(item.id ?? ''), - String(item.title ?? ''), - Array.isArray(item.labels) ? item.labels.join(',') : '', - String(item.sourceType ?? ''), - String(item.created ?? ''), - ], - } - ); - }, -}; diff --git a/src/commands/memory/history.ts b/src/commands/memory/history.ts new file mode 100644 index 0000000..88f123a --- /dev/null +++ b/src/commands/memory/history.ts @@ -0,0 +1,35 @@ +import type { Command } from '../../command'; +import type { Config } from '../../config/schema'; +import { PolylaneAPI } from '../../generated/client'; +import { formatList } from '../../output/formatter'; +import { requireWorkspace, getArgNumber } from '../helpers'; + +export const memoryHistoryCommand: Command = { + name: 'memory history', + description: 'List recent commits to the workspace memory repository', + operationId: 'memories.commits', + options: [{ flag: '--limit ', description: 'Max commits (default 20)', type: 'number' }], + async execute(config: Config, _flags, args: Record): Promise { + const workspaceId = await requireWorkspace(config); + const limit = getArgNumber(args, 'limit') ?? 20; + + const api = new PolylaneAPI(config); + const result = await api.memoriesCommits(workspaceId, { limit }); + + const items = result.items.map((c) => ({ + hash: c.hash, + subject: c.subject, + author: c.authorName, + committed: new Date(c.committedAt * 1000).toISOString(), + })); + + formatList( + config, + { items, count: items.length }, + { + headers: ['Commit', 'Subject', 'Author', 'Committed'], + rows: (item) => [item.hash.slice(0, 7), item.subject, item.author, item.committed], + } + ); + }, +}; diff --git a/src/commands/memory/index.ts b/src/commands/memory/index.ts index e218ce2..b002030 100644 --- a/src/commands/memory/index.ts +++ b/src/commands/memory/index.ts @@ -1,13 +1,5 @@ import { memoryListCommand } from './list'; -import { memoryFindCommand } from './find'; import { memoryShowCommand } from './show'; -import { memorySaveCommand } from './save'; -import { memoryDeleteCommand } from './delete'; +import { memoryHistoryCommand } from './history'; -export const memoryCommands = [ - memoryListCommand, - memoryFindCommand, - memoryShowCommand, - memorySaveCommand, - memoryDeleteCommand, -]; +export const memoryCommands = [memoryListCommand, memoryShowCommand, memoryHistoryCommand]; diff --git a/src/commands/memory/list.ts b/src/commands/memory/list.ts index f45b361..bf3a41d 100644 --- a/src/commands/memory/list.ts +++ b/src/commands/memory/list.ts @@ -2,52 +2,33 @@ import type { Command } from '../../command'; import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; import { formatList } from '../../output/formatter'; -import { projectItems } from '../../output/project'; -import { requireWorkspace, getArgNumber, getArgBoolean } from '../helpers'; - -const FIELDS = ['id', 'title', 'labels', 'sourceType', 'created']; +import { requireWorkspace, getArgString } from '../helpers'; export const memoryListCommand: Command = { name: 'memory list', - description: 'List memories', - operationId: 'memories.list', - options: [ - { flag: '--limit ', description: 'Max items (default 20)', type: 'number' }, - { flag: '--full', description: 'Return full objects', type: 'boolean' }, - ], + description: 'List files and folders in the workspace memory repository', + operationId: 'memories.tree', + options: [{ flag: '--path ', description: 'Folder to list (default: repository root)', type: 'string' }], + examples: ['polylane memory list', 'polylane memory list --path services'], async execute(config: Config, _flags, args: Record): Promise { const workspaceId = await requireWorkspace(config); - const limit = getArgNumber(args, 'limit') ?? 20; - const full = getArgBoolean(args, 'full') === true; + const dir = (getArgString(args, 'path') ?? '').replace(/^\/+|\/+$/g, ''); const api = new PolylaneAPI(config); - const result = await api.memoriesList(workspaceId, { perPage: limit }); + const result = await api.memoriesTree(workspaceId, dir ? { path: dir } : undefined); - if (full) { - formatList(config, result as { items: Array>; count: number }); - return; - } + const items = result.entries.map((e) => ({ + path: dir ? `${dir}/${e.name}` : e.name, + type: e.type === 'tree' ? 'dir' : 'file', + })); formatList( config, + { items, count: items.length }, { - items: projectItems(result.items as unknown as Array>, FIELDS), - count: result.count, - }, - { - headers: ['ID', 'Title', 'Labels', 'Source', 'Created'], - rows: (item) => [ - String(item.id ?? ''), - truncate(String(item.title ?? ''), 50), - Array.isArray(item.labels) ? item.labels.join(',') : '', - String(item.sourceType ?? ''), - String(item.created ?? ''), - ], + headers: ['Path', 'Type'], + rows: (item) => [String(item.path), String(item.type)], } ); }, }; - -function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max - 1) + '…' : s; -} diff --git a/src/commands/memory/save.ts b/src/commands/memory/save.ts deleted file mode 100644 index 1d8b901..0000000 --- a/src/commands/memory/save.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatOutput } from '../../output/formatter'; -import { requireWorkspace, requirePositional, getAllPositional, getArgString } from '../helpers'; - -export const memorySaveCommand: Command = { - name: 'memory save', - description: 'Save a memory', - operationId: 'memories.post', - positional: [{ name: 'content', description: 'Markdown content of the memory', variadic: true }], - options: [ - { flag: '--title ', description: 'Title (default: first line of content)', type: 'string' }, - { flag: '--labels ', description: 'Comma-separated labels', type: 'string' }, - { flag: '--source-type ', description: 'investigation | change_analysis | observation', type: 'string' }, - ], - examples: ['polylane memory save "The auth service uses Redis for sessions"'], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - requirePositional(args, 0, 'content'); - const content = getAllPositional(args).join(' '); - - let title = getArgString(args, 'title'); - if (!title) { - const firstLine = content.split('\n')[0] ?? content; - title = firstLine.length > 80 ? firstLine.slice(0, 77) + '…' : firstLine; - } - - const labelsRaw = getArgString(args, 'labels'); - const labels = labelsRaw ? labelsRaw.split(',').map((s) => s.trim()) : undefined; - const sourceType = getArgString(args, 'sourceType') as - | 'investigation' - | 'change_analysis' - | 'observation' - | undefined; - - const api = new PolylaneAPI(config); - const result = await api.memoriesPost({ - workspaceId, - title, - markdown: content, - ...(labels ? { labels } : {}), - ...(sourceType ? { sourceType } : {}), - }); - - formatOutput(config, { id: result.id, title: result.title }); - }, -}; diff --git a/src/commands/memory/show.ts b/src/commands/memory/show.ts index 36615ee..3513b05 100644 --- a/src/commands/memory/show.ts +++ b/src/commands/memory/show.ts @@ -6,14 +6,14 @@ import { requireWorkspace, requirePositional } from '../helpers'; export const memoryShowCommand: Command = { name: 'memory show', - description: 'Show a memory with its markdown body', - operationId: 'memories.get', - positional: [{ name: 'memory-id', description: 'The memory ID' }], + description: 'Show a file from the workspace memory repository', + operationId: 'memories.file', + positional: [{ name: 'path', description: 'File path in the memory repository (from `memory list`)' }], + examples: ['polylane memory show services/payments.md'], async execute(config: Config, _flags, args: Record): Promise { const workspaceId = await requireWorkspace(config); - const id = requirePositional(args, 0, 'memory-id'); + const path = requirePositional(args, 0, 'path').replace(/^\/+/, ''); const api = new PolylaneAPI(config); - const result = await api.memoriesGet(workspaceId, id); - formatOutput(config, result); + formatOutput(config, await api.memoriesFile(workspaceId, { path })); }, }; diff --git a/src/commands/note/delete.ts b/src/commands/note/delete.ts deleted file mode 100644 index 2715381..0000000 --- a/src/commands/note/delete.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { requireWorkspace, requirePositional, getArgBoolean } from '../helpers'; -import { promptConfirm } from '../../utils/prompt'; -import { isInteractive } from '../../utils/env'; - -export const noteDeleteCommand: Command = { - name: 'note delete', - description: 'Delete a daily note', - operationId: 'notes.del', - positional: [{ name: 'note-id', description: 'The daily note ID' }], - options: [{ flag: '--yes', description: 'Skip confirmation', type: 'boolean' }], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const id = requirePositional(args, 0, 'note-id'); - - if (getArgBoolean(args, 'yes') !== true && isInteractive(config.nonInteractive)) { - const ok = await promptConfirm( - { nonInteractive: config.nonInteractive }, - `Delete note ${id}?`, - false - ); - if (!ok) { - process.stderr.write('Cancelled. Nothing changed.\n'); - return; - } - } - - const api = new PolylaneAPI(config); - const result = await api.notesDel(workspaceId, id); - process.stdout.write(`Deleted ${result.id}\n`); - }, -}; diff --git a/src/commands/note/global-clear.ts b/src/commands/note/global-clear.ts deleted file mode 100644 index 46eeaa8..0000000 --- a/src/commands/note/global-clear.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { requireWorkspace, getArgBoolean } from '../helpers'; -import { promptConfirm } from '../../utils/prompt'; -import { isInteractive } from '../../utils/env'; - -export const noteGlobalClearCommand: Command = { - name: 'note global clear', - description: 'Delete the workspace global note', - operationId: 'notes.global.del', - options: [{ flag: '--yes', description: 'Skip confirmation', type: 'boolean' }], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - - if (getArgBoolean(args, 'yes') !== true && isInteractive(config.nonInteractive)) { - const ok = await promptConfirm( - { nonInteractive: config.nonInteractive }, - `Clear the workspace global note?`, - false - ); - if (!ok) { - process.stderr.write('Cancelled. Nothing changed.\n'); - return; - } - } - - const api = new PolylaneAPI(config); - await api.notesGlobalDel(workspaceId); - process.stdout.write(`Cleared global note\n`); - }, -}; diff --git a/src/commands/note/global-set.ts b/src/commands/note/global-set.ts deleted file mode 100644 index 5c6582b..0000000 --- a/src/commands/note/global-set.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatOutput } from '../../output/formatter'; -import { requireWorkspace, requirePositional, getAllPositional } from '../helpers'; - -export const noteGlobalSetCommand: Command = { - name: 'note global set', - description: 'Replace the workspace global note', - operationId: 'notes.global.put', - positional: [{ name: 'content', description: 'Markdown content', variadic: true }], - examples: ['polylane note global set "Production runbook: …"'], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - requirePositional(args, 0, 'content'); - const content = getAllPositional(args).join(' '); - const api = new PolylaneAPI(config); - const result = await api.notesGlobalPut(workspaceId, { content }); - formatOutput(config, result); - }, -}; diff --git a/src/commands/note/global-show.ts b/src/commands/note/global-show.ts deleted file mode 100644 index af90abb..0000000 --- a/src/commands/note/global-show.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatOutput } from '../../output/formatter'; -import { requireWorkspace } from '../helpers'; - -export const noteGlobalShowCommand: Command = { - name: 'note global show', - description: 'Show the workspace global note', - operationId: 'notes.global.get', - async execute(config: Config, _flags, _args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const api = new PolylaneAPI(config); - const result = await api.notesGlobalGet(workspaceId); - formatOutput(config, result); - }, -}; diff --git a/src/commands/note/index.ts b/src/commands/note/index.ts deleted file mode 100644 index 6196f47..0000000 --- a/src/commands/note/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { noteListCommand } from './list'; -import { noteShowCommand } from './show'; -import { noteSaveCommand } from './save'; -import { noteDeleteCommand } from './delete'; -import { noteGlobalShowCommand } from './global-show'; -import { noteGlobalSetCommand } from './global-set'; -import { noteGlobalClearCommand } from './global-clear'; - -export const noteCommands = [ - noteListCommand, - noteShowCommand, - noteSaveCommand, - noteDeleteCommand, - noteGlobalShowCommand, - noteGlobalSetCommand, - noteGlobalClearCommand, -]; diff --git a/src/commands/note/list.ts b/src/commands/note/list.ts deleted file mode 100644 index 81cb0e1..0000000 --- a/src/commands/note/list.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatList } from '../../output/formatter'; -import { projectItems } from '../../output/project'; -import { requireWorkspace, getArgNumber, getArgBoolean } from '../helpers'; - -const FIELDS = ['id', 'date', 'threadCount', 'updated']; - -export const noteListCommand: Command = { - name: 'note list', - description: 'List daily notes', - operationId: 'notes.list', - options: [ - { flag: '--limit ', description: 'Max items (default 20)', type: 'number' }, - { flag: '--full', description: 'Return full objects', type: 'boolean' }, - ], - examples: ['polylane note list', 'polylane note list --limit 7'], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const limit = getArgNumber(args, 'limit') ?? 20; - const full = getArgBoolean(args, 'full') === true; - - const api = new PolylaneAPI(config); - const result = await api.notesList(workspaceId, { perPage: limit }); - - if (full) { - formatList(config, result as { items: Array>; count: number }); - return; - } - - formatList( - config, - { - items: projectItems(result.items as unknown as Array>, FIELDS), - count: result.count, - }, - { - headers: ['ID', 'Date', 'Threads', 'Updated'], - rows: (item) => [ - String(item.id ?? ''), - String(item.date ?? ''), - String(item.threadCount ?? ''), - String(item.updated ?? ''), - ], - } - ); - }, -}; diff --git a/src/commands/note/save.ts b/src/commands/note/save.ts deleted file mode 100644 index 59cc552..0000000 --- a/src/commands/note/save.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatOutput } from '../../output/formatter'; -import { requireWorkspace, requirePositional, getAllPositional, getArgString } from '../helpers'; -import { CLIError } from '../../errors/base'; -import { ExitCode } from '../../errors/codes'; - -export const noteSaveCommand: Command = { - name: 'note save', - description: 'Replace a daily note for a given date', - operationId: 'notes.put', - positional: [ - { name: 'note-id', description: 'The daily note ID' }, - { name: 'content', description: 'Markdown content', variadic: true }, - ], - options: [ - { flag: '--date ', description: 'Update the note date as well', type: 'string' }, - ], - examples: [ - 'polylane note save note_xxx "Deployed v0.2 to prod"', - ], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const id = requirePositional(args, 0, 'note-id'); - const contentParts = getAllPositional(args).slice(1); - if (contentParts.length === 0) { - throw new CLIError( - 'Missing ', - ExitCode.USAGE, - 'polylane note save ""' - ); - } - const content = contentParts.join(' '); - const date = getArgString(args, 'date'); - - const api = new PolylaneAPI(config); - const result = await api.notesPut(workspaceId, id, { - content, - ...(date ? { date } : {}), - }); - formatOutput(config, result); - }, -}; diff --git a/src/commands/note/show.ts b/src/commands/note/show.ts deleted file mode 100644 index 3e381ac..0000000 --- a/src/commands/note/show.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Command } from '../../command'; -import type { Config } from '../../config/schema'; -import { PolylaneAPI } from '../../generated/client'; -import { formatOutput } from '../../output/formatter'; -import { requireWorkspace, requirePositional } from '../helpers'; - -export const noteShowCommand: Command = { - name: 'note show', - description: 'Show a daily note', - operationId: 'notes.get', - positional: [{ name: 'note-id', description: 'The daily note ID' }], - async execute(config: Config, _flags, args: Record): Promise { - const workspaceId = await requireWorkspace(config); - const id = requirePositional(args, 0, 'note-id'); - const api = new PolylaneAPI(config); - const result = await api.notesGet(workspaceId, id); - formatOutput(config, result); - }, -}; diff --git a/src/commands/service/find.ts b/src/commands/service/find.ts index 05a4042..578410d 100644 --- a/src/commands/service/find.ts +++ b/src/commands/service/find.ts @@ -3,34 +3,31 @@ import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; import { formatList } from '../../output/formatter'; import { requireWorkspace, requirePositional, getAllPositional, getArgString } from '../helpers'; -import type { InfrastructureNode } from '../../generated/types'; +import { findServiceNodes, formatServiceId } from './id'; export const serviceFindCommand: Command = { name: 'service find', - description: 'Natural-language search over cloud infrastructure', - operationId: 'cloud_infra.nodes.search', - positional: [{ name: 'prompt', description: 'Search prompt', variadic: true }], + description: 'Find cloud infrastructure by part of its id, name or alternate name', + operationId: 'cloud_infra.nodes.list', + positional: [{ name: 'query', description: 'Part of the id, name or alternate name', variadic: true }], options: [ { flag: '--provider

', description: 'Restrict to a provider', type: 'string' }, { flag: '--type ', description: 'Restrict to a resource type', type: 'string' }, ], - examples: ['polylane service find "lambda functions handling payments"'], + examples: ['polylane service find payments', 'polylane service find api --provider cloudflare'], async execute(config: Config, _flags, args: Record): Promise { const workspaceId = await requireWorkspace(config); - requirePositional(args, 0, 'prompt'); - const prompt = getAllPositional(args).join(' '); + requirePositional(args, 0, 'query'); + const query = getAllPositional(args).join(' '); const api = new PolylaneAPI(config); const provider = getArgString(args, 'provider'); const type = getArgString(args, 'type'); - const items = await api.cloudInfraNodesSearch( - { workspaceId, prompt }, - provider || type ? { provider, type } : undefined - ); + const { nodes: items, truncated } = await findServiceNodes(api, workspaceId, query, { provider, type }); - const projected = items.map((n: InfrastructureNode) => ({ - id: `${n.provider}/${n.account}/${n.region}/${n.type}/${n.id}`, + const projected = items.map((n) => ({ + id: formatServiceId(n), type: n.type, provider: n.provider, account: n.account, @@ -39,7 +36,8 @@ export const serviceFindCommand: Command = { formatList( config, - { items: projected, count: projected.length }, + // truncated tells a script reading JSON that more services match than listed. + { items: projected, count: projected.length, truncated }, { headers: ['ID', 'Type', 'Provider', 'Account', 'Region'], rows: (item) => [ @@ -51,5 +49,9 @@ export const serviceFindCommand: Command = { ], } ); + // On stderr, so stdout stays the list a script can parse. + if (truncated && !config.quiet) { + process.stderr.write(`More services match "${query}" than shown. Narrow the query, or add --provider or --type.\n`); + } }, }; diff --git a/src/commands/service/id.ts b/src/commands/service/id.ts index fc7a01c..beccb98 100644 --- a/src/commands/service/id.ts +++ b/src/commands/service/id.ts @@ -2,6 +2,7 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; +import type { InfrastructureNode } from '../../generated/types'; export type Provider = | 'aws' @@ -56,38 +57,159 @@ export function formatServiceId(node: { return `${node.provider}/${node.account}/${node.region}/${node.type}/${node.id}`; } +const NAME_FIELDS = ['id', 'name', 'alternate_names'] as const; +const DEFAULT_FIND_LIMIT = 50; + +export interface FindServiceOptions { + provider?: string; + type?: string; + limit?: number; +} + +type NodesListApi = Pick; + +export interface NodeLookup { + nodes: InfrastructureNode[]; + /** A field stopped before reading every row, so more nodes may match than were returned. */ + truncated: boolean; +} + +// The graph wraps a LIKE value in % only when it holds no % or _, so a query with an +// underscore would be matched as a whole value. Sending the pattern ourselves keeps it a +// substring match; the _ it carries is a one-character wildcard, so containsQuery drops the +// extra rows that lets through (case-insensitive, like SQLite's LIKE). +function containsQuery(node: InfrastructureNode, query: string): boolean { + const q = query.toLowerCase(); + const names = [node.id, node.data?.name, ...(node.alternateNames ?? [])]; + return names.some((n) => typeof n === 'string' && n.toLowerCase().includes(q)); +} + +function isExactMatch(node: InfrastructureNode, raw: string): boolean { + return node.id === raw || node.data?.name === raw || node.alternateNames?.includes(raw) === true; +} + +// Rows are read in pages, ordered by id so offsets are stable, until a field has `limit` +// kept matches or runs out. The cap bounds the work when `_` wildcards match many rows that +// containsQuery then drops; a field that hits it counts as truncated. +const PAGE_SIZE = 200; +const MAX_PAGES = 10; + +// The graph joins clauses after the scope filters without parentheses, so an OR clause would +// escape provider/type. One query per name field, merged, keeps every match scoped. +async function lookupNodes( + api: NodesListApi, + workspaceId: string, + query: string, + mode: 'exact' | 'substring', + opts: FindServiceOptions = {} +): Promise { + const limit = opts.limit ?? DEFAULT_FIND_LIMIT; + const filters = { + ...(opts.provider ? { provider: opts.provider } : {}), + ...(opts.type ? { type: opts.type } : {}), + }; + const clause = + mode === 'exact' + ? { value: query, predicate: '=' as const } + : { value: `%${query}%`, predicate: 'LIKE' as const }; + const keep = mode === 'exact' ? isExactMatch : containsQuery; + + const scanField = async (field: (typeof NAME_FIELDS)[number]): Promise => { + const nodes: InfrastructureNode[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const { items } = await api.cloudInfraNodesList({ + workspaceId, + clauses: [{ field, ...clause }], + ...(Object.keys(filters).length ? { filters } : {}), + options: { limit: PAGE_SIZE, offset: page * PAGE_SIZE, orderBy: 'id' }, + }); + for (const node of items) { + if (!keep(node, query)) continue; + nodes.push(node); + if (nodes.length >= limit) return { nodes, truncated: true }; + } + if (items.length < PAGE_SIZE) return { nodes, truncated: false }; + } + return { nodes, truncated: true }; + }; + + const results = await Promise.all(NAME_FIELDS.map(scanField)); + const seen = new Set(); + const nodes: InfrastructureNode[] = []; + for (const node of results.flatMap((r) => r.nodes)) { + const key = formatServiceId(node); + if (seen.has(key)) continue; + seen.add(key); + nodes.push(node); + } + return { nodes, truncated: results.some((r) => r.truncated) }; +} + +/** + * Services whose id, name or an alternate name contains the query, for `service find`. + * `truncated` says more may match than were returned, so the caller can say so. + */ +export async function findServiceNodes( + api: NodesListApi, + workspaceId: string, + query: string, + opts: FindServiceOptions = {} +): Promise { + const limit = opts.limit ?? DEFAULT_FIND_LIMIT; + const { nodes, truncated } = await lookupNodes(api, workspaceId, query, 'substring', opts); + return { nodes: nodes.slice(0, limit), truncated: truncated || nodes.length > limit }; +} + +function toServiceId(node: InfrastructureNode): ServiceId { + return { + provider: node.provider as Provider, + account: node.account, + region: node.region, + type: node.type, + id: node.id, + }; +} + export async function resolveServiceId( config: Config, raw: string, - workspaceId: string + workspaceId: string, + api: NodesListApi = new PolylaneAPI(config) ): Promise { const strict = parseServiceIdStrict(raw); if (strict) return strict; - const api = new PolylaneAPI(config); - const matches = await api.cloudInfraNodesSearch({ workspaceId, prompt: raw }); + // Exact matches are queried on their own, before any substring page is cut, so a long + // list of partial matches can neither hide the exact one nor make a shared name look unique. + const exact = await lookupNodes(api, workspaceId, raw, 'exact'); + if (exact.nodes.length === 1) return toServiceId(exact.nodes[0]!); + if (exact.nodes.length > 1) throw multipleMatches(raw, exact.nodes); - if (matches.length === 0) { + const partial = await lookupNodes(api, workspaceId, raw, 'substring'); + // A capped scan that kept nothing hasn't shown the service is missing, only that it ran out. + if (partial.nodes.length === 0 && partial.truncated) { throw new CLIError( - `No service found matching "${raw}"`, + `Couldn't finish looking up "${raw}": too many services partly match it`, ExitCode.GENERAL, - 'Use the full form: ////' + 'Use the full form: ////\n' + + 'or narrow the search: polylane service find "" --provider

--type ' ); } - - if (matches.length === 1) { - const m = matches[0]!; - return { - provider: m.provider as Provider, - account: m.account, - region: m.region, - type: m.type, - id: m.id, - }; + if (partial.nodes.length === 0) { + throw new CLIError( + `No service found matching "${raw}"`, + ExitCode.GENERAL, + 'polylane service find ""\n' + + 'or use the full form: ////' + ); } + if (partial.nodes.length === 1 && !partial.truncated) return toServiceId(partial.nodes[0]!); + throw multipleMatches(raw, partial.nodes); +} - const topK = matches.slice(0, 5).map((m) => ` ${formatServiceId(m)}`).join('\n'); - throw new CLIError( +function multipleMatches(raw: string, candidates: InfrastructureNode[]): CLIError { + const topK = candidates.slice(0, 5).map((m) => ` ${formatServiceId(m)}`).join('\n'); + return new CLIError( `Multiple services match "${raw}"`, ExitCode.USAGE, `Candidates:\n${topK}\n\nUse the full form to disambiguate.` diff --git a/src/commands/thread/ask.ts b/src/commands/thread/ask.ts index 6c45b9f..2eb97e9 100644 --- a/src/commands/thread/ask.ts +++ b/src/commands/thread/ask.ts @@ -11,14 +11,12 @@ type ContextType = | 'repository' | 'cloud_account' | 'thread' - | 'infrastructure_node' - | 'memory'; + | 'infrastructure_node'; function inferContextType(id: string): ContextType { if (id.startsWith('repo_')) return 'repository'; if (id.startsWith('acc_')) return 'cloud_account'; if (id.startsWith('thrd_')) return 'thread'; - if (id.startsWith('mem_')) return 'memory'; return 'infrastructure_node'; } diff --git a/src/output/formatter.ts b/src/output/formatter.ts index 37f50cf..35c5ecd 100644 --- a/src/output/formatter.ts +++ b/src/output/formatter.ts @@ -74,7 +74,7 @@ export function formatOutput(config: Config, data: unknown): void { export function formatList>( config: Config, - result: { items: T[]; count: number }, + result: { items: T[]; count: number; truncated?: boolean }, table?: TableDef ): void { if (config.output === 'json') { diff --git a/src/registry.ts b/src/registry.ts index b0eb185..87ecbfd 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -15,13 +15,11 @@ export interface ResourceGroup { } const RESOURCE_ORDER: Record = { - feed: { name: 'feed', description: 'Workspace activity feed (what just happened)', order: 6 }, issue: { name: 'issue', description: 'Detected issues (anomalies + alerts) and their timelines', order: 8 }, service: { name: 'service', description: 'Cloud infrastructure (nodes, logs, metrics, graph)', order: 20 }, repo: { name: 'repo', description: 'Repositories', order: 30 }, tools: { name: 'tools', description: 'Run Polylane agent tools (observability, infra graph, GitHub reads)', order: 40 }, - memory: { name: 'memory', description: 'Persistent agent memory', order: 50 }, - note: { name: 'note', description: 'Daily and global workspace notes', order: 52 }, + memory: { name: 'memory', description: 'Workspace memory repository (files, history)', order: 50 }, thread: { name: 'thread', description: 'Conversation threads', order: 60 }, artifact: { name: 'artifact', description: 'Per-thread artifacts (reports, diagrams, PRs)', order: 61 }, autofix: { name: 'autofix', description: 'Automated code fixes (read-only)', order: 66 }, diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 3cef9b6..a3b86f2 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -51,12 +51,10 @@ describe('command resolution', () => { 'autofix', 'cloud', 'config', - 'feed', 'help', 'integration', 'issue', 'memory', - 'note', 'repo', 'service', 'setup', @@ -87,10 +85,10 @@ describe('unknown command handling', () => { }) as typeof process.stdout.write; try { await assert.rejects( - helpCommand.execute(mockConfig(), {}, { _: ['nope', 'sub'] }), + helpCommand.execute(mockConfig(), {}, { _: ['isue', 'sub'] }), (err: unknown) => { assert.ok(err instanceof CLIError); - assert.equal(err.message, 'Unknown command: polylane nope sub. Closest match: polylane note.'); + assert.equal(err.message, 'Unknown command: polylane isue sub. Closest match: polylane issue.'); assert.equal(err.exitCode, ExitCode.USAGE); assert.ok(err.hint?.includes('polylane --help')); return true; diff --git a/test/service-find.test.ts b/test/service-find.test.ts new file mode 100644 index 0000000..71ed1d8 --- /dev/null +++ b/test/service-find.test.ts @@ -0,0 +1,244 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { findServiceNodes, resolveServiceId } from '../src/commands/service/id'; +import { CLIError } from '../src/errors/base'; +import type { Config } from '../src/config/schema'; +import type { PolylaneAPI } from '../src/generated/client'; +import type { InfrastructureNode } from '../src/generated/types'; + +type ListBody = Parameters[0]; + +const config = { nonInteractive: true } as Config; + +function node(id: string, extra: Partial = {}): InfrastructureNode { + return { + workspaceId: 'ws_1', + data: {}, + alternateNames: [], + createdBy: 'u', + lastEditedBy: 'u', + updated: null, + created: null, + id, + provider: 'cloudflare', + account: 'acct', + region: 'global', + type: 'cf.workers.script', + category: 'compute', + _object: 'infra_node', + ...extra, + }; +} + +// A stand-in for the graph's node list: applies one clause per request the way the graph +// does ("=" on a field, LIKE with % and _ wildcards, case-insensitive), then the page limit. +function graphApi(nodes: InfrastructureNode[], seen: ListBody[] = []) { + const fieldValues = (n: InfrastructureNode, field: string): unknown[] => + field === 'id' ? [n.id] : field === 'name' ? [n.data?.name] : field === 'alternate_names' ? n.alternateNames ?? [] : []; + const likeToRegExp = (pattern: string) => + new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.')}$`, 'i'); + return { + async cloudInfraNodesList(body: ListBody) { + seen.push(body); + const clause = body.clauses?.[0]; + const offset = body.options?.offset ?? 0; + const ordered = body.options?.orderBy === 'id' ? [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) : nodes; + const items = ordered + .filter((n) => { + if (!clause) return true; + const values = fieldValues(n, clause.field); + if (clause.predicate === 'LIKE') { + // alternate_names is matched as its JSON text, as the graph stores it. + const text = clause.field === 'alternate_names' ? [JSON.stringify(values)] : values; + return text.some((v) => typeof v === 'string' && likeToRegExp(String(clause.value)).test(v)); + } + return values.includes(clause.value); + }) + .slice(offset, offset + (body.options?.limit ?? 200)); + return { items, count: items.length }; + }, + } as unknown as Pick; +} + +describe('findServiceNodes', () => { + it('sends one scoped LIKE query per name field and merges without duplicates', async () => { + const seen: ListBody[] = []; + const api = graphApi([node('payments-api', { data: { name: 'payments-api' } }), node('worker-2', { data: { name: 'payments-worker' } })], seen); + const { nodes: items } = await findServiceNodes(api, 'ws_1', 'payments', { provider: 'cloudflare' }); + + assert.deepEqual( + items.map((n) => n.id), + ['payments-api', 'worker-2'] + ); + assert.deepEqual( + seen.map((b) => b.clauses), + ['id', 'name', 'alternate_names'].map((field) => [{ field, value: '%payments%', predicate: 'LIKE' }]) + ); + for (const b of seen) { + assert.equal(b.workspaceId, 'ws_1'); + assert.deepEqual(b.filters, { provider: 'cloudflare' }); + assert.deepEqual(b.options, { limit: 200, offset: 0, orderBy: 'id' }); + } + }); + + it('says when more services match than it returns', async () => { + const many = graphApi(Array.from({ length: 60 }, (_, i) => node(`pay-${i}`))); + assert.equal((await findServiceNodes(many, 'ws_1', 'pay')).truncated, true); + assert.equal((await findServiceNodes(many, 'ws_1', 'pay')).nodes.length, 50); + const few = graphApi([node('pay-1'), node('pay-2')]); + assert.equal((await findServiceNodes(few, 'ws_1', 'pay')).truncated, false); + }); + + it('omits filters when none are given', async () => { + const seen: ListBody[] = []; + await findServiceNodes(graphApi([], seen), 'ws_1', 'x'); + assert.ok(seen.every((b) => !('filters' in b))); + }); + + it('keeps substring matching for a query with an underscore, in every name field', async () => { + const api = graphApi([ + node('prod-payments_api-worker'), + node('n2', { data: { name: 'prod-payments_api-cron' } }), + node('n3', { alternateNames: ['payments_api'] }), + node('payments-api-lookalike'), + ]); + const { nodes: items } = await findServiceNodes(api, 'ws_1', 'payments_api', { provider: 'cloudflare', type: 'cf.workers.script' }); + assert.deepEqual(items.map((n) => n.id).sort(), ['n2', 'n3', 'prod-payments_api-worker']); + }); +}); + +describe('underscore wildcards that fill whole pages', () => { + // `_` matches any one character, so prod-payments-api-N all match %payments_api%; with + // 250 of them the literal match sits past the first 200-row page. + const wildcardOnly = (make: (i: number) => InfrastructureNode) => Array.from({ length: 250 }, (_, i) => make(i)); + + it('finds a literal id match past a page of wildcard-only hits', async () => { + const api = graphApi([...wildcardOnly((i) => node(`prod-payments-api-${i}`)), node('prod-payments_api-worker')]); + const { nodes: items } = await findServiceNodes(api, 'ws_1', 'payments_api'); + assert.deepEqual(items.map((n) => n.id), ['prod-payments_api-worker']); + }); + + it('finds a literal name or alias match past wildcard-only hits, keeping the scope filters', async () => { + const seen: ListBody[] = []; + const api = graphApi( + [ + ...wildcardOnly((i) => node(`a-${i}`, { data: { name: `prod-payments-api-${i}` }, alternateNames: [`prod-payments-api-${i}`] })), + node('zz-name', { data: { name: 'prod-payments_api-worker' } }), + node('zz-alias', { alternateNames: ['prod-payments_api-worker'] }), + ], + seen + ); + const { nodes: items } = await findServiceNodes(api, 'ws_1', 'payments_api', { provider: 'cloudflare', type: 'cf.workers.script' }); + assert.deepEqual(items.map((n) => n.id).sort(), ['zz-alias', 'zz-name']); + assert.ok(seen.every((b) => b.filters?.provider === 'cloudflare' && b.filters?.type === 'cf.workers.script')); + }); + + it('applies the limit to literal matches, not raw rows', async () => { + const api = graphApi([node('prod-payments-api-worker'), node('prod-payments_api-worker')]); + const { nodes: items } = await findServiceNodes(api, 'ws_1', 'payments_api', { limit: 1 }); + assert.deepEqual(items.map((n) => n.id), ['prod-payments_api-worker']); + }); + + for (const [label, make] of [ + ['id', (id: string) => node(id)], + ['name', (name: string) => node(`zz-${name.length}`, { data: { name } })], + ['alias', (alias: string) => node(`zz-${alias.length}`, { alternateNames: [alias] })], + ] as const) { + it(`resolves the sole literal ${label} match behind a page of wildcard-only hits`, async () => { + const literal = make('prod-payments_api-worker'); + const api = graphApi([...wildcardOnly((i) => make(`prod-payments-api-${i}`)), literal]); + const out = await resolveServiceId(config, 'payments_api', 'ws_1', api); + assert.equal(out.id, literal.id); + }); + } +}); + +describe('a search that hits the page cap', () => { + // 10 pages of 200 wildcard-only rows sort ahead of the literal match, so the scan stops first. + const capped = (make: (i: number) => InfrastructureNode, literal: InfrastructureNode) => + graphApi([...Array.from({ length: 2001 }, (_, i) => make(i)), literal]); + + it('reports an incomplete lookup instead of "No service found"', async () => { + for (const api of [ + capped((i) => node(`aa-payments-api-${String(i).padStart(4, '0')}`), node('zz-payments_api-worker')), + capped((i) => node(`aa-${i}`, { data: { name: `prod-payments-api-${i}` } }), node('zz-target', { data: { name: 'prod-payments_api-worker' } })), + capped((i) => node(`aa-${i}`, { alternateNames: [`prod-payments-api-${i}`] }), node('zz-target', { alternateNames: ['prod-payments_api-worker'] })), + ]) { + await assert.rejects( + () => resolveServiceId(config, 'payments_api', 'ws_1', api), + (err: unknown) => err instanceof CLIError && err.message.includes("Couldn't finish looking up") + ); + } + }); + + it('marks an empty capped find as truncated, so JSON output can say so', async () => { + const api = capped((i) => node(`aa-payments-api-${String(i).padStart(4, '0')}`), node('zz-payments_api-worker')); + const out = await findServiceNodes(api, 'ws_1', 'payments_api', { provider: 'cloudflare', type: 'cf.workers.script' }); + assert.deepEqual(out, { nodes: [], truncated: true }); + }); +}); + +describe('resolveServiceId', () => { + it('returns the full form without calling the API', async () => { + const seen: ListBody[] = []; + const out = await resolveServiceId(config, 'aws/1/us-east-1/aws.lambda.function/pay_api', 'ws_1', graphApi([], seen)); + assert.equal(out.id, 'pay_api'); + assert.equal(seen.length, 0); + }); + + it('picks the single exact match among substring matches', async () => { + const api = graphApi([node('a', { data: { name: 'payments' } }), node('b', { data: { name: 'payments-v2' } })]); + const out = await resolveServiceId(config, 'payments', 'ws_1', api); + assert.equal(out.id, 'a'); + }); + + it('finds an exact name or alias hidden behind a full page of partial matches', async () => { + const filler = Array.from({ length: 60 }, (_, i) => node(`payments-${i}`)); + const byName = await resolveServiceId(config, 'payments', 'ws_1', graphApi([...filler, node('x', { data: { name: 'payments' } })])); + assert.equal(byName.id, 'x'); + const byAlias = await resolveServiceId(config, 'payments', 'ws_1', graphApi([...filler, node('y', { alternateNames: ['payments'] })])); + assert.equal(byAlias.id, 'y'); + }); + + it('reports two exact matches as ambiguous, even when one sits behind partial matches', async () => { + const filler = Array.from({ length: 60 }, (_, i) => node(`payments-${i}`)); + const api = graphApi([...filler, node('payments'), node('z', { data: { name: 'payments' } })]); + await assert.rejects( + () => resolveServiceId(config, 'payments', 'ws_1', api), + (err: unknown) => err instanceof CLIError && err.message.includes('Multiple services') && (err.hint?.includes('/z') ?? false) + ); + }); + + it('resolves a short name with an underscore through its alias', async () => { + const out = await resolveServiceId(config, 'payments_api', 'ws_1', graphApi([node('n3', { alternateNames: ['payments_api'] })])); + assert.equal(out.id, 'n3'); + }); + + it('returns the only match', async () => { + const out = await resolveServiceId(config, 'pay', 'ws_1', graphApi([node('payments')])); + assert.equal(out.id, 'payments'); + }); + + it('treats a full page of partial matches as ambiguous', async () => { + const api = graphApi(Array.from({ length: 50 }, (_, i) => node(`pay-${i}`))); + await assert.rejects( + () => resolveServiceId(config, 'pay', 'ws_1', api), + (err: unknown) => err instanceof CLIError && err.message.includes('Multiple services') + ); + }); + + it('throws with candidates when several match', async () => { + const api = graphApi([node('pay-1'), node('pay-2')]); + await assert.rejects( + () => resolveServiceId(config, 'pay', 'ws_1', api), + (err: unknown) => err instanceof CLIError && err.message.includes('Multiple services') && (err.hint?.includes('pay-2') ?? false) + ); + }); + + it('throws when nothing matches', async () => { + await assert.rejects( + () => resolveServiceId(config, 'nope', 'ws_1', graphApi([])), + (err: unknown) => err instanceof CLIError && err.message.includes('No service found') + ); + }); +});