toga-ai 1.0.308 → 1.0.310

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.
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-07-09
10
- owners: ["jcardinal", "dfranks"]
10
+ owners: ["jcardinal", "dfranks", "mhammontree"]
11
11
  files:
12
12
  - crons/tickets.php
13
13
  - crons/tickets_prod.php
@@ -40,6 +40,19 @@ TOGa Desk ingests support email into tickets through a cron-driven IMAP poller
40
40
  3. **Parse & route (`emailToTicket()`):** extract from/to/cc/subject/body (with encoding fixes);
41
41
  match an existing ticket via `In-Reply-To` + `Message-ID` headers and **plus-addressing**
42
42
  (`support+<ticketnum>@…`). Matched ⇒ `addReply()`; otherwise ⇒ `add()`.
43
+ - **Noise suppression (before any ticket/attachment is created):** both feeders drop known
44
+ automated no-noise mail via the shared helper
45
+ `Ticket::isSuppressedNoiseEmail(?string $from, ?string $subject): bool` (static, in
46
+ `class.ticket.php`). It returns true only when the sender matches `/^no-?reply@/` **AND**
47
+ the subject starts with `staples po ` — both required so a real person is never blocked, and
48
+ the sender check is **domain-agnostic** (matches `noreply@togatech.com` /
49
+ `noreply@goagilant.com` and survives future sender-domain flips; the Staples PO alert sender
50
+ flipped `goagilant.com → togatech.com` on 2026-06-02). Wired into BOTH intake feeders:
51
+ `crons/tickets.php` adds it as one more `&&`-clause in the existing IMAP intake guard
52
+ (alongside the delivery-status / `MAILER-DAEMON` / Compass auto-reply filters);
53
+ `crons/pipe.php` uses it as an early guard-and-`exit(0)` right after the `$from`/`$subject`
54
+ parse. Each feeder logs a "suppressed" line. (TRUE-79975 — ~815 such alerts since Jun 2024,
55
+ ~704 of them landing in the dept-1 "Support Desk" catch-all and being closed by hand.)
43
56
  4. **Side effects:** auto-create a user when the sender domain maps to an existing client; assign
44
57
  to a department via the department email lookup; save attachments locally then push to **S3**
45
58
  (`App_Cloud::copyFileToS3()`, bucket `asifiles`); optionally generate Talos/TogaIQ summaries.
@@ -49,7 +62,12 @@ TOGa Desk ingests support email into tickets through a cron-driven IMAP poller
49
62
  ## Data model
50
63
  Writes `tickets`, `tickets_replies`, attachment `files`; reads config from the `config` table
51
64
  (`tickets_defaultdepartment`, `tickets_defaultclient`, `auto_close_tickets`,
52
- `email_from_address`, `timezone`). Department routing keys on `tickets_departments.email`.
65
+ `email_from_address`, `timezone`). Department routing keys on a `tickets_departments.email`
66
+ `LIKE` match against the To/CC addresses; **anything unmatched falls through to config
67
+ `tickets_defaultdepartment` (= dept 1 "Support Desk" catch-all) and `tickets_defaultclient`** —
68
+ which is why unrouted/automated mail piles into "Support Desk". There is **no allowlist/blocklist
69
+ config framework**: noise filtering is ad-hoc inline guards in the crons (the hardcoded Compass
70
+ "Automatic reply:" filter and now `isSuppressedNoiseEmail`).
53
71
 
54
72
  ## Gotchas / known issues
55
73
  - **`$processName` guard:** `tickets.php` throws if executed directly instead of via an env
