toga-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +119 -0
- package/.claude-plugin/marketplace.json +87 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CLAUDE.md +161 -0
- package/README.md +72 -0
- package/agents/framework-pattern-checker.md +67 -0
- package/agents/harness-optimizer.md +102 -0
- package/agents/knowledge-writer.md +62 -0
- package/agents/php-build-resolver.md +51 -0
- package/agents/php-reviewer.md +51 -0
- package/agents/planner.md +88 -0
- package/agents/session-capture.md +101 -0
- package/agents/sql-reviewer.md +67 -0
- package/contexts/dev.md +43 -0
- package/contexts/research.md +49 -0
- package/contexts/review.md +37 -0
- package/knowledge/1.0/apps/library/INDEX.md +5 -0
- package/knowledge/1.0/apps/library/architecture.md +105 -0
- package/knowledge/1.0/apps/worker/INDEX.md +5 -0
- package/knowledge/1.0/apps/worker/architecture.md +223 -0
- package/knowledge/1.0/standards/backend-php.md +450 -0
- package/knowledge/2.0/apps/_underscore/INDEX.md +6 -0
- package/knowledge/2.0/apps/_underscore/architecture.md +183 -0
- package/knowledge/2.0/apps/_underscore/features/recursive-item-fulfillments.md +111 -0
- package/knowledge/2.0/apps/api2/INDEX.md +5 -0
- package/knowledge/2.0/apps/api2/architecture.md +162 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +6 -0
- package/knowledge/2.0/apps/worker2/architecture.md +127 -0
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +135 -0
- package/knowledge/2.0/standards/backend-php.md +710 -0
- package/knowledge/CONVENTIONS.md +117 -0
- package/knowledge/INDEX.md +19 -0
- package/knowledge/clients/.gitkeep +0 -0
- package/knowledge/registry.json +7 -0
- package/knowledge.js +384 -0
- package/mcp-configs/README.md +72 -0
- package/mcp-configs/mcp-servers.json +23 -0
- package/package.json +50 -0
- package/rules/README.md +53 -0
- package/rules/common/coding-style.md +123 -0
- package/rules/common/git-workflow.md +72 -0
- package/rules/common/security.md +118 -0
- package/rules/common/testing.md +74 -0
- package/rules/php/app-framework.md +104 -0
- package/rules/php/underscore-framework.md +111 -0
- package/scripts/harness.js +605 -0
- package/scripts/hooks/evaluate-session.js +55 -0
- package/scripts/hooks/post-edit-validate.js +102 -0
- package/scripts/hooks/session-end.js +13 -0
- package/scripts/hooks/session-start.js +57 -0
- package/scripts/install.js +611 -0
- package/scripts/pre-commit +46 -0
- package/skills/capture/SKILL.md +294 -0
- package/skills/code-review/SKILL.md +140 -0
- package/skills/create-elastic-beanstalk/SKILL.md +217 -0
- package/skills/harness-audit/SKILL.md +152 -0
- package/skills/kickoff/SKILL.md +151 -0
- package/skills/php-patterns/SKILL.md +296 -0
- package/skills/session-resume/SKILL.md +156 -0
- package/skills/session-save/SKILL.md +158 -0
- package/skills/sync-team-skills/SKILL.md +87 -0
- package/sync-skills.js +71 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Recursive Item Fulfillments (upstream mirroring)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-08
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Client/ItemFulfillment.php
|
|
13
|
+
- _underscore/Model/Client/ItemFulfillmentItem.php
|
|
14
|
+
- _underscore/Model/Client/ItemFulfillmentItemUnit.php
|
|
15
|
+
- _underscore/Model/Client/ItemFulfillmentPackage.php
|
|
16
|
+
- _underscore/Model/Compass/AdvanceShippingNotice.php
|
|
17
|
+
- dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql
|
|
18
|
+
- dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql
|
|
19
|
+
related:
|
|
20
|
+
- ../architecture.md
|
|
21
|
+
- ../../api2/architecture.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Summary
|
|
25
|
+
In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes
|
|
26
|
+
another SO downstream, and so on. When goods ship, an ItemFulfillment (IF) is created against
|
|
27
|
+
the downstream SO. This feature **mirrors that fulfillment up the chain onto every upstream
|
|
28
|
+
(customer-facing) SO, recursively, to the top** — so the customer-facing order reflects
|
|
29
|
+
shipments that physically happened further down the chain. Triggered whenever any IF,
|
|
30
|
+
IF-item, IF-item-unit, or IF-package is **POSTed or PUT**.
|
|
31
|
+
|
|
32
|
+
## Key files / entry points
|
|
33
|
+
The whole engine is in `_Model_Client_ItemFulfillment`; the three child models are thin
|
|
34
|
+
triggers. Each registers `postPost`/`postPut` interceptors that funnel into one driver:
|
|
35
|
+
|
|
36
|
+
| File | Hooks | Role |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `Model/Client/ItemFulfillment.php` | `postPost`, `postPut` | engine: `ensureUpstreamItemFulfillment`, `reconcileUpstreamLevel`, `reconcileUpstreamUnits`, `reconcileUpstreamPackages`, `buildUpstreamHeaderPayload`, `buildUpstreamHeaderUpdatePayload` |
|
|
39
|
+
| `Model/Client/ItemFulfillmentItem.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
|
|
40
|
+
| `Model/Client/ItemFulfillmentItemUnit.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
|
|
41
|
+
| `Model/Client/ItemFulfillmentPackage.php` | `postPost`, `postPut` | resolve owning IF uuid → driver |
|
|
42
|
+
|
|
43
|
+
Triggers live on every child (not just the IF) so the sync is robust to all paths: the ASN
|
|
44
|
+
flow (POSTs the IF then each child individually), nested IF POSTs, and direct child edits.
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
**Driver — `ensureUpstreamItemFulfillment($api, $ifUuid)`:** a static `$isReconciling`
|
|
48
|
+
re-entrancy guard makes nested re-entries (from our own `internalApiRequest` writes, which
|
|
49
|
+
fire those records' interceptors) no-op. A `while` loop walks UP one level at a time via
|
|
50
|
+
`reconcileUpstreamLevel`, with a `$visited` set to prevent cycles.
|
|
51
|
+
|
|
52
|
+
**One level — `reconcileUpstreamLevel($api, $downIfUuid): ?string`:**
|
|
53
|
+
1. Load downstream IF; no `salesOrderId` (transfer order) → return null (out of scope).
|
|
54
|
+
2. Resolve upstream SO via the **header walk** (bridge tables); none → null (top of chain).
|
|
55
|
+
3. Build desired upstream items: map each downstream IFI's SOI up via the **item walk**,
|
|
56
|
+
GROUP BY upstream SOI, sum fulfilled qty, then **scale into upstream order units** for
|
|
57
|
+
bundle decomposition (see Gotchas).
|
|
58
|
+
4. Resolve-or-create the upstream IF (**eager** — header-only POST still builds the chain):
|
|
59
|
+
load existing via `upstreamItemFulfillmentId` and PUT changed header fields, or POST a
|
|
60
|
+
new IF with mirrored header + upstream SO then PUT the downstream IF's link.
|
|
61
|
+
5. Reconcile upstream IFIs keyed by `salesOrderItemId`: update qty, create missing, link
|
|
62
|
+
`upstreamItemFulfillmentItem`, delete stale (units first, then item).
|
|
63
|
+
6. Reconcile units per IFI matched on `unitId`; reconcile packages on the IF matched on
|
|
64
|
+
`trackingNumberId`: add missing, update changed tracking, delete leftovers.
|
|
65
|
+
7. Return upstream IF uuid → loop continues upward.
|
|
66
|
+
|
|
67
|
+
All internal calls pass `['depth' => -1]` and null-check responses; hard failures throw,
|
|
68
|
+
rolling back the whole outer transaction (consistency over silent desync).
|
|
69
|
+
|
|
70
|
+
## Data model
|
|
71
|
+
Bridge tables (client DB, in the blank-client template → all clients):
|
|
72
|
+
`PurchaseOrders_SalesOrders`, `SalesOrders_PurchaseOrders`,
|
|
73
|
+
`PurchaseOrderItems_SalesOrderItems`, `SalesOrderItems_PurchaseOrderItems`.
|
|
74
|
+
|
|
75
|
+
Upstream links added this feature (`RecordFields`, `isIdentifier=1`):
|
|
76
|
+
`ItemFulfillments.upstreamItemFulfillmentId`, `ItemFulfillmentItems.upstreamItemFulfillmentItemId`.
|
|
77
|
+
Units match on shared `unitId`; packages match on shared `trackingNumberId` (no upstream-id
|
|
78
|
+
columns — the same `Unit` / `TrackingNumber` record is shared up the chain).
|
|
79
|
+
|
|
80
|
+
Mirrored header fields (only when populated, never cleared upstream): scalars
|
|
81
|
+
`dateItemFulfillment`, `dateDeliveryEta`, `dtSubmitted`; FK references `itemFulfillmentStage`,
|
|
82
|
+
`shipToAddress`, `location`, `fulfilledByUser`. `number` is left unset → auto `F#####`.
|
|
83
|
+
|
|
84
|
+
## Client variations
|
|
85
|
+
Engine is shared `_Model_Client_*` logic. Compass uses **empty pass-through subclasses**
|
|
86
|
+
(`_Model_Compass_ItemFulfillment`, `…ItemFulfillmentItemUnit`) so base logic applies via
|
|
87
|
+
inheritance. Verified against prod `Client_Compass` chains (≥3 levels deep).
|
|
88
|
+
|
|
89
|
+
## Gotchas / known issues
|
|
90
|
+
- **Interceptors are DB-driven.** `postPost`/`postPut` PHP does nothing without matching
|
|
91
|
+
`ApiPayloadInterceptors` rows (recordId 28/29/30/41). **Deploying to any env requires
|
|
92
|
+
running BOTH Core SQL files** (`…Creation.sql` for POST/POST + RecordFields, and
|
|
93
|
+
`…Put.sql` for POST/PUT) against that env's Core DB — the PHP won't fire on PUT otherwise.
|
|
94
|
+
- **`depth: -1` always** on orchestration calls. `depth => 0` means *unlimited* FK
|
|
95
|
+
traversal → OOM. Never use 0.
|
|
96
|
+
- **Bundle scaling:** `upstreamQty = totalDownstreamFulfilled × upstreamOrderedQty /
|
|
97
|
+
totalDownstreamOrderedQty`, **capped at the raw sum** so the factor can only reduce
|
|
98
|
+
(roll-up), never inflate. A qty-1 bundle of 6 fully-shipped components → upstream qty 1.
|
|
99
|
+
- **DELETE propagation is NOT implemented.** The engine sets `$outData = null` on DELETE so
|
|
100
|
+
post-delete hooks never fire; a standalone downstream DELETE doesn't immediately remove its
|
|
101
|
+
upstream mirror. A later reconciling PUT cleans up stale records via set comparison. Full
|
|
102
|
+
delete handling would need a `preDelete` mechanism + `PRE/DELETE` interceptor rows.
|
|
103
|
+
- **Out of scope:** NetSuite sync of upstream IFs; transfer-order fulfillments
|
|
104
|
+
(`transferOrderId`) are skipped.
|
|
105
|
+
- **Performance:** every child write re-runs a full upstream walk (read-heavy, but each
|
|
106
|
+
upstream record is written at most once — idempotent). Fine for normal fulfillment sizes.
|
|
107
|
+
|
|
108
|
+
## Related docs
|
|
109
|
+
- `_underscore` architecture (interceptors, `internalApiRequest`, `_Model` layer).
|
|
110
|
+
- `api2` architecture (V2 metadata engine that fires these interceptors; the
|
|
111
|
+
commit-logs / rollback-data-on-failure transaction invariant the throws rely on).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# api2 (API) — 2.0 knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API (api2 / TOGa API v2) Architecture
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-08
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Controller/Index.php
|
|
13
|
+
- api2/Component/Api/V2/V2.php
|
|
14
|
+
- api2/Component/Api/Cxml/Cxml.php
|
|
15
|
+
- api2/Component/Api/V2/Response/Response.php
|
|
16
|
+
- api2/Config/
|
|
17
|
+
related: []
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
`api2` is the backend powering the public **TOGa 2.0 API**. It serves two protocols from
|
|
23
|
+
one codebase, chosen by hostname in `Controller/Index.php`:
|
|
24
|
+
|
|
25
|
+
1. **RESTful JSON API** (`/v2/...`) — the primary, fully metadata- and ACL-driven CRUD API
|
|
26
|
+
third parties integrate with. Engine: `Component/Api/V2/V2.php`.
|
|
27
|
+
2. **cXML gateway** — accepts supplier cXML (PunchOut, OrderRequest, ShipNotice) and
|
|
28
|
+
translates them into internal JSON API calls. Engine: `Component/Api/Cxml/Cxml.php`.
|
|
29
|
+
|
|
30
|
+
Runtime: PHP 8.2+, AWS Elastic Beanstalk (Apache/httpd → PHP-FPM). The `_underscore`
|
|
31
|
+
framework is **pulled at deploy, not vendored**. Composer deps: `sentry/sentry`,
|
|
32
|
+
`aws/aws-sdk-php`, `robrichards/xmlseclibs`, `phpmailer/phpmailer`.
|
|
33
|
+
|
|
34
|
+
## Dependencies
|
|
35
|
+
|
|
36
|
+
- **`_underscore` (framework core)** — this API is essentially a metadata-driven layer over
|
|
37
|
+
`_Model`. Read its architecture first.
|
|
38
|
+
- **`apiproxy`** (reverse proxy, not yet captured) — all external traffic hits the proxy
|
|
39
|
+
first; it forwards here and retries across regions on 5xx so integrators never see a
|
|
40
|
+
regional failure. _When apiproxy knowledge is captured, add it to this repo's `dependsOn`._
|
|
41
|
+
|
|
42
|
+
## Request lifecycle
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
third party → apiproxy (region failover) → EB ALB → Apache → index.php
|
|
46
|
+
→ _underscore boot → _Controller_Index::api()
|
|
47
|
+
├─ host api/api1-3/api-writer/apiproxy*/api-production* → JSON → _Component_Api_V2::execute()
|
|
48
|
+
└─ host cxml/cxml1-3/cxml-writer → cXML → _Component_Api_Cxml::execute()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`.htaccess` rewrites all to `index.php` (just `require '_underscore.php'`).
|
|
52
|
+
`_underscore::DEFAULT_CONTROLLER_METHOD = ['_Controller_Index','api']`, so every request
|
|
53
|
+
lands in `api()`.
|
|
54
|
+
|
|
55
|
+
## Front controller — `Controller/Index.php`
|
|
56
|
+
|
|
57
|
+
`api()` is the protocol router and the **transaction/logging boundary**:
|
|
58
|
+
- **CORS:** permissive `Access-Control-Allow-*`; returns 200 for `OPTIONS` preflight.
|
|
59
|
+
- **`/health`:** 200 + empty object (EB/LB probe — keep it working).
|
|
60
|
+
- **Sentry:** initialized per request; EC2 instance metadata attached.
|
|
61
|
+
- **Core DB:** registers `Core` (`DB_CORE`), preferring a region-local read host
|
|
62
|
+
(`CORE_READHOST`). **Core Logs DB** (`DatabaseHost` id 11) registered region-aware as `DB_LOGS`.
|
|
63
|
+
- **Host dispatch:** lowercase `HTTP_HOST`, strip trailing domain, switch on the remainder.
|
|
64
|
+
- **Transaction/logging invariant (JSON path):** wraps the call in transactions; on
|
|
65
|
+
**success** commit logs then data; on **failure** commit logs but **roll back data**; on
|
|
66
|
+
uncaught `Throwable` report to Sentry, write a log row/CloudWatch line, commit logs, roll
|
|
67
|
+
back data. **Preserve this invariant.**
|
|
68
|
+
|
|
69
|
+
## JSON engine — `Component/Api/V2/V2.php`
|
|
70
|
+
|
|
71
|
+
One ~2,000-line `execute()` then `processRoutePairs()`:
|
|
72
|
+
|
|
73
|
+
1. **Parse/normalize** — reads "now" from Core (`SELECT NOW()`) for token expiry; parses
|
|
74
|
+
method/headers/route (strips `/v2`)/body; builds Core-metadata lookups (`Parameters`,
|
|
75
|
+
`Records`, `InherentRecordChildren`, `RecordFields`).
|
|
76
|
+
2. **Options** (`fields`, `where`, `join`/`ojoin`, `sort`, `group`, `distinct`, `page`,
|
|
77
|
+
`recordsPerPage` default 25 / max 10000, `depth` default 3, `calcDepth` default 1) —
|
|
78
|
+
passed as query params or one base64(JSON) `_` blob.
|
|
79
|
+
3. **Authentication** — `transactionId` is required and **globally unique** (checked vs
|
|
80
|
+
Core + client logs; reuse → `EV-5`). Auth routes under `/v2/auth/*`: `api`, `refresh`,
|
|
81
|
+
`oauth`, `oauth/refresh`, `login` (email/pw; AD via LDAP bind for AD-configured clients),
|
|
82
|
+
`public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s,
|
|
83
|
+
refresh 30d); signing secret **rotated** with current+previous accepted across the
|
|
84
|
+
boundary. The JWT `id` claim carries the full identity used for ACL.
|
|
85
|
+
4. **Response envelope** — `{transactionId, timestamp, authority, audience, isSuccess,
|
|
86
|
+
status, error, messages[], meta{}, data{}}`; `isSuccess` = 2xx status.
|
|
87
|
+
5. **Transaction logging** — every request logged (to client/core Logs DB, or as a JSONL
|
|
88
|
+
line shipped by CloudWatch when `[api] log_filepath` is set).
|
|
89
|
+
|
|
90
|
+
## CRUD engine — `processRoutePairs()`
|
|
91
|
+
|
|
92
|
+
**Metadata-driven** — routes/models/fields/permissions come from Core/Client DB tables, not
|
|
93
|
+
hardcoded controllers. The route after `/v2` is chunked into `[routeName, uuid?]` pairs and
|
|
94
|
+
processed recursively (nested resources). Metadata tables (Core): `Records` (route ↔ model,
|
|
95
|
+
`aclDatabase`, `childPolicy`), `RecordFields`, `InherentRecordChildren`,
|
|
96
|
+
`AclRecordPermissions` (per-role CRUD), `AclFieldPermissions` (per-role field read/write).
|
|
97
|
+
|
|
98
|
+
Per route pair: resolve Record → **authorize record-level** (`EZ-1` if no grant) → **client
|
|
99
|
+
model override** (`_Model_Client_X` → `_Model_<Slug>_X` when present) → **pre interceptors**
|
|
100
|
+
→ **authorize field-level** → field analysis (columns, FK children, SQL fields, aggregates,
|
|
101
|
+
inherent children) → **execute** the `_Model` op → **serialize** via `getFullModelData()`
|
|
102
|
+
(nested, bounded by `depth`) → **post interceptors**. Scripted APIs and
|
|
103
|
+
`internalApiRequest()` (in-process calls reusing auth/ACL) are supported. Message codes:
|
|
104
|
+
`EN-*` auth, `EZ-*` authorization, `EV-*` validation, `EO-*` operation, `W*`/`D*`.
|
|
105
|
+
|
|
106
|
+
## cXML gateway — `Component/Api/Cxml/Cxml.php`
|
|
107
|
+
|
|
108
|
+
Translation layer for EDI/punchout suppliers. `execute()`: normalize XML (DOMDocument,
|
|
109
|
+
strip DOCTYPE, disable external entities) → authenticate via `Sender > Credential` against
|
|
110
|
+
Core `ClientApiIdentities` → dispatch: `OrderRequest` builds a Sales Order and POSTs to
|
|
111
|
+
`/sales-orders`; `ShipNoticeRequest` → `/advance-shipping-notices`; `PunchOutSetupRequest`
|
|
112
|
+
creates a Vision (1.0) quote, Store (1.0) cart, or TOGa Commerce session. **cXML rides on
|
|
113
|
+
top of the V2 JSON engine** (it authenticates and POSTs via `_ApiRequest`), inheriting ACL,
|
|
114
|
+
interceptors, and logging. PunchOut paths write directly to legacy **Vision/Store** (1.0)
|
|
115
|
+
DBs (`DB_VISION_1` / `DB_STORE_1`).
|
|
116
|
+
|
|
117
|
+
## Multi-database architecture
|
|
118
|
+
|
|
119
|
+
Multi-tenant; each client has isolated DBs resolved at request time per environment/region.
|
|
120
|
+
Aliases: `DB_CORE`, `DB_CLIENT`, `DB_CLIENT_LOGS`, `DB_CLIENT_ARCHIVE`, `DB_LOGS`, `DB_TEAM`,
|
|
121
|
+
`DB_STORE_1`/`DB_VISION_1` (legacy). `_Database::registerClientDatabases(clientId,
|
|
122
|
+
environment)` joins `Clients`/`Databases`/`DatabaseHosts`/`Environments`, preferring the
|
|
123
|
+
instance's own region; reads→readers, writes→writers, per-connection transactions.
|
|
124
|
+
|
|
125
|
+
## Deployment (EB)
|
|
126
|
+
|
|
127
|
+
Same shape as other 2.0 tiers. `.ebextensions/php_include_underscore.config` adds
|
|
128
|
+
`_underscore` to `include_path`; `.platform/hooks/prebuild/git.sh` clones
|
|
129
|
+
`TOGATechnology/_underscore` (branch `_<ENVIRONMENT>`) at build (framework not vendored);
|
|
130
|
+
`015_install_composer.sh` runs `composer install`; instance-id/region written from IMDS for
|
|
131
|
+
region-aware DB host selection; CloudWatch agent ships the JSONL log file;
|
|
132
|
+
`long_gateway_timeout.conf` sets `ProxyTimeout 1800`/`Timeout 1800` — **the fix for the
|
|
133
|
+
"exactly 60 second" 504** (EB Apache→PHP-FPM defaults to 60s); `enforce_https.conf`.
|
|
134
|
+
|
|
135
|
+
## CI — commit message policy
|
|
136
|
+
|
|
137
|
+
`.github/workflows/true-devteam-requirements.yml` enforces **`TRUE-{ticket}: {Subject}`** —
|
|
138
|
+
subject ≤80 chars, capitalized, no trailing period, **imperative mood** (rejects `fixed`/
|
|
139
|
+
`added`/`updated`), >1 word. Merge commits exempt.
|
|
140
|
+
|
|
141
|
+
## ⚠️ Security note (committed secrets)
|
|
142
|
+
|
|
143
|
+
`Config/production.ini` (and dev configs) contain **plaintext production credentials** —
|
|
144
|
+
Core/Client/Logs DB passwords, **AWS access key id + secret**, and third-party API keys
|
|
145
|
+
(FedEx, UPS, PayPal, NetSuite, Cal.com, AIG, OptimumDesk, VAPI). `.ebextensions/git.json`
|
|
146
|
+
carries a **GitHub PAT**. These should be rotated and moved to SSM Parameter Store / EB env
|
|
147
|
+
properties. **Flag this if you touch config or deploy.**
|
|
148
|
+
|
|
149
|
+
## When making changes here
|
|
150
|
+
|
|
151
|
+
- **Adding/altering an endpoint is usually a data change, not code** — define `Records` +
|
|
152
|
+
`RecordFields` rows and grant `AclRecordPermissions`/`AclFieldPermissions`. The model
|
|
153
|
+
class lives in `_underscore` (`Model/Client/...`).
|
|
154
|
+
- **Client-specific behavior** belongs in `_Model_<ClientSlug>_X` overrides and
|
|
155
|
+
`Client_ApiPayloadInterceptor` pre/post hooks — not in `V2.php`.
|
|
156
|
+
- **Don't break `/health`** or weaken `enforce_https.conf`.
|
|
157
|
+
- **504s:** verify `long_gateway_timeout.conf` on both this tier and the proxy
|
|
158
|
+
(LB 3600s → proxy Apache 1800s → proxy cURL 900s → api Apache 1800s).
|
|
159
|
+
- **`transactionId` uniqueness is load-bearing** — the idempotency/audit key; don't bypass
|
|
160
|
+
the duplicate check.
|
|
161
|
+
- Preserve the commit-logs / rollback-data-on-failure invariant when editing the controller
|
|
162
|
+
or `execute()`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# worker2 (Worker) — 2.0 knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [Worker (worker2) Architecture](architecture.md) | Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that processes background jobs. | worker2/Controller/Index.php, worker2/Worker/, worker2/LambdaFunctions/, _underscore/Worker.php |
|
|
6
|
+
| [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Worker (worker2) Architecture
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-08
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Controller/Index.php
|
|
13
|
+
- worker2/Worker/
|
|
14
|
+
- worker2/LambdaFunctions/
|
|
15
|
+
- _underscore/Worker.php
|
|
16
|
+
related:
|
|
17
|
+
- ./features/creating-worker-actions.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that
|
|
23
|
+
processes background jobs. It's a `_underscore` 2.0 app (`index.php` is just
|
|
24
|
+
`require '_underscore.php'`). Two job sources:
|
|
25
|
+
|
|
26
|
+
1. **Webhooks** — external HTTP calls (ClickUp, GitHub, …) hit an AWS Lambda that inserts
|
|
27
|
+
a job into MySQL and sends its ID to SQS.
|
|
28
|
+
2. **Cron jobs** — a Lambda scheduler fires timed jobs into MySQL and SQS every minute.
|
|
29
|
+
|
|
30
|
+
**MySQL is the source of truth; SQS is delivery only.** All job state lives in
|
|
31
|
+
`Core.WorkerJobs`. Every worker invocation reads from that table and writes its result back.
|
|
32
|
+
|
|
33
|
+
## AWS infrastructure
|
|
34
|
+
|
|
35
|
+
| Component | Notes |
|
|
36
|
+
|---|---|
|
|
37
|
+
| SQS `WorkerProductionQueue` | EB Worker tier listens here (`aws_worker_queue_url`). Visibility timeout 3600s; **no DLQ**. |
|
|
38
|
+
| SQS `WebhookFallback` | S3 references for webhook payloads that failed the MySQL insert. |
|
|
39
|
+
| S3 `agilant-worker-fallback` | Failed webhook payloads (`webhook-fallback/{uuid}.json`, 7-day lifecycle). |
|
|
40
|
+
| API Gateway `WorkerWebhookIngestion` | `webhook.togahub.com` → Lambda. Lambda Function URLs are blocked by an AWS Org SCP, so API Gateway is the only public entry point. |
|
|
41
|
+
|
|
42
|
+
## MySQL tables
|
|
43
|
+
|
|
44
|
+
**`Core.WorkerJobs`** — unified execution record. Key columns: `uuid`,
|
|
45
|
+
`jobType ENUM('ACTION','CRON')`, `cronJobId`, `dtQueued` (NULL = not yet queued),
|
|
46
|
+
`dtStarted` (committed immediately at check-in), `dtCompleted`, `executionTime`,
|
|
47
|
+
`instanceId`, `isSuccess` (NULL pending/running, 0 fail, 1 success), `action`,
|
|
48
|
+
`parameters` JSON, `failureReason`.
|
|
49
|
+
|
|
50
|
+
Derived status: `pending` (`dtQueued IS NULL AND isSuccess IS NULL`) → `queued` → `running`
|
|
51
|
+
(`dtStarted` set, `dtCompleted` NULL) → `succeeded`/`failed`.
|
|
52
|
+
|
|
53
|
+
**`Core.CronJobs`** — `isActive`, `name`, `schedule` (cron expr, **Central tz**),
|
|
54
|
+
`maxExecutionTime` (watchdog seconds, default 300), `action`, `parameters` JSON.
|
|
55
|
+
|
|
56
|
+
## The four Lambdas (`worker2/LambdaFunctions/`, Python)
|
|
57
|
+
|
|
58
|
+
`boto3` is in the runtime; `pymysql`, `croniter`, `pytz` are vendored into the zip.
|
|
59
|
+
|
|
60
|
+
1. **WebhookIngestion** — API Gateway → derives action from URL path (kebab → Pascal,
|
|
61
|
+
`/Webhook` appended; `/clickup/task-update` → `Clickup/TaskUpdate/Webhook`), logs to
|
|
62
|
+
`Logs.Webhook` (non-fatal), INSERTs `WorkerJobs` (`ACTION`, `dtQueued=NOW()`), sends
|
|
63
|
+
`{workerJobId}` to the queue. On SQS failure resets `dtQueued=NULL`; on MySQL failure
|
|
64
|
+
writes payload to S3 + `WebhookFallback`. **Always returns HTTP 200.**
|
|
65
|
+
2. **PayloadRecovery** — every minute, recovers S3 fallback payloads into `WorkerJobs`.
|
|
66
|
+
3. **JobScheduler** — every minute. **Part A:** picks up `dtQueued IS NULL AND isSuccess
|
|
67
|
+
IS NULL` (≤100), sets `dtQueued=NOW()`, sends to queue. **Part B (watchdog):** marks
|
|
68
|
+
jobs `isSuccess=0` when `NOW() > dtStarted + timeout` (CronJobs.maxExecutionTime, else
|
|
69
|
+
env `MAX_EXECUTION_TIME_WORKER_ACTION` default 300).
|
|
70
|
+
4. **CronScheduler** — every minute, evaluates active `CronJobs.schedule` against the
|
|
71
|
+
current Central-tz minute (`croniter`); on match INSERTs a `CRON` job and queues it.
|
|
72
|
+
|
|
73
|
+
## EB worker tier — `Controller/Index.php`
|
|
74
|
+
|
|
75
|
+
The `worker()` method handles all SQS deliveries. **Critical rule: always return HTTP
|
|
76
|
+
200** — even on failure. Returning 500 keeps the message in-flight until the visibility
|
|
77
|
+
timeout; job outcomes are tracked in `WorkerJobs.isSuccess`, so SQS retry is unwanted.
|
|
78
|
+
|
|
79
|
+
New path (`{workerJobId}` present):
|
|
80
|
+
1. Fetch the job; guard: if missing or `isSuccess IS NOT NULL` → 200, skip.
|
|
81
|
+
2. **Check-in:** `UPDATE dtStarted=NOW(), instanceId` — immediately committed via
|
|
82
|
+
`_Database::transactionCommit(DB_CORE)` so no rollback can undo it.
|
|
83
|
+
3. Register DB_LOGS / DB_CLIENT_LOGS (try/catch → failure marks job failed, returns 200).
|
|
84
|
+
4. Resolve class+method from action (`Clickup/Webhook` → `_Worker_Clickup::Webhook`).
|
|
85
|
+
5. Call `initialize()` if present, then the action with spread parameters.
|
|
86
|
+
6. **Check-out:** `UPDATE dtCompleted, executionTime, isSuccess, failureReason`. Return 200.
|
|
87
|
+
|
|
88
|
+
Legacy path (`action` present, no `workerJobId`): used by `_Worker::runTask()` debug mode
|
|
89
|
+
only; runs directly with no `WorkerJobs` tracking.
|
|
90
|
+
|
|
91
|
+
## Action → class/method routing
|
|
92
|
+
|
|
93
|
+
Split the action on `/`: last segment = method; everything before = class segments joined
|
|
94
|
+
with `_` and prefixed `_Worker_`. `Team/Github/Merge` → `_Worker_Team_Github::Merge`,
|
|
95
|
+
file `Worker/Team/Github.php`.
|
|
96
|
+
|
|
97
|
+
## Programmatic invocation & manual retry
|
|
98
|
+
|
|
99
|
+
- **`_Worker::runTask(action, parameters)`** (`_underscore/Worker.php`) — INSERT
|
|
100
|
+
`WorkerJobs` → **`transactionCommit(DB_CORE)` before SQS** → send `{workerJobId}`.
|
|
101
|
+
Debug mode posts synchronously with no tracking.
|
|
102
|
+
- **`_Worker_Infrastructure_Worker::Retry($workerJobId)`** — resets the job fields,
|
|
103
|
+
commits before SQS, sends `{workerJobId}` immediately. Sets `dtQueued=NOW()` (not NULL)
|
|
104
|
+
so JobScheduler doesn't double-pick it.
|
|
105
|
+
|
|
106
|
+
## Cleanup cron
|
|
107
|
+
|
|
108
|
+
`_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days
|
|
109
|
+
(≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
|
|
110
|
+
|
|
111
|
+
## Critical transaction pattern
|
|
112
|
+
|
|
113
|
+
**Any INSERT/UPDATE that must be visible to another connection before an SQS message is
|
|
114
|
+
delivered must be immediately committed.** Three sites: `Retry()`, `_Worker::runTask()`,
|
|
115
|
+
and the `Controller/Index.php` check-in. Reason: the caller's MySQL transaction is still
|
|
116
|
+
open when SQS delivers; the worker reads in a separate connection and, if uncommitted, its
|
|
117
|
+
SELECT returns nothing and the guard fires ("already processed or not found — skipping").
|
|
118
|
+
|
|
119
|
+
## Key design decisions
|
|
120
|
+
|
|
121
|
+
MySQL-first (SQS on success) prevents lost jobs · always HTTP 200 (WorkerJobs tracks
|
|
122
|
+
failures, SQS retry unwanted) · 3600s visibility timeout for hour-long jobs · no DLQ
|
|
123
|
+
(developer-controlled `Retry()` preferred) · all fallback payloads to S3 (no size split) ·
|
|
124
|
+
SQS failure → reset `dtQueued=NULL` (rescheduled next minute) · `maxExecutionTime` on
|
|
125
|
+
CronJobs, env var for ACTION jobs · `/Webhook` appended enforces method-name convention ·
|
|
126
|
+
API Gateway in front of Lambda (SCP blocks Function URLs) · `workerJobId` lookup keeps SQS
|
|
127
|
+
messages tiny · commit before SQS avoids the delivery-before-commit race.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Creating Worker Actions
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-08
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/
|
|
13
|
+
- worker2/Controller/Index.php
|
|
14
|
+
- _underscore/Worker.php
|
|
15
|
+
related:
|
|
16
|
+
- ../architecture.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
How to add a new callable Worker action — a PHP class whose `public static` methods are
|
|
22
|
+
invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). Actions follow a
|
|
23
|
+
path convention that maps directly to class/method names; in most cases **no routing or
|
|
24
|
+
Lambda code changes are needed** — you just create the PHP file.
|
|
25
|
+
|
|
26
|
+
> This replaces the former `/worker2-action` skill. To build one, run `kickoff` for
|
|
27
|
+
> `worker2` and then describe the action you want; the conventions below are the contract.
|
|
28
|
+
|
|
29
|
+
## What you need to decide
|
|
30
|
+
|
|
31
|
+
1. **Action path** — `Category/Sub/File/MethodName`. Determines the class and file:
|
|
32
|
+
- File: `Worker/Category/Sub/File.php`
|
|
33
|
+
- Class: `_Worker_Category_Sub_File` (replace `/` with `_`, prefix `_Worker_`, **abstract**)
|
|
34
|
+
- Method: `MethodName`
|
|
35
|
+
- Example: `Client/Acme/ImportData` → `Worker/Client/Acme.php`, `_Worker_Client_Acme::ImportData`.
|
|
36
|
+
2. **Parameters** — name + PHP type (`string`/`int`/`bool`/`array`/`float`); optional ones
|
|
37
|
+
get PHP defaults. The framework spreads the `parameters` JSON as positional arguments.
|
|
38
|
+
3. **Return** — return `string`; `json_encode()` structured data.
|
|
39
|
+
4. **`initialize()`** — optional `public static function initialize()` the framework calls
|
|
40
|
+
automatically before any method in the class (register DB connections / shared setup).
|
|
41
|
+
|
|
42
|
+
## Class & method rules
|
|
43
|
+
|
|
44
|
+
- File `Worker/{Path}.php`; class `abstract class _Worker_{Path_With_Underscores}`.
|
|
45
|
+
- All action methods are `public static` and typed.
|
|
46
|
+
- Typed parameters map 1:1 to the JSON `parameters` keys; optional params use defaults.
|
|
47
|
+
|
|
48
|
+
### Non-webhook action template
|
|
49
|
+
|
|
50
|
+
```php
|
|
51
|
+
<?php
|
|
52
|
+
|
|
53
|
+
abstract class _Worker_Category_MyAction {
|
|
54
|
+
|
|
55
|
+
public static function initialize() {
|
|
56
|
+
// Register DB connections / shared resources. Called before any method runs.
|
|
57
|
+
_Database::register(
|
|
58
|
+
database: 'SomeDatabase',
|
|
59
|
+
hostname: _Config::database('some_host'),
|
|
60
|
+
username: _Config::database(_Config::DATABASE_USERNAME),
|
|
61
|
+
password: _Config::database(_Config::DATABASE_PASSWORD),
|
|
62
|
+
alias: 'some_alias'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public static function DoSomething(string $param1, int $count = 0): string {
|
|
67
|
+
// perform work
|
|
68
|
+
return json_encode(['result' => 'done']);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Invoke programmatically:
|
|
74
|
+
`_Worker::runTask('Category/MyAction/DoSomething', ['param1' => 'value', 'count' => 5]);`
|
|
75
|
+
|
|
76
|
+
### Webhook action template
|
|
77
|
+
|
|
78
|
+
Webhook endpoints receive `$payload` (raw request body string) and `$headers`
|
|
79
|
+
(lowercase-keyed — API Gateway lowercases all header names). Both required.
|
|
80
|
+
|
|
81
|
+
```php
|
|
82
|
+
<?php
|
|
83
|
+
|
|
84
|
+
abstract class _Worker_MyService {
|
|
85
|
+
|
|
86
|
+
public static function Webhook(mixed $payload, object $headers): string {
|
|
87
|
+
$payload = json_decode($payload);
|
|
88
|
+
$event = $headers->{'x-my-service-event'} ?? null;
|
|
89
|
+
// handle the event
|
|
90
|
+
return 'ok';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Adding a new webhook endpoint
|
|
96
|
+
|
|
97
|
+
No Lambda/routing changes — just create the file:
|
|
98
|
+
1. Create `Worker/MyService.php` with `_Worker_MyService` and a `Webhook(mixed $payload,
|
|
99
|
+
object $headers): string` method.
|
|
100
|
+
2. Register the URL `webhook.togahub.com/my-service` with the external service.
|
|
101
|
+
3. Events arrive → Lambda converts `/my-service` → `MyService/Webhook` → job inserted →
|
|
102
|
+
worker calls `_Worker_MyService::Webhook($payload, $headers)`. (Multi-segment paths:
|
|
103
|
+
`/my-service/task-update` → `MyService/TaskUpdate/Webhook`.)
|
|
104
|
+
|
|
105
|
+
## Adding a new cron job
|
|
106
|
+
|
|
107
|
+
1. Create the PHP file + method (e.g. `Worker/Reports/Daily.php`, method `Generate()`).
|
|
108
|
+
2. Insert a `Core.CronJobs` row:
|
|
109
|
+
```sql
|
|
110
|
+
INSERT INTO Core.CronJobs (uuid, isActive, name, schedule, maxExecutionTime, action, parameters)
|
|
111
|
+
VALUES (UUID(), 1, 'Daily Report Generation', '0 6 * * *', 300, 'Reports/Daily/Generate', NULL);
|
|
112
|
+
```
|
|
113
|
+
`schedule` is evaluated in **Central time**; `maxExecutionTime` is the watchdog timeout.
|
|
114
|
+
3. CronScheduler fires it at the next matching minute — no code wiring needed.
|
|
115
|
+
|
|
116
|
+
## Test any action directly
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
curl -X POST https://worker.togahub.com/ \
|
|
120
|
+
-H "Content-Type: application/json" \
|
|
121
|
+
-d '{
|
|
122
|
+
"action": "Category/MyAction/DoSomething",
|
|
123
|
+
"parameters": { "param1": "example_value", "count": 5 }
|
|
124
|
+
}'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Use realistic placeholder values, not empty strings/nulls.
|
|
128
|
+
|
|
129
|
+
## Gotchas
|
|
130
|
+
|
|
131
|
+
- Class must be **`abstract`** and methods **`public static`** or routing fails.
|
|
132
|
+
- Webhook headers are **lowercased** by API Gateway (`X-GitHub-Event` → `x-github-event`).
|
|
133
|
+
- If the action needs a client DB, register it in `initialize()` — it runs before the method.
|
|
134
|
+
- See [architecture.md](../architecture.md) for the always-HTTP-200 rule and the
|
|
135
|
+
commit-before-SQS transaction pattern that the worker relies on.
|