@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451

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.
@@ -15,15 +15,42 @@ import type { CallToolResult } from '@modelcontextprotocol/server';
15
15
  import { z } from 'zod';
16
16
  import { toolError, toolResult } from './errors.js';
17
17
 
18
- /** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
18
+ /** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
19
19
  export type Sections = Record<string, unknown>;
20
20
 
21
+ /** A section stored as an object: its text and the metadata an edit keeps. */
22
+ export interface StoredSection {
23
+ content?: unknown;
24
+ title?: unknown;
25
+ priority?: unknown;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ /** A document's `data`: a section-shaped document keeps its content under `sections`. */
30
+ export interface DocumentData {
31
+ sections?: unknown;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ /** A document as these helpers read it: its `data` and the provenance a reader does not need. */
36
+ export interface StoredDocument {
37
+ data?: unknown;
38
+ derivedFrom?: unknown;
39
+ outdated?: unknown;
40
+ [key: string]: unknown;
41
+ }
42
+
21
43
  export function isRecord(value: unknown): value is Record<string, unknown> {
22
44
  return typeof value === 'object' && value !== null && !Array.isArray(value);
23
45
  }
24
46
 
47
+ /** A section stored as an object rather than a bare string. */
48
+ export function isSectionObject(value: unknown): value is StoredSection {
49
+ return isRecord(value);
50
+ }
51
+
25
52
  /** The `sections` map of a document, or null when the document is not section-shaped. */
26
- export function sectionsOf(data: Record<string, unknown>): Sections | null {
53
+ export function sectionsOf(data: DocumentData): Sections | null {
27
54
  return isRecord(data.sections) ? data.sections : null;
28
55
  }
29
56
 
@@ -32,7 +59,7 @@ export function sectionContent(sections: Sections, sectionId: string): string |
32
59
  const section = sections[sectionId];
33
60
  if (section === undefined || section === null) return null;
34
61
  if (typeof section === 'string') return section;
35
- return isRecord(section) && typeof section.content === 'string' ? section.content : '';
62
+ return isSectionObject(section) && typeof section.content === 'string' ? section.content : '';
36
63
  }
37
64
 
38
65
  /** The map with one section's text replaced (or the section added); every other section untouched. */
@@ -48,10 +75,12 @@ export function applySectionEdit(sections: Sections, sectionId: string, content:
48
75
  * the editor needs and a reader does not; `source` and `priority` stay, the
49
76
  * server instructions explain them.
50
77
  */
51
- export function stripProvenance(doc: Record<string, unknown>): Record<string, unknown> {
78
+ export function stripProvenance<T extends StoredDocument>(doc: T): Omit<T, 'derivedFrom' | 'outdated'> {
52
79
  const { derivedFrom: _derivedFrom, outdated: _outdated, ...rest } = doc;
53
- if (!isRecord(rest.data)) return rest;
54
- const sections = sectionsOf(rest.data);
80
+ // `rest` holds the same `data`; read through `doc`, whose type still names it.
81
+ const { data } = doc;
82
+ if (!isRecord(data)) return rest;
83
+ const sections = sectionsOf(data);
55
84
  if (!sections) return rest;
56
85
  const stripped = Object.fromEntries(
57
86
  Object.entries(sections).map(([id, section]) => {
@@ -60,7 +89,7 @@ export function stripProvenance(doc: Record<string, unknown>): Record<string, un
60
89
  return [id, keep];
61
90
  })
62
91
  );
63
- return { ...rest, data: { ...rest.data, sections: stripped } };
92
+ return { ...rest, data: { ...data, sections: stripped } };
64
93
  }
65
94
 
66
95
  /**
@@ -73,7 +102,7 @@ export function instructionsText(sections: Sections): string | null {
73
102
  for (const section of Object.values(sections)) {
74
103
  // Object sections only, as the API's extractor reads them: a bare-string
75
104
  // row the agents never receive is not handed to the model here either.
76
- const content = isRecord(section) ? section.content : undefined;
105
+ const content = isSectionObject(section) ? section.content : undefined;
77
106
  if (typeof content === 'string' && content.trim()) parts.push(content.trim());
78
107
  }
79
108
  const text = parts.join('\n\n').trim();
@@ -82,15 +111,15 @@ export function instructionsText(sections: Sections): string | null {
82
111
 
83
112
  /** A document cut to some of its sections, and the ones left out with their sizes. */
84
113
  export interface SectionCut {
85
- doc: Record<string, unknown>;
114
+ doc: StoredDocument;
86
115
  omittedSections: { id: string; characters: number }[];
87
116
  }
88
117
 
89
- function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections): SectionCut {
118
+ function cutTo(doc: StoredDocument, sections: Sections, kept: Sections): SectionCut {
90
119
  const omittedSections = Object.keys(sections)
91
120
  .filter((id) => !Object.hasOwn(kept, id))
92
121
  .map((id) => ({ id, characters: sectionContent(sections, id)?.length ?? 0 }));
93
- return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, omittedSections };
122
+ return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, omittedSections };
94
123
  }
95
124
 
96
125
  /**
@@ -100,7 +129,7 @@ function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections)
100
129
  * every other section is listed with its size so the reader knows what else
101
130
  * there is. Null when the document is not made of sections.
102
131
  */
103
- export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<string>): SectionCut | null {
132
+ export function pickSections(doc: StoredDocument, ids: ReadonlySet<string>): SectionCut | null {
104
133
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
105
134
  if (!sections) return null;
106
135
  return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
@@ -115,7 +144,7 @@ export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<stri
115
144
  * and a later, smaller one may still be carried. Null when the document is
116
145
  * not made of sections.
117
146
  */
118
- export function budgetSections(doc: Record<string, unknown>, budget: number): SectionCut | null {
147
+ export function budgetSections(doc: StoredDocument, budget: number): SectionCut | null {
119
148
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
120
149
  if (!sections) return null;
121
150
  // Entries, not assignment: a section id is text, and `kept["__proto__"] = x` would set a prototype.
@@ -139,7 +168,7 @@ export const sectionsInput = z
139
168
  .max(50)
140
169
  .optional()
141
170
  .describe(
142
- 'Only these sections, by id; [] for the outline (every section id and its size, no text); omit for the whole document'
171
+ 'Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
143
172
  );
144
173
 
145
174
  /**
@@ -150,11 +179,23 @@ export const sectionsInput = z
150
179
  * real id with its size, so the retry needs nothing else. No ids is the
151
180
  * outline.
152
181
  */
153
- export function sectionReadResult(doc: Record<string, unknown>, ids: readonly string[], label: string): CallToolResult {
182
+ export function sectionReadResult(doc: StoredDocument, ids: readonly string[], label: string): CallToolResult {
154
183
  const cut = pickSections(doc, new Set(ids));
155
184
  if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
156
- const all = Object.keys(sectionsOf(doc.data as Record<string, unknown>) ?? {});
185
+ const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
157
186
  const unknownSections = ids.filter((id) => !all.includes(id));
187
+ const known = ids.filter((id) => all.includes(id));
188
+ if (known.length === 0) {
189
+ // The outline, or a read naming only ids the document lacks: no text to
190
+ // carry, so no empty data.sections beside the ids, which read as a
191
+ // document with no sections (PRO-2800).
192
+ const { data: _data, ...meta } = cut.doc;
193
+ return toolResult({
194
+ ...meta,
195
+ outline: cut.omittedSections,
196
+ ...(unknownSections.length > 0 ? { unknownSections } : {}),
197
+ });
198
+ }
158
199
  return toolResult({
159
200
  ...cut.doc,
160
201
  omittedSections: cut.omittedSections,
@@ -1,10 +1,12 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { DEPLOYMENT_NOTES_MAX_LENGTH, REALITY_DERIVED_DOCUMENT_TYPES } from '@taiga/types';
3
3
  import { z } from 'zod';
4
- import { TaigaApiError, type TaigaClient } from '../client.js';
4
+ import type { Product } from '@taiga/types';
5
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
6
- import { isImportedProduct, setupNextStep, type ProductSettingsView } from './catalogue.js';
7
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
7
+ import { isImportedProduct } from './catalogue.js';
8
+ import { runTool, toolError, toolResult } from './errors.js';
9
+ import { setupReadiness } from './readiness.js';
8
10
  import { withStop } from './hints.js';
9
11
  import { idInput, productId } from './inputs.js';
10
12
  import { pickFields } from './summarize.js';
@@ -18,8 +20,6 @@ import { pickFields } from './summarize.js';
18
20
  * a product stay in the web.
19
21
  */
20
22
 
21
- type Row = Record<string, unknown>;
22
-
23
23
  const PRODUCT_FIELDS = [
24
24
  'id',
25
25
  'name',
@@ -63,42 +63,25 @@ const MISSING_DOCUMENTS_HOW =
63
63
  'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
64
64
  'A draft is published with taiga_publish_product_document.';
65
65
 
66
- /** A product row the model can act on, saying how it started as the other product tools say it. */
67
- async function productRow(client: TaigaClient, product: Row): Promise<Row> {
66
+ type ProductRow = WithoutTenant<Product>;
67
+
68
+ /** A product row the model can act on: how it started, and while in setup what it still needs. */
69
+ async function productRow(client: TaigaClient, product: ProductRow) {
68
70
  const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
69
- const nextStep = setupNextStep(await withDiscoveryStep(client, product));
71
+ const readiness = await setupReadiness(client, product);
70
72
  return {
71
73
  ...rest,
72
- projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
73
- ...(nextStep ? { nextStep } : {}),
74
+ projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
75
+ ...(readiness ? { readiness } : {}),
74
76
  };
75
77
  }
76
78
 
77
- /**
78
- * The product with its discoveryStep. The API derives it on reads only, not
79
- * on the answer to a write (products.ts), and a draft started from scratch
80
- * needs it to say what comes next; an imported one reads its import status
81
- * instead. Best effort: a failed read leaves the product without a nextStep
82
- * rather than failing the write that already happened.
83
- */
84
- async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row> {
85
- if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product as ProductSettingsView)) {
86
- return product;
87
- }
88
- try {
89
- return (await client.getProduct(String(product.id))).data as Row;
90
- } catch (error) {
91
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
92
- throw error;
93
- }
94
- }
95
-
96
79
  /**
97
80
  * A half turned off with nothing said about how it is done today. Allowed (the
98
81
  * web asks for the sentence but does not insist), so a warning rather than a
99
82
  * refusal: plans then design against a gap.
100
83
  */
101
- function missingNotesWarning(product: Row): string[] {
84
+ function missingNotesWarning(product: ProductRow): string[] {
102
85
  const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
103
86
  const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
104
87
  return off && !notes
@@ -144,7 +127,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
144
127
  },
145
128
  (params) =>
146
129
  runTool(async () =>
147
- toolResult(pickFields([(await client.createFactory({ name: params.name })).data as Row], FACTORY_FIELDS)[0])
130
+ toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0])
148
131
  )
149
132
  );
150
133
 
@@ -161,10 +144,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
161
144
  (params) =>
162
145
  runTool(async () =>
163
146
  toolResult(
164
- pickFields(
165
- [(await client.updateFactory(params.factoryId, { name: params.name })).data as Row],
166
- FACTORY_FIELDS
167
- )[0]
147
+ pickFields([(await client.updateFactory(params.factoryId, { name: params.name })).data], FACTORY_FIELDS)[0]
168
148
  )
169
149
  )
170
150
  );
@@ -193,8 +173,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
193
173
  'Move a product to another factory. Access follows the factory, so this changes who can reach the ' +
194
174
  "product (taiga_preview_product_move says who); the product's own knowledge files are re-filed onto " +
195
175
  'the new factory, and factory knowledge and instructions stay with the factory that owns them. Refused ' +
196
- '(CONFLICT) while a plan or build is in flight: stopLine: true stops the line first and then moves, which ' +
197
- 'interrupts that work.',
176
+ '(WORK_IN_FLIGHT) while a plan or build is in flight. Then ask the person: stopLine: true stops the ' +
177
+ 'line first and then moves, which interrupts that work.',
198
178
  inputSchema: z.object({
199
179
  productId,
200
180
  factoryId: factoryId.describe('The factory to move it to'),
@@ -207,12 +187,30 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
207
187
  },
208
188
  (params) =>
209
189
  runTool(async () => {
210
- const moved = await client.moveProduct(params.productId, {
211
- factoryId: params.factoryId,
212
- ...(params.stopLine ? { stopLine: true } : {}),
213
- });
190
+ let moved;
191
+ try {
192
+ moved = await client.moveProduct(params.productId, {
193
+ factoryId: params.factoryId,
194
+ ...(params.stopLine ? { stopLine: true } : {}),
195
+ });
196
+ } catch (error) {
197
+ // The API's own words say "Stop the line, then move the product",
198
+ // which reads as an instruction to the agent; whether to interrupt
199
+ // the work is the person's call (PRO-2800).
200
+ if (error instanceof TaigaApiError && error.code === 'WORK_IN_FLIGHT') {
201
+ return toolError(
202
+ 'WORK_IN_FLIGHT',
203
+ // The API's first sentence says what is in flight; its second ("Stop the line, then move the product")
204
+ // is the instruction this replaces.
205
+ `${withStop((error.apiMessage ?? 'Work is in flight on this product').split(/(?<=[.!?])\s/)[0]!)} Moving it now means stopping ` +
206
+ 'the line, which interrupts that work: ask the person, and pass stopLine: true only if they say so.',
207
+ error.details
208
+ );
209
+ }
210
+ throw error;
211
+ }
214
212
  return toolResult({
215
- product: await productRow(client, moved.data as Row),
213
+ product: await productRow(client, moved.data),
216
214
  changed: moved.changed,
217
215
  previousFactoryId: moved.previousFactoryId,
218
216
  lineStopped: moved.lineStopped,
@@ -260,7 +258,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
260
258
  ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
261
259
  ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
262
260
  })
263
- ).data as Row;
261
+ ).data;
264
262
  return toolResult(await productRow(client, product));
265
263
  })
266
264
  );
@@ -297,7 +295,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
297
295
  }
298
296
  const contradiction = notesWithNothingToDescribe(params);
299
297
  if (contradiction) return contradiction;
300
- const updated = (await client.updateProduct(id, changes)).data as Row;
298
+ const updated = (await client.updateProduct(id, changes)).data;
301
299
  const warnings = missingNotesWarning(updated);
302
300
  return toolResult({ ...(await productRow(client, updated)), ...(warnings.length > 0 ? { warnings } : {}) });
303
301
  })
@@ -347,11 +345,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
347
345
  (params) =>
348
346
  runTool(async () => {
349
347
  try {
350
- return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data as Row));
348
+ return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data));
351
349
  } catch (error) {
352
350
  // The refusal names what is missing; it does not say how a missing
353
351
  // document comes to exist, which is what the agent asks next.
354
- if (!(error instanceof TaigaApiError) || error.details?.reason !== 'documents_incomplete') throw error;
352
+ if (!(error instanceof TaigaApiError) || error.details?.['reason'] !== 'documents_incomplete') throw error;
355
353
  return toolError(
356
354
  error.code ?? 'CONFLICT',
357
355
  `${withStop(error.apiMessage ?? 'Required documents are not published yet')} ${MISSING_DOCUMENTS_HOW}`,
@@ -372,9 +370,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
372
370
  annotations: OPERATE,
373
371
  },
374
372
  (params) =>
375
- runTool(async () =>
376
- toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data as Row))
377
- )
373
+ runTool(async () => toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data)))
378
374
  );