@@ -59,7 +77,13 @@ Writes `tickets`, `tickets_replies`, attachment `files`; reads config from the `
59
77
  - **Loop prevention** only excludes FROM==department-TO addresses; CC/reply-all across multiple
60
78
  department addresses can still loop.
61
79
  - **Auto-reply filters** are partly hardcoded (e.g. ignore `TechHub@compass-usa.com`
62
- "Automatic reply:" messages).
80
+ "Automatic reply:" messages), and now the `isSuppressedNoiseEmail` Staples-PO filter — there
81
+ is no config-driven allow/block list, so each new noise source is another inline guard.
82
+ - **`Ticket::extractEmail()` returned an undefined offset on a no-match** (bare `$matches[0]`).
83
+ With a sender-less/malformed email this codebase promotes the warning to a thrown error
84
+ (`E_ALL` + `App_Error::handleError`), so an intake cron could **halt**. Hardened to
85
+ `return $matches[0] ?? '';` (TRUE-79975) — important because `isSuppressedNoiseEmail` routes
86
+ nullable/malformed sender input through `extractEmail`, putting this latent bug on the hot path.
63
87
  - Attachments are deleted locally immediately after the S3 upload with **no retry** if S3 fails.
64
88
  - **`adminid` clobber on reply routing:** after `emailToTicket()` sets `$data['adminid']` from
65
89
  the FROM address (~line 1407), an asset lookup (~line 1437-1442) overwrites it with the
@@ -69,6 +93,10 @@ Writes `tickets`, `tickets_replies`, attachment `files`; reads config from the `
69
93
  classification. See the ticket-lifecycle doc.
70
94
 
71
95
  ## Change history
96
+ - 2026-07-09 — added `Ticket::isSuppressedNoiseEmail()` noise suppression (no-reply sender + "Staples PO"
97
+ subject) wired into both feeders before ticket creation; hardened `extractEmail()` no-match to
98
+ `''`; documented the `tickets_defaultdepartment` → "Support Desk" catch-all fall-through
99
+ (TRUE-79975) (mhammontree)
72
100
  - 2026-07-09 — documented the `emailToTicket()` adminid-overwrite reply misclassification
73
101
  (TRUE-80114 planning investigation) (dfranks)
74
102
  - 2026-06-15 — documented from a source read of `crons/tickets*.php` and `emailToTicket()` (jcardinal)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-07
9
+ updated: 2026-07-09
10
10
  owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - Core/
@@ -168,35 +168,62 @@ its own header.)
168
168
  5. Never edit or re-date an already-applied file — add a new dated file instead. Never put new
169
169
  work in a `HISTORIC` folder.
170
170
 
171
- ## Bulk data loads — one statement, not many
171
+ ## Bulk data loads — batch, and stage large sets in a temp table
172
172
 
173
- When a change inserts many rows (reference-data / code-table loads), write it as a **single
174
- bulk `INSERT`**, not one `INSERT` statement per row. The database is billed per query and the
175
- external executor runs each statement as its own round-trip, so N separate inserts cost N times
176
- the round-trips (and query cost) of one batched statement. This is a standing team preference.
173
+ When a change inserts many rows (reference-data / code-table loads), write it as **batched
174
+ statements**, not one `INSERT` per row. The database is billed per query and the external
175
+ executor runs each statement as its own round-trip, so N separate inserts cost N times the
176
+ round-trips (and query cost) of a batched load.
177
177
 
178
- - Use one `INSERT ... VALUES (...),(...),...` or `INSERT ... SELECT` over a derived
179
- (`UNION ALL`) row set.
180
- - To stay **idempotent** without a unique key to `INSERT IGNORE` against, anti-join the source
181
- set to the target and insert only the misses:
178
+ To stay **idempotent** without a unique key to `INSERT IGNORE` against, anti-join the source
179
+ set to the target and insert only the misses.
180
+
181
+ **Small sets (a few hundred rows):** a single `INSERT ... VALUES (...),(...),...`, or an
182
+ `INSERT ... SELECT` over a `UNION ALL` derived row set, is fine:
182
183
 
