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.
Files changed (62) hide show
  1. package/.claude/settings.json +119 -0
  2. package/.claude-plugin/marketplace.json +87 -0
  3. package/.claude-plugin/plugin.json +22 -0
  4. package/CLAUDE.md +161 -0
  5. package/README.md +72 -0
  6. package/agents/framework-pattern-checker.md +67 -0
  7. package/agents/harness-optimizer.md +102 -0
  8. package/agents/knowledge-writer.md +62 -0
  9. package/agents/php-build-resolver.md +51 -0
  10. package/agents/php-reviewer.md +51 -0
  11. package/agents/planner.md +88 -0
  12. package/agents/session-capture.md +101 -0
  13. package/agents/sql-reviewer.md +67 -0
  14. package/contexts/dev.md +43 -0
  15. package/contexts/research.md +49 -0
  16. package/contexts/review.md +37 -0
  17. package/knowledge/1.0/apps/library/INDEX.md +5 -0
  18. package/knowledge/1.0/apps/library/architecture.md +105 -0
  19. package/knowledge/1.0/apps/worker/INDEX.md +5 -0
  20. package/knowledge/1.0/apps/worker/architecture.md +223 -0
  21. package/knowledge/1.0/standards/backend-php.md +450 -0
  22. package/knowledge/2.0/apps/_underscore/INDEX.md +6 -0
  23. package/knowledge/2.0/apps/_underscore/architecture.md +183 -0
  24. package/knowledge/2.0/apps/_underscore/features/recursive-item-fulfillments.md +111 -0
  25. package/knowledge/2.0/apps/api2/INDEX.md +5 -0
  26. package/knowledge/2.0/apps/api2/architecture.md +162 -0
  27. package/knowledge/2.0/apps/worker2/INDEX.md +6 -0
  28. package/knowledge/2.0/apps/worker2/architecture.md +127 -0
  29. package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +135 -0
  30. package/knowledge/2.0/standards/backend-php.md +710 -0
  31. package/knowledge/CONVENTIONS.md +117 -0
  32. package/knowledge/INDEX.md +19 -0
  33. package/knowledge/clients/.gitkeep +0 -0
  34. package/knowledge/registry.json +7 -0
  35. package/knowledge.js +384 -0
  36. package/mcp-configs/README.md +72 -0
  37. package/mcp-configs/mcp-servers.json +23 -0
  38. package/package.json +50 -0
  39. package/rules/README.md +53 -0
  40. package/rules/common/coding-style.md +123 -0
  41. package/rules/common/git-workflow.md +72 -0
  42. package/rules/common/security.md +118 -0
  43. package/rules/common/testing.md +74 -0
  44. package/rules/php/app-framework.md +104 -0
  45. package/rules/php/underscore-framework.md +111 -0
  46. package/scripts/harness.js +605 -0
  47. package/scripts/hooks/evaluate-session.js +55 -0
  48. package/scripts/hooks/post-edit-validate.js +102 -0
  49. package/scripts/hooks/session-end.js +13 -0
  50. package/scripts/hooks/session-start.js +57 -0
  51. package/scripts/install.js +611 -0
  52. package/scripts/pre-commit +46 -0
  53. package/skills/capture/SKILL.md +294 -0
  54. package/skills/code-review/SKILL.md +140 -0
  55. package/skills/create-elastic-beanstalk/SKILL.md +217 -0
  56. package/skills/harness-audit/SKILL.md +152 -0
  57. package/skills/kickoff/SKILL.md +151 -0
  58. package/skills/php-patterns/SKILL.md +296 -0
  59. package/skills/session-resume/SKILL.md +156 -0
  60. package/skills/session-save/SKILL.md +158 -0
  61. package/skills/sync-team-skills/SKILL.md +87 -0
  62. 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.