379
375
 
380
376
  if (!client.hasCapability('products:read')) return;
@@ -396,8 +392,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
396
392
  },
397
393
  (params) =>
398
394
  runTool(async () => {
399
- const product = (await client.getProduct(params.productId)).data as Row;
400
- if (!isImportedProduct(product as ProductSettingsView)) {
395
+ const product = (await client.getProduct(params.productId)).data;
396
+ if (!isImportedProduct(product)) {
401
397
  return toolError(
402
398
  'CONFLICT',
403
399
  'This product was started from scratch, so it has no import: the choice is made when a product is created.'
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pickFields } from './summarize.js';
3
+
4
+ describe('pickFields', () => {
5
+ const rows: { id: string; name?: string; content?: string }[] = [
6
+ { id: 'a', name: 'A', content: 'the prose a list leaves out' },
7
+ { id: 'b' },
8
+ ];
9
+
10
+ it('keeps the named fields and leaves out one an item does not carry', () => {
11
+ expect(pickFields(rows, ['id', 'name'])).toEqual([{ id: 'a', name: 'A' }, { id: 'b' }]);
12
+ });
13
+
14
+ it('refuses a field the row type does not have', () => {
15
+ // The *_FIELDS lists are checked against their rows, so a misspelled field
16
+ // fails to compile instead of silently picking nothing (tsc covers tests).
17
+ // @ts-expect-error -- 'nmae' is not a field of the row.
18
+ expect(pickFields([{ id: 'a', name: 'A' }], ['id', 'nmae'])).toEqual([{ id: 'a' }]);
19
+ });
20
+ });
@@ -3,12 +3,13 @@
3
3
  * fields a model needs to pick an item) and get tools return everything;
4
4
  * this is how a list drops the prose and the provenance trees.
5
5
  */
6
- export function pickFields<T extends Record<string, unknown>>(
7
- items: T[],
8
- fields: readonly string[]
9
- ): Record<string, unknown>[] {
6
+ export function pickFields<T extends object, K extends keyof T>(
7
+ items: readonly T[],
8
+ fields: readonly K[]
9
+ ): Partial<Pick<T, K>>[] {
10
10
  return items.map((item) => {
11
- const picked: Record<string, unknown> = {};
11
+ // Partial: a field the item does not carry is left out, not set to undefined.
12
+ const picked: Partial<Pick<T, K>> = {};
12
13
  for (const field of fields) {
13
14
  if (field in item) picked[field] = item[field];
14
15
  }
@@ -26,7 +27,7 @@ export function page<T>(data: T[], nextCursor: string | null | undefined): { dat
26
27
  * carry, so "done with two unmet" shows in a list without reading the item.
27
28
  * Null while it has none (an unplanned initiative).
28
29
  */
29
- export function criteriaCounts(item: Record<string, unknown>): { met: number; total: number } | null {
30
+ export function criteriaCounts(item: { acceptanceCriteria?: unknown }): { met: number; total: number } | null {
30
31
  const criteria = item.acceptanceCriteria;
31
32
  if (!Array.isArray(criteria) || criteria.length === 0) return null;
32
33
  const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
@@ -38,7 +39,10 @@ export function criteriaCounts(item: Record<string, unknown>): { met: number; to
38
39
  * does not carry `acceptanceCriteria` at all gets no count rather than a
39
40
  * null that would read as "has none".
40
41
  */
41
- export function itemRows(items: Record<string, unknown>[], fields: readonly string[]): Record<string, unknown>[] {
42
+ export function itemRows<T extends { acceptanceCriteria?: unknown }, K extends keyof T>(
43
+ items: readonly T[],
44
+ fields: readonly K[]
45
+ ): (Partial<Pick<T, K>> & { criteria?: { met: number; total: number } | null })[] {
42
46
  return pickFields(items, fields).map((row, index) =>
43
47
  'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
44
48
  );
@@ -53,18 +57,18 @@ const SEARCH_PAGE_LIMIT = 10;
53
57
  * told the person's word for a thing ("the Sandbox factory") otherwise pages
54
58
  * the whole list to find its ID. `more` is true when it stopped before the end.
55
59
  */
56
- export async function searchPages(
57
- fetchPage: (cursor: string | undefined) => Promise<{ data: unknown[]; nextCursor: string | null }>,
60
+ export async function searchPages<T>(
61
+ fetchPage: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>,
58
62
  query: string,
59
- text: (row: Record<string, unknown>) => unknown[],
63
+ text: (row: T) => unknown[],
60
64
  limit: number
61
- ): Promise<{ rows: Record<string, unknown>[]; more: boolean }> {
65
+ ): Promise<{ rows: T[]; more: boolean }> {
62
66
  const needle = query.toLowerCase();
63
- const rows: Record<string, unknown>[] = [];
67
+ const rows: T[] = [];
64
68
  let cursor: string | undefined;
65
69
  for (let index = 0; index < SEARCH_PAGE_LIMIT; index += 1) {
66
70
  const result = await fetchPage(cursor);
67
- const page = result.data as Record<string, unknown>[];
71
+ const page = result.data;
68
72
  for (const [at, row] of page.entries()) {
69
73
  if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
70
74
  rows.push(row);
package/src/tools/wait.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { BUILD_RUN_TERMINAL_STATUSES, resolveAutoBuild } from '@taiga/types';
3
+ import type { BacklogItem, BacklogItemLifecycle, BuildRunWithContext } from '@taiga/types';
3
4
  import { z } from 'zod';
4
5
  import { idInput } from './inputs.js';
5
- import { TaigaApiError, type TaigaClient } from '../client.js';
6
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
6
7
  import { READ_ONLY } from './annotations.js';
7
8
  import { isNetworkFailure, runTool, toolResult } from './errors.js';
8
9
  import { SETTLED_JOB_STATUSES, jobReport } from './jobs.js';
9
10
  import { criteriaCounts, pickFields } from './summarize.js';
11
+ import { situationOf } from './backlogs.js';
12
+ import { readableRun } from './builds.js';
10
13
 
11
14
  /**
12
15
  * Waiting without a timer. A model in an editor has no clock: to learn that
@@ -43,7 +46,11 @@ const timeoutSeconds = z
43
46
  'many stop a call at 60 seconds.'
44
47
  );
45
48
 
46
- type Row = Record<string, unknown>;
49
+ type Item = WithoutTenant<BacklogItem>;
50
+ type Run = WithoutTenant<BuildRunWithContext>;
51
+
52
+ /** The pull-request fields an initiative's latest run and a build run both carry. */
53
+ type PullRequestFields = Pick<Run, 'prUrl' | 'prMerged' | 'prState'>;
47
54
 
48
55
  /** Why a wait ended: the condition held, it can no longer hold on its own, or time ran out. */
49
56
  type Verdict = { reached: true } | { reached: false; stoppedBecause: string } | null;
@@ -91,17 +98,17 @@ function isTransient(error: unknown): boolean {
91
98
  * it, which also keeps a client that resets its timeout on progress from
92
99
  * cutting the wait short.
93
100
  */
94
- async function waitFor(
95
- read: (signal: AbortSignal) => Promise<Row>,
96
- judge: (now: Row, first: Row) => Verdict,
101
+ async function waitFor<T>(
102
+ read: (signal: AbortSignal) => Promise<T>,
103
+ judge: (now: T, first: T) => Verdict,
97
104
  timeoutMs: number,
98
105
  options: WaitOptions,
99
106
  progress: Progress
100
107
  ) {
101
108
  const started = Date.now();
102
109
  const deadline = started + timeoutMs;
103
- let first: Row | null = null;
104
- let now: Row | null = null;
110
+ let first: T | null = null;
111
+ let now: T | null = null;
105
112
  let verdict: Verdict = null;
106
113
  let failures = 0;
107
114
  for (let attempt = 0; !progress.signal.aborted; attempt += 1) {
@@ -156,7 +163,7 @@ const SETTLED_RUN: ReadonlySet<string> = new Set(BUILD_RUN_TERMINAL_STATUSES);
156
163
  * is open; the mirrored state and the merged latch say which. An unknown
157
164
  * state (null, just opened) counts as open.
158
165
  */
159
- function prIsOpen(run: Row): boolean {
166
+ function prIsOpen(run: PullRequestFields): boolean {
160
167
  return typeof run.prUrl === 'string' && run.prUrl !== '' && run.prMerged !== true && run.prState !== 'closed';
161
168
  }
162
169
 
@@ -167,11 +174,13 @@ function prIsOpen(run: Row): boolean {
167
174
  const ITEM_CONDITIONS = ['changed', 'planned', 'your_turn', 'pr_open', 'run_settled', 'done'] as const;
168
175
  type ItemCondition = (typeof ITEM_CONDITIONS)[number];
169
176
 
170
- const latestBuildOf = (item: Row) => ((item.lifecycle as Row | undefined)?.latestBuild ?? null) as Row | null;
171
- const lifecycleOf = (item: Row) => (item.lifecycle ?? {}) as Row;
177
+ // The API sends a lifecycle on every backlog item (backlogItemSchema); a
178
+ // response without one reads as no lifecycle rather than failing the wait.
179
+ const latestBuildOf = (item: Item) => item.lifecycle?.latestBuild ?? null;
180
+ const lifecycleOf = (item: Item): Partial<BacklogItemLifecycle> => item.lifecycle ?? {};
172
181
 
173
182
  /** What "changed" compares: where the item is, whose turn it is and how its latest run stands. */
174
- function itemSignature(item: Row): string {
183
+ function itemSignature(item: Item): string {
175
184
  const lifecycle = lifecycleOf(item);
176
185
  const run = latestBuildOf(item);
177
186
  return JSON.stringify([
@@ -209,7 +218,7 @@ interface GateDefault {
209
218
  * the gate cannot be resolved (no gate of its own, and the product's default
210
219
  * unreadable) the answer is unknown, and the wait says so rather than guess.
211
220
  */
212
- function personTurn(item: Row, gate: GateDefault): 'yes' | 'no' | 'unknown' {
221
+ function personTurn(item: Item, gate: GateDefault): 'yes' | 'no' | 'unknown' {
213
222
  const lifecycle = lifecycleOf(item);
214
223
  if (lifecycle.turn !== 'you') return 'no';
215
224
  if (lifecycle.cta !== 'start_build') return 'yes';
@@ -219,7 +228,7 @@ function personTurn(item: Row, gate: GateDefault): 'yes' | 'no' | 'unknown' {
219
228
  return resolveAutoBuild({ initiative: own, product: product ?? true }) ? 'no' : 'yes';
220
229
  }
221
230
 
222
- function itemHolds(until: ItemCondition, item: Row, first: Row, gate: GateDefault): boolean {
231
+ function itemHolds(until: ItemCondition, item: Item, first: Item, gate: GateDefault): boolean {
223
232
  const lifecycle = lifecycleOf(item);
224
233
  const run = latestBuildOf(item);
225
234
  switch (until) {
@@ -239,7 +248,7 @@ function itemHolds(until: ItemCondition, item: Row, first: Row, gate: GateDefaul
239
248
  }
240
249
 
241
250
  function judgeItem(until: ItemCondition, gate: GateDefault) {
242
- return (item: Row, first: Row): Verdict => {
251
+ return (item: Item, first: Item): Verdict => {
243
252
  if (itemHolds(until, item, first, gate)) return { reached: true };
244
253
  // "changed" is how a model waits for a person to act on a flagged or
245
254
  // finished initiative, so none of the stops below apply to it.
@@ -276,9 +285,11 @@ function judgeItem(until: ItemCondition, gate: GateDefault) {
276
285
  }
277
286
 
278
287
  /** The initiative as a wait reports it: where it is, whose turn, its latest run and its criteria. */
279
- function itemReport(item: Row) {
288
+ function itemReport(item: Item) {
280
289
  const run = latestBuildOf(item);
290
+ const situation = situationOf(item);
281
291
  return {
292
+ ...(situation ? { situation } : {}),
282
293
  ...pickFields(
283
294
  [item],
284
295
  ['id', 'title', 'state', 'planningStatus', 'needsHumanCause', 'needsHumanDetail', 'autoBuild']
@@ -313,14 +324,16 @@ const RUN_REPORT_FIELDS = [
313
324
  'prState',
314
325
  'prDraft',
315
326
  'prMerged',
327
+ 'prNumber',
328
+ 'baseBranch',
316
329
  'actions',
317
330
  ] as const;
318
331
 
319
- const runSignature = (run: Row) =>
332
+ const runSignature = (run: Run) =>
320
333
  JSON.stringify([run.status, run.completedSteps, run.prUrl, run.prState, run.prMerged]);
321
334
 
322
335
  function judgeBuild(until: BuildCondition) {
323
- return (run: Row, first: Row): Verdict => {
336
+ return (run: Run, first: Run): Verdict => {
324
337
  const holds =
325
338
  until === 'changed'
326
339
  ? runSignature(run) !== runSignature(first)
@@ -341,7 +354,7 @@ function judgeBuild(until: BuildCondition) {
341
354
  export function registerWaitTools(server: McpServer, client: TaigaClient, options: WaitOptions) {
342
355
  if (client.hasCapability('backlog:read')) {
343
356
  server.registerTool(
344
- 'taiga_wait_for_backlog_item',
357
+ 'taiga_wait_for_initiative',
345
358
  {
346
359
  title: 'Wait for an initiative',
347
360
  description:
@@ -357,7 +370,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
357
370
  'settled answers run_settled at once; item is null when not even the first read answered in time. ' +
358
371
  'Planning takes minutes and a build up to an hour.',
359
372
  inputSchema: z.object({
360
- itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
373
+ initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
361
374
  until: z.enum(ITEM_CONDITIONS).describe('What to wait for'),
362
375
  timeoutSeconds,
363
376
  }),
@@ -367,14 +380,14 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
367
380
  runTool(async () => {
368
381
  const gate: GateDefault = { product: undefined };
369
382
  const read = async (signal: AbortSignal) => {
370
- const item = (await client.getBacklogItem(params.itemId, signal)).data as Row;
383
+ const item = (await client.getBacklogItem(params.initiativeId, signal)).data;
371
384
  // The product's default, once, when this initiative has no gate of its own to answer with.
372
385
  if (gate.product === undefined && typeof item.autoBuild !== 'boolean') {
373
386
  gate.product = client.hasCapability('products:read')
374
387
  ? await client
375
388
  .getProduct(String(item.productId), signal)
376
389
  .then((p) => {
377
- const value = (p.data as { autoBuild?: unknown }).autoBuild;
390
+ const value = p.data.autoBuild;
378
391
  return typeof value === 'boolean' ? value : true;
379
392
  })
380
393
  // Not allowed, or gone: unknown for good. Anything transient
@@ -406,7 +419,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
406
419
 
407
420
  if (client.hasCapability('products:read')) {
408
421
  server.registerTool(
409
- 'taiga_wait_for_build',
422
+ 'taiga_wait_for_run',
410
423
  {
411
424
  title: 'Wait for a run',
412
425
  description:
@@ -418,7 +431,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
418
431
  'call again to keep waiting. Conditions are states: a run already settled answers settled at once; run ' +
419
432
  'is null when not even the first read answered in time.',
420
433
  inputSchema: z.object({
421
- buildId: idInput('build run', 'taiga_list_builds gives it'),
434
+ runId: idInput('run', 'taiga_list_runs gives it'),
422
435
  until: z.enum(BUILD_CONDITIONS).describe('What to wait for'),
423
436
  timeoutSeconds,
424
437
  }),
@@ -427,7 +440,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
427
440
  (params, ctx) =>
428
441
  runTool(async () => {
429
442
  const result = await waitFor(
430
- async (signal) => (await client.getBuildRun(params.buildId, signal)).data as Row,
443
+ async (signal) => (await client.getBuildRun(params.runId, signal)).data,
431
444
  judgeBuild(params.until),
432
445
  (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
433
446
  options,
@@ -441,7 +454,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
441
454
  return toolResult({
442
455
  until: params.until,
443
456
  ...outcome,
444
- run: now ? pickFields([now], RUN_REPORT_FIELDS)[0] : null,
457
+ run: now ? readableRun(pickFields([now], RUN_REPORT_FIELDS)[0]!) : null,
445
458
  });
446
459
  })
447
460
  );
@@ -466,7 +479,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
466
479
  (params, ctx) =>
467
480
  runTool(async () => {
468
481
  const result = await waitFor(
469
- async (signal) => (await client.getAgentJob(params.jobId, signal)).data as Row,
482
+ async (signal) => (await client.getAgentJob(params.jobId, signal)).data,
470
483
  (job) => (SETTLED_JOB_STATUSES.has(String(job.status)) ? { reached: true } : null),
471
484
  (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
472
485
  options,