183
184
  ```sql
184
185
  INSERT INTO TargetTable (uuid, keyColumn, otherColumn)
185
- SELECT
186
- src.uuid,
187
- src.keyColumn,
188
- src.otherColumn
186
+ SELECT src.uuid, src.keyColumn, src.otherColumn
189
187
  FROM (
190
188
  SELECT '<uuid>' AS uuid, '<key>' AS keyColumn, '<value>' AS otherColumn
191
189
  UNION ALL SELECT ...
192
190
  ) AS src
193
191
  LEFT JOIN TargetTable existing ON existing.keyColumn = src.keyColumn
194
- WHERE
195
- existing.id IS NULL;
192
+ WHERE existing.id IS NULL;
193
+ ```
194
+
195
+ **⚠ Large sets — never build them as a long `UNION ALL` chain.** MySQL parses a `UNION ALL`
196
+ chain **recursively**, one parser-stack frame per link. Around ~2,000+ links overflow the
197
+ default 256 KB per-thread stack and the statement dies at **parse time** (before a single row
198
+ is processed) with:
199
+
200
+ ERROR 1436 (HY000): Thread stack overrun: <n> bytes used of a 262144 byte stack, and
201
+ 20000 bytes needed. Use 'mysqld --thread_stack=#' to specify a bigger stack.
202
+
203
+ This is a parser-recursion limit — **not** a row-volume, `max_allowed_packet`, or
204
+ server-misconfiguration issue, and **do not** raise `thread_stack` on the server for a one-off
205
+ load. Instead, stage the rows in a **session-scoped temp table via a flat multi-row `VALUES`
206
+ list** (which the parser reads as a flat list, no recursion), then run the anti-join once from
207
+ the staging table:
208
+
209
+ ```sql
210
+ CREATE TEMPORARY TABLE _stage (
211
+ uuid VARCHAR(36) NOT NULL, keyColumn VARCHAR(255) NOT NULL, otherColumn TEXT NULL);
212
+ INSERT INTO _stage (uuid, keyColumn, otherColumn)
213
+ VALUES ('<uuid>','<key>','<value>'), ('<uuid>','<key>','<value>'), ... ; -- flat, thousands of rows
214
+ INSERT INTO TargetTable (uuid, keyColumn, otherColumn)
215
+ SELECT src.uuid, src.keyColumn, src.otherColumn
216
+ FROM _stage AS src
217
+ LEFT JOIN TargetTable existing ON existing.keyColumn = src.keyColumn
218
+ WHERE existing.id IS NULL;
219
+ DROP TEMPORARY TABLE _stage;
196
220
  ```
197
221
 
198
- - Keep the single statement well under `max_allowed_packet` (64 MB default) — a few thousand
199
- rows is comfortably fine.
222
+ The `TEMPORARY` table is session-scoped (safe on production, auto-dropped on disconnect even if
223
+ the run aborts). A multi-row `VALUES` load of a few thousand rows is a few hundred KB — well
224
+ under the 64 MB `max_allowed_packet` default. First hit in
225
+ `Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql` (2,200 AIG SaleItem codes); see
226
+ `clients/aig/features/entitlement-intake.md` → *Uploading new codes*.
200
227
 
201
228
  ## Self-referencing DELETE — wrap the subquery in a derived table
202
229
 
@@ -88,17 +88,27 @@ When AIG sends a new "Active SaleItemID" spreadsheet (columns `SaleItemID`, `Des
88
88
  3. Generate **fully-random UUIDs** (e.g. `uuid4`), never the time-based MySQL `UUID()` —
89
89
  per the 2.0 standard. Bake literal UUIDs into the SQL since a migration can't call PHP's
90
90
  `_String::generateUuid()`.
91
- 4. Use a **single bulk `INSERT`** (see the dbchanges2 architecture rule), made idempotent with
92
- an anti-join because `Items` has **no unique key on `partNumber`** (so `INSERT IGNORE`
93
- can't help):
91
+ 4. Feed the rows through a **session-scoped temp table populated by a flat multi-row
92
+ `VALUES` list**, then anti-join once into `Items` — idempotent because `Items` has **no
93
+ unique key on `partNumber`** (so `INSERT IGNORE` can't help). Do **not** assemble the rows
94
+ as a long `UNION ALL` derived table: at AIG's ~2,200-code scale that overflows MySQL's
95
+ parser stack and fails at **parse time** with **ERROR 1436 (HY000) Thread stack overrun**
96
+ (see the gotcha below and the dbchanges2 architecture bulk-load rule). A flat `VALUES`
97
+ list does not recurse and stays well under `max_allowed_packet`:
94
98
  ```sql
99
+ CREATE TEMPORARY TABLE _aig_saleitem_stage (
100
+ uuid VARCHAR(36) NOT NULL, partNumber VARCHAR(255) NOT NULL, description TEXT NULL);
101
+ INSERT INTO _aig_saleitem_stage (uuid, partNumber, description)
102
+ VALUES ('<uuid>','<part>','<desc>'), ('<uuid>','<part>','<desc>'), ... ; -- flat, ~2,200 rows
95
103
  INSERT INTO Items (uuid, partNumber, description)
96
104
  SELECT src.uuid, src.partNumber, src.description
97
- FROM ( SELECT '<uuid>' AS uuid, '<part>' AS partNumber, '<desc>' AS description
98
- UNION ALL SELECT ... ) AS src
105
+ FROM _aig_saleitem_stage AS src
99
106
  LEFT JOIN Items existing ON existing.partNumber = src.partNumber
100
107
  WHERE existing.id IS NULL;
108
+ DROP TEMPORARY TABLE _aig_saleitem_stage;
101
109
  ```
110
+ The `TEMPORARY` table is session-scoped (safe on production, auto-dropped on disconnect
111
+ even if the run aborts). Do **not** raise `thread_stack` on the server for a one-off load.
102
112
 
103
113
  ## Client variations
104
114
 
@@ -121,6 +131,13 @@ this interceptor or use this dual-purpose Items pattern.
121
131
  codes are **not** in the SaleItemID spreadsheet and must be sourced separately if missing.
122
132
  - **No unique key on `Items.partNumber`** — use an anti-join for idempotent loads, not
123
133
  `INSERT IGNORE`.
134
+ - **A long `UNION ALL` derived table fails at ~2,200 rows with ERROR 1436 "Thread stack
135
+ overrun."** The AIG SaleItem load is exactly this scale. MySQL builds the `UNION ALL`
136
+ chain recursively, so ~2,200 links overflow the default 256 KB per-thread parser stack and
137
+ the statement dies at **parse time** (before any row is processed) — it is a parser-recursion
138
+ limit, **not** a row-volume, `max_allowed_packet`, or server-config problem. Stage the rows
139
+ in a temp table via a flat multi-row `VALUES` list and anti-join from there (see *Uploading
140
+ new codes* step 4). Do not raise `thread_stack` for a one-off load.
124
141
  - **An `entitlementFulfillmentType` EV-12 VALIDATION error is NOT a bad client payload —
125
142
  it's this interceptor's injected field failing to resolve.** AIG never sends that field;
126
143
  the interceptor injects it as `{name: "Repair"}`. The error means the V2 resolver could not
@@ -136,6 +153,11 @@ this interceptor or use this dual-purpose Items pattern.
136
153
 
137
154
  ## Change history
138
155
 
156
+ - 2026-07-09 — TRUE-79534 SaleItem load reworked: the 2,200-code `INSERT ... SELECT FROM
157
+ (<2,199 `UNION ALL` links>)` derived table failed at parse time with ERROR 1436 (Thread
158
+ stack overrun). Rebuilt as a `TEMPORARY` staging table filled by a flat multi-row `VALUES`
159
+ list + the same anti-join into `Items` (identical rows/UUIDs, 0 duplicate partNumbers).
160
+ (mhammontree)
139
161
  - 2026-07-09 — TRUE-79978: root-caused an `entitlementFulfillmentType` EV-12 on beta/QA. The
140
162
  field is injected (name-only) by `prePost`; the beta `Client_Aig.Apis_RecordFields` override
141
163
  row (apiId=2, recordFieldId=1202) had `overrideIsIdentifier=NULL` + `overrideChildPolicy=MATCH_CREATE`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.308",
3
+ "version": "1.0.310",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",