thinkwork-cli 0.5.4 → 0.6.1

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.
@@ -29,6 +29,10 @@ terraform {
29
29
  source = "hashicorp/null"
30
30
  version = "~> 3.0"
31
31
  }
32
+ cloudflare = {
33
+ source = "cloudflare/cloudflare"
34
+ version = "~> 4.0"
35
+ }
32
36
  }
33
37
 
34
38
  backend "s3" {
@@ -44,6 +48,10 @@ provider "aws" {
44
48
  region = var.region
45
49
  }
46
50
 
51
+ # Cloudflare provider reads its token from the CLOUDFLARE_API_TOKEN env var.
52
+ # Never commit the token to tfvars or source control.
53
+ provider "cloudflare" {}
54
+
47
55
  variable "stage" {
48
56
  description = "Deployment stage — must match the Terraform workspace name"
49
57
  type = string
@@ -110,6 +118,46 @@ variable "api_auth_secret" {
110
118
  default = ""
111
119
  }
112
120
 
121
+ variable "www_domain" {
122
+ description = "Public website apex domain (e.g. thinkwork.ai). Leave empty to skip the custom domain and DNS wiring."
123
+ type = string
124
+ default = ""
125
+ }
126
+
127
+ variable "cloudflare_zone_id" {
128
+ description = "Cloudflare zone ID for var.www_domain. Non-secret. Required when www_domain is set."
129
+ type = string
130
+ default = ""
131
+ }
132
+
133
+ variable "ses_inbound_domain" {
134
+ description = "Subdomain for agent email (e.g. agents.thinkwork.ai). Terraform creates a delegated Route53 hosted zone, SES domain identity + DKIM, MX record, and receipt rule. Leave empty to skip SES inbound resources."
135
+ type = string
136
+ default = ""
137
+ }
138
+
139
+ variable "lastmile_tasks_api_url" {
140
+ description = <<-EOT
141
+ OPTIONAL fallback base URL for the LastMile Tasks REST API.
142
+
143
+ Prefer setting the URL per-tenant via the admin Connectors → LastMile
144
+ page (stored in webhooks.config.baseUrl); that value takes precedence.
145
+ This variable only fires when the per-tenant config is empty, and is
146
+ mainly useful for single-tenant dev stacks and bootstrap scenarios.
147
+
148
+ Leave blank (default) unless you specifically need the env-var
149
+ fallback. Example: https://api-dev.lastmile-tei.com.
150
+ EOT
151
+ type = string
152
+ default = ""
153
+ }
154
+
155
+ locals {
156
+ www_dns_enabled = var.www_domain != "" && var.cloudflare_zone_id != ""
157
+ docs_domain = var.www_domain != "" ? "docs.${var.www_domain}" : ""
158
+ admin_domain = var.www_domain != "" ? "admin.${var.www_domain}" : ""
159
+ }
160
+
113
161
  module "thinkwork" {
114
162
  source = "../../modules/thinkwork"
115
163
 
@@ -126,9 +174,80 @@ module "thinkwork" {
126
174
  lambda_zips_dir = var.lambda_zips_dir
127
175
  api_auth_secret = var.api_auth_secret
128
176
 
177
+ # Public website custom domain (optional — wired only when www_domain is set)
178
+ www_domain = var.www_domain
179
+ www_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
180
+
181
+ # Docs site custom domain (derived from www_domain — docs.<apex>). The
182
+ # same ACM cert covers apex + www + docs + admin so every distribution
183
+ # shares it.
184
+ docs_domain = local.www_dns_enabled ? local.docs_domain : ""
185
+ docs_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
186
+
187
+ # Admin SPA custom domain (derived from www_domain — admin.<apex>).
188
+ admin_domain = local.www_dns_enabled ? local.admin_domain : ""
189
+ admin_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
190
+
191
+ # SES inbound email subdomain (delegated Route53 subzone).
192
+ ses_inbound_domain = var.ses_inbound_domain
193
+
194
+ # LastMile Tasks REST API base URL — feature-flags the outbound task
195
+ # sync. Empty string keeps mobile-created tasks in sync_status='local'.
196
+ lastmile_tasks_api_url = var.lastmile_tasks_api_url
197
+
129
198
  # Greenfield: create everything (all defaults are true)
130
199
  }
131
200
 
201
+ ################################################################################
202
+ # Public Website DNS (Cloudflare zone, ACM cert, www→apex redirect, docs)
203
+ ################################################################################
204
+
205
+ module "www_dns" {
206
+ count = local.www_dns_enabled ? 1 : 0
207
+ source = "../../modules/app/www-dns"
208
+
209
+ stage = var.stage
210
+ domain = var.www_domain
211
+ cloudflare_zone_id = var.cloudflare_zone_id
212
+ cloudfront_domain_name = module.thinkwork.www_distribution_domain
213
+
214
+ # Docs: include_docs is a plain bool (no output reference) so the
215
+ # ACM cert SAN list doesn't depend on the docs distribution output,
216
+ # which itself depends on the cert. docs_cloudfront_domain_name is
217
+ # read only after the cert is created, for the CNAME record.
218
+ include_docs = true
219
+ docs_cloudfront_domain_name = module.thinkwork.docs_distribution_domain
220
+
221
+ # Admin: same cycle-avoidance pattern.
222
+ include_admin = true
223
+ admin_cloudfront_domain_name = module.thinkwork.admin_distribution_domain
224
+ }
225
+
226
+ ################################################################################
227
+ # SES Inbound DNS Delegation
228
+ #
229
+ # The ses-email module creates a Route53 hosted zone for var.ses_inbound_domain
230
+ # (e.g. agents.thinkwork.ai). For the subzone to resolve, the parent zone
231
+ # (thinkwork.ai at Cloudflare) must carry NS records pointing at the 4 AWS name
232
+ # servers. New Route53 zones always return exactly 4 name servers, so we can
233
+ # hardcode count = 4 without hitting "count value is not known" at plan time.
234
+ #
235
+ # Without this delegation, terraform creates the Route53 zone and the MX/DKIM
236
+ # records inside it, but the outside world asks Cloudflare for agents.thinkwork.ai
237
+ # and gets NXDOMAIN because Cloudflare doesn't know to delegate.
238
+ ################################################################################
239
+
240
+ resource "cloudflare_record" "agents_ns" {
241
+ count = var.ses_inbound_domain != "" && var.cloudflare_zone_id != "" ? 4 : 0
242
+
243
+ zone_id = var.cloudflare_zone_id
244
+ name = var.ses_inbound_domain
245
+ content = module.thinkwork.ses_inbound_name_servers[count.index]
246
+ type = "NS"
247
+ ttl = 300
248
+ proxied = false
249
+ }
250
+
132
251
  ################################################################################
133
252
  # Outputs
134
253
  ################################################################################
@@ -216,7 +335,7 @@ output "agentcore_memory_id" {
216
335
 
217
336
  output "admin_url" {
218
337
  description = "Admin app URL"
219
- value = "https://${module.thinkwork.admin_distribution_domain}"
338
+ value = local.www_dns_enabled ? "https://${local.admin_domain}" : "https://${module.thinkwork.admin_distribution_domain}"
220
339
  }
221
340
 
222
341
  output "admin_distribution_id" {
@@ -231,7 +350,7 @@ output "admin_bucket_name" {
231
350
 
232
351
  output "docs_url" {
233
352
  description = "Docs site URL"
234
- value = "https://${module.thinkwork.docs_distribution_domain}"
353
+ value = local.www_dns_enabled ? "https://${local.docs_domain}" : "https://${module.thinkwork.docs_distribution_domain}"
235
354
  }
236
355
 
237
356
  output "docs_distribution_id" {
@@ -243,3 +362,38 @@ output "docs_bucket_name" {
243
362
  description = "S3 bucket for docs site assets"
244
363
  value = module.thinkwork.docs_bucket_name
245
364
  }
365
+
366
+ output "www_url" {
367
+ description = "Public website URL"
368
+ value = var.www_domain != "" ? "https://${var.www_domain}" : "https://${module.thinkwork.www_distribution_domain}"
369
+ }
370
+
371
+ output "www_distribution_id" {
372
+ description = "CloudFront distribution ID for the public website (for cache invalidation)"
373
+ value = module.thinkwork.www_distribution_id
374
+ }
375
+
376
+ output "www_distribution_domain" {
377
+ description = "CloudFront distribution domain for the public website"
378
+ value = module.thinkwork.www_distribution_domain
379
+ }
380
+
381
+ output "www_bucket_name" {
382
+ description = "S3 bucket for public website assets"
383
+ value = module.thinkwork.www_bucket_name
384
+ }
385
+
386
+ output "ses_inbound_zone_id" {
387
+ description = "Route53 hosted zone ID for the email subdomain (null when ses_inbound_domain is not set)"
388
+ value = module.thinkwork.ses_inbound_zone_id
389
+ }
390
+
391
+ output "ses_inbound_name_servers" {
392
+ description = "Name servers for the delegated email subzone. Paste these as NS records at the registrar that hosts the parent domain (Google Domains for thinkwork.ai) before SES can verify."
393
+ value = module.thinkwork.ses_inbound_name_servers
394
+ }
395
+
396
+ output "ses_inbound_mx_target" {
397
+ description = "MX target host for the email subdomain. Already written into the subzone by Terraform — informational."
398
+ value = module.thinkwork.ses_inbound_mx_target
399
+ }
@@ -29,3 +29,13 @@ db_password = "CHANGE_ME_strong_password_here"
29
29
 
30
30
  # Pre-signup Lambda (optional — leave empty if not using custom pre-signup logic)
31
31
  # pre_signup_lambda_zip = "./lambdas/pre-signup.zip"
32
+
33
+ # Public website (apps/www) custom domain.
34
+ # Leave www_domain empty to skip the custom domain and serve on the raw
35
+ # CloudFront URL. When set, you must also provide cloudflare_zone_id below —
36
+ # the www-dns module creates the ACM cert, apex CNAME, and www→apex redirect.
37
+ #
38
+ # The Cloudflare API token is read from the CLOUDFLARE_API_TOKEN environment
39
+ # variable. NEVER put the token in this file.
40
+ # www_domain = "thinkwork.ai"
41
+ # cloudflare_zone_id = "your-cloudflare-zone-id"
@@ -38,6 +38,26 @@ variable "agentcore_memory_id" {
38
38
  default = ""
39
39
  }
40
40
 
41
+ variable "memory_engine" {
42
+ description = "Active long-term memory engine ('hindsight' or 'agentcore'). Surfaced to the runtime as MEMORY_ENGINE for telemetry/debugging only; engine selection itself happens in the API's normalized memory layer when memory-retain is invoked."
43
+ type = string
44
+ default = "hindsight"
45
+ validation {
46
+ condition = contains(["hindsight", "agentcore"], var.memory_engine)
47
+ error_message = "memory_engine must be 'hindsight' or 'agentcore'."
48
+ }
49
+ }
50
+
51
+ # memory-retain Lambda name + ARN are constructed locally rather than
52
+ # taken as inputs to avoid a circular dependency: the lambda-api module
53
+ # already consumes this module's outputs (agentcore_function_name/arn).
54
+ # The Lambda name follows the deterministic pattern from
55
+ # lambda-api/handlers.tf: thinkwork-${stage}-api-${handler_name}.
56
+ locals {
57
+ memory_retain_fn_name = "thinkwork-${var.stage}-api-memory-retain"
58
+ memory_retain_fn_arn = "arn:aws:lambda:${var.region}:${var.account_id}:function:${local.memory_retain_fn_name}"
59
+ }
60
+
41
61
  ################################################################################
42
62
  # ECR Repository
43
63
  ################################################################################
@@ -157,6 +177,17 @@ resource "aws_iam_role_policy" "agentcore" {
157
177
  Action = ["ssm:GetParameter", "ssm:PutParameter"]
158
178
  Resource = "arn:aws:ssm:${var.region}:${var.account_id}:parameter/thinkwork/${var.stage}/agentcore/*"
159
179
  },
180
+ {
181
+ # Async-invoke the memory-retain Lambda after every chat turn so
182
+ # the API's normalized memory layer can run the active engine's
183
+ # retainTurn() path (Hindsight POST /memories or AgentCore
184
+ # CreateEvent). InvocationType=Event from the Python client; this
185
+ # Lambda is the only target.
186
+ Sid = "MemoryRetainInvoke"
187
+ Effect = "Allow"
188
+ Action = ["lambda:InvokeFunction"]
189
+ Resource = local.memory_retain_fn_arn
190
+ },
160
191
  ]
161
192
  })
162
193
  }
@@ -192,6 +223,8 @@ resource "aws_lambda_function" "agentcore" {
192
223
  AWS_LWA_PORT = "8080"
193
224
  AGENTCORE_MEMORY_ID = var.agentcore_memory_id
194
225
  AGENTCORE_FILES_BUCKET = var.bucket_name
226
+ MEMORY_ENGINE = var.memory_engine
227
+ MEMORY_RETAIN_FN_NAME = local.memory_retain_fn_name
195
228
  }
196
229
  }
197
230
 
@@ -55,6 +55,27 @@ resource "aws_iam_role" "job_scheduler" {
55
55
  })
56
56
  }
57
57
 
58
+ # When job-schedule-manager creates a schedule, it sets this role as the
59
+ # target RoleArn. EventBridge Scheduler assumes it and invokes the
60
+ # job-trigger Lambda. ARN is constructed by naming convention so this
61
+ # module doesn't have to import the lambda-api module's outputs.
62
+ resource "aws_iam_role_policy" "job_scheduler_invoke" {
63
+ name = "invoke-job-trigger"
64
+ role = aws_iam_role.job_scheduler.id
65
+
66
+ policy = jsonencode({
67
+ Version = "2012-10-17"
68
+ Statement = [{
69
+ Effect = "Allow"
70
+ Action = ["lambda:InvokeFunction"]
71
+ Resource = [
72
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger",
73
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger:*",
74
+ ]
75
+ }]
76
+ })
77
+ }
78
+
58
79
  ################################################################################
59
80
  # Outputs
60
81
  ################################################################################
@@ -28,17 +28,40 @@ locals {
28
28
  GRAPHQL_API_KEY = var.appsync_api_key
29
29
  API_AUTH_SECRET = var.api_auth_secret
30
30
  THINKWORK_API_SECRET = var.api_auth_secret
31
- MANIFLOW_API_SECRET = var.api_auth_secret
31
+ EMAIL_HMAC_SECRET = var.api_auth_secret
32
+ THINKWORK_API_URL = "https://${aws_apigatewayv2_api.main.id}.execute-api.${var.region}.amazonaws.com"
32
33
  AGENTCORE_FUNCTION_NAME = var.agentcore_function_name
33
34
  WORKSPACE_BUCKET = var.bucket_name
34
35
  HINDSIGHT_ENDPOINT = var.hindsight_endpoint
35
36
  AGENTCORE_MEMORY_ID = var.agentcore_memory_id
36
- ADMIN_URL = var.admin_url
37
- DOCS_URL = var.docs_url
38
- APPSYNC_REALTIME_URL = var.appsync_realtime_url
39
- ECR_REPOSITORY_URL = var.ecr_repository_url
40
- AWS_ACCOUNT_ID = var.account_id
41
- NODE_OPTIONS = "--enable-source-maps"
37
+ MEMORY_ENGINE = var.memory_engine
38
+ # Skip the SSM indirection for cross-function ARN lookup. Terraform
39
+ # already knows this ARN at apply time and the Lambda role's SSM
40
+ # permission has been a recurring source of silent failures where
41
+ # getChatAgentInvokeFnArn falls back to null and sendMessage loses
42
+ # message_history on the wakeup-processor fallback path.
43
+ CHAT_AGENT_INVOKE_FN_ARN = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-chat-agent-invoke"
44
+ ADMIN_URL = var.admin_url
45
+ DOCS_URL = var.docs_url
46
+ APPSYNC_REALTIME_URL = var.appsync_realtime_url
47
+ ECR_REPOSITORY_URL = var.ecr_repository_url
48
+ AWS_ACCOUNT_ID = var.account_id
49
+ NODE_OPTIONS = "--enable-source-maps"
50
+ # LastMile Tasks REST API base URL — feature-flags the outbound sync
51
+ # path. When unset, syncExternalTaskOnCreate writes sync_status='local'
52
+ # and the workflow picker proxy returns 503. Set to the LMI develop /
53
+ # staging / prod base URL per stage to enable real cross-system sync.
54
+ LASTMILE_TASKS_API_URL = var.lastmile_tasks_api_url
55
+ }
56
+
57
+ # Per-handler env-var overrides. ARNs are constructed from the naming
58
+ # pattern (same trick as lambda_api_cross_invoke in main.tf) so we don't
59
+ # introduce a self-referential dependency inside the handler for_each.
60
+ handler_extra_env = {
61
+ "job-schedule-manager" = {
62
+ JOB_TRIGGER_ARN = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger"
63
+ JOB_TRIGGER_ROLE_ARN = var.job_scheduler_role_arn
64
+ }
42
65
  }
43
66
  }
44
67
 
@@ -69,8 +92,11 @@ resource "aws_lambda_function" "handler" {
69
92
  "guardrails",
70
93
  "scheduled-jobs",
71
94
  "job-schedule-manager",
95
+ "job-trigger",
72
96
  "webhooks",
73
97
  "webhooks-admin",
98
+ "webhook-deliveries-cleanup",
99
+ "task-connectors",
74
100
  "workspace-files",
75
101
  "knowledge-base-manager",
76
102
  "knowledge-base-files",
@@ -79,10 +105,9 @@ resource "aws_lambda_function" "handler" {
79
105
  "github-app",
80
106
  "github-repos",
81
107
  "memory",
108
+ "memory-retain",
82
109
  "artifact-deliver",
83
110
  "recipe-refresh",
84
- "connector-installs",
85
- "connector-secrets",
86
111
  "agent-skills-list",
87
112
  "bootstrap-workspaces",
88
113
  "code-factory",
@@ -99,9 +124,11 @@ resource "aws_lambda_function" "handler" {
99
124
  source_code_hash = filebase64sha256("${var.lambda_zips_dir}/${each.key}.zip")
100
125
 
101
126
  environment {
102
- variables = merge(local.common_env, {
103
- FUNCTION_NAME = each.key
104
- })
127
+ variables = merge(
128
+ local.common_env,
129
+ { FUNCTION_NAME = each.key },
130
+ lookup(local.handler_extra_env, each.key, {}),
131
+ )
105
132
  }
106
133
 
107
134
  tags = {
@@ -195,6 +222,10 @@ locals {
195
222
  "ANY /api/webhooks/{proxy+}" = "webhooks-admin"
196
223
  "ANY /api/webhooks" = "webhooks-admin"
197
224
 
225
+ # Task Connectors admin
226
+ "ANY /api/task-connectors/{proxy+}" = "task-connectors"
227
+ "ANY /api/task-connectors" = "task-connectors"
228
+
198
229
  # Workspace files
199
230
  "ANY /api/workspaces/{proxy+}" = "workspace-files"
200
231
 
@@ -216,10 +247,6 @@ locals {
216
247
  # GitHub App
217
248
  "ANY /api/github-app/{proxy+}" = "github-app"
218
249
  "POST /api/github/webhook" = "github-app"
219
-
220
- # Connectors
221
- "ANY /api/connector-installs/{proxy+}" = "connector-installs"
222
- "ANY /api/connector-secrets/{proxy+}" = "connector-secrets"
223
250
  } : {}
224
251
  }
225
252
 
@@ -272,6 +299,28 @@ resource "aws_scheduler_schedule" "wakeup_processor" {
272
299
  }
273
300
  }
274
301
 
302
+ # ---------------------------------------------------------------------------
303
+ # webhook_deliveries retention cron — daily delete of rows older than 90 days
304
+ # ---------------------------------------------------------------------------
305
+
306
+ resource "aws_scheduler_schedule" "webhook_deliveries_cleanup" {
307
+ count = local.use_local_zips ? 1 : 0
308
+
309
+ name = "thinkwork-${var.stage}-webhook-deliveries-cleanup"
310
+ group_name = "default"
311
+ schedule_expression = "cron(0 4 * * ? *)" # daily at 04:00 UTC
312
+ state = "ENABLED"
313
+
314
+ flexible_time_window {
315
+ mode = "OFF"
316
+ }
317
+
318
+ target {
319
+ arn = aws_lambda_function.handler["webhook-deliveries-cleanup"].arn
320
+ role_arn = aws_iam_role.scheduler.arn
321
+ }
322
+ }
323
+
275
324
  resource "aws_iam_role" "scheduler" {
276
325
  name = "thinkwork-${var.stage}-scheduler-role"
277
326
 
@@ -308,6 +357,7 @@ resource "aws_ssm_parameter" "lambda_arns" {
308
357
  "chat-agent-invoke-fn-arn" = aws_lambda_function.handler["chat-agent-invoke"].arn
309
358
  "kb-manager-fn-arn" = aws_lambda_function.handler["knowledge-base-manager"].arn
310
359
  "job-schedule-manager-fn-arn" = aws_lambda_function.handler["job-schedule-manager"].arn
360
+ "memory-retain-fn-arn" = aws_lambda_function.handler["memory-retain"].arn
311
361
  } : {}
312
362
 
313
363
  name = "/thinkwork/${var.stage}/${each.key}"
@@ -28,7 +28,7 @@ resource "aws_apigatewayv2_api" "main" {
28
28
 
29
29
  cors_configuration {
30
30
  allow_headers = ["Content-Type", "Authorization", "x-api-key", "x-tenant-id", "x-tenant-slug", "x-principal-id"]
31
- allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
31
+ allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
32
32
  allow_origins = var.cors_allowed_origins
33
33
  max_age = 3600
34
34
  }
@@ -206,6 +206,30 @@ resource "aws_iam_role_policy" "lambda_bedrock" {
206
206
  })
207
207
  }
208
208
 
209
+ # SES send permissions for the email-send handler. Scoped to any
210
+ # verified identity in this account+region so the email-send Lambda
211
+ # can SendRawEmail from agents.thinkwork.ai (and any other domain
212
+ # identity a future deployment might add).
213
+ resource "aws_iam_role_policy" "lambda_ses_send" {
214
+ name = "ses-send"
215
+ role = aws_iam_role.lambda.id
216
+
217
+ policy = jsonencode({
218
+ Version = "2012-10-17"
219
+ Statement = [{
220
+ Effect = "Allow"
221
+ Action = [
222
+ "ses:SendEmail",
223
+ "ses:SendRawEmail",
224
+ ]
225
+ Resource = [
226
+ "arn:aws:ses:${var.region}:${var.account_id}:identity/*",
227
+ "arn:aws:ses:${var.region}:${var.account_id}:configuration-set/*",
228
+ ]
229
+ }]
230
+ })
231
+ }
232
+
209
233
  # Allow API Lambdas to directly invoke the AgentCore Lambda. Used by
210
234
  # chat-agent-invoke (and future wake-up/retry paths) via InvokeCommand.
211
235
  resource "aws_iam_role_policy" "lambda_agentcore_invoke" {
@@ -232,7 +256,7 @@ resource "aws_iam_role_policy" "lambda_agentcore_invoke" {
232
256
  # memoryRecords / memorySearch call ListMemoryRecordsCommand to fetch
233
257
  # records across the tenant's agents.
234
258
  resource "aws_iam_role_policy" "lambda_agentcore_memory" {
235
- name = "agentcore-memory-read"
259
+ name = "agentcore-memory-rw"
236
260
  role = aws_iam_role.lambda.id
237
261
 
238
262
  policy = jsonencode({
@@ -243,12 +267,106 @@ resource "aws_iam_role_policy" "lambda_agentcore_memory" {
243
267
  "bedrock-agentcore:ListMemoryRecords",
244
268
  "bedrock-agentcore:RetrieveMemoryRecords",
245
269
  "bedrock-agentcore:GetMemoryRecord",
270
+ "bedrock-agentcore:BatchCreateMemoryRecords",
271
+ "bedrock-agentcore:BatchUpdateMemoryRecords",
272
+ "bedrock-agentcore:BatchDeleteMemoryRecords",
273
+ "bedrock-agentcore:DeleteMemoryRecord",
246
274
  ]
247
275
  Resource = "*"
248
276
  }]
249
277
  })
250
278
  }
251
279
 
280
+ # graphql-http's sendMessage mutation reads SSM parameters like
281
+ # /thinkwork/${stage}/chat-agent-invoke-fn-arn to discover the direct
282
+ # Lambda targets for cross-function invocation. Without this, the SSM
283
+ # GetParameter call fails with AccessDenied, the caller silently
284
+ # catches the error, and sendMessage falls back to the wakeup-processor
285
+ # path — which doesn't load messages_history from Aurora. That's why
286
+ # multi-turn chat was losing prior context: history was only loaded on
287
+ # the direct path, which never ran.
288
+ resource "aws_iam_role_policy" "lambda_ssm_read" {
289
+ name = "ssm-param-read"
290
+ role = aws_iam_role.lambda.id
291
+
292
+ policy = jsonencode({
293
+ Version = "2012-10-17"
294
+ Statement = [{
295
+ Effect = "Allow"
296
+ Action = [
297
+ "ssm:GetParameter",
298
+ "ssm:GetParameters",
299
+ ]
300
+ Resource = "arn:aws:ssm:${var.region}:${var.account_id}:parameter/thinkwork/${var.stage}/*"
301
+ }]
302
+ })
303
+ }
304
+
305
+ # job-schedule-manager creates/updates/deletes EventBridge Scheduler
306
+ # schedules (and the thinkwork-jobs schedule group on first use). Without
307
+ # these permissions the manager Lambda threw silently and every scheduled
308
+ # automation was orphaned with eb_schedule_name = null.
309
+ resource "aws_iam_role_policy" "lambda_scheduler" {
310
+ name = "eventbridge-scheduler-rw"
311
+ role = aws_iam_role.lambda.id
312
+
313
+ policy = jsonencode({
314
+ Version = "2012-10-17"
315
+ Statement = [
316
+ {
317
+ Effect = "Allow"
318
+ Action = [
319
+ "scheduler:CreateSchedule",
320
+ "scheduler:UpdateSchedule",
321
+ "scheduler:DeleteSchedule",
322
+ "scheduler:GetSchedule",
323
+ "scheduler:ListSchedules",
324
+ "scheduler:CreateScheduleGroup",
325
+ "scheduler:GetScheduleGroup",
326
+ "scheduler:DeleteScheduleGroup",
327
+ "scheduler:TagResource",
328
+ ]
329
+ Resource = "*"
330
+ },
331
+ # Scheduler.CreateSchedule takes a RoleArn for the target; AWS requires
332
+ # the caller to have iam:PassRole on that role. Without this the
333
+ # CreateSchedule call fails with AccessDenied even if the scheduler
334
+ # permissions above are set.
335
+ {
336
+ Effect = "Allow"
337
+ Action = ["iam:PassRole"]
338
+ Resource = var.job_scheduler_role_arn != "" ? var.job_scheduler_role_arn : "*"
339
+ },
340
+ ]
341
+ })
342
+ }
343
+
344
+ # Allow API handler Lambdas to invoke each other directly. sendMessage
345
+ # dispatches to chat-agent-invoke for instant chat response; the memory
346
+ # resolvers reach knowledge-base-manager and job-schedule-manager for
347
+ # admin-driven operations. The existing lambda_agentcore_invoke policy
348
+ # covers the Strands runtime Lambda only — this one covers internal
349
+ # api-to-api calls. ARNs are constructed deterministically from the
350
+ # handler naming pattern so we don't create a dependency cycle with the
351
+ # handler resource.
352
+ resource "aws_iam_role_policy" "lambda_api_cross_invoke" {
353
+ name = "api-cross-function-invoke"
354
+ role = aws_iam_role.lambda.id
355
+
356
+ policy = jsonencode({
357
+ Version = "2012-10-17"
358
+ Statement = [{
359
+ Effect = "Allow"
360
+ Action = ["lambda:InvokeFunction"]
361
+ Resource = [
362
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-chat-agent-invoke",
363
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-knowledge-base-manager",
364
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-schedule-manager",
365
+ ]
366
+ }]
367
+ })
368
+ }
369
+
252
370
  ################################################################################
253
371
  # Placeholder Lambda — proves the infrastructure works
254
372
  #
@@ -22,3 +22,23 @@ output "lambda_role_name" {
22
22
  description = "Shared Lambda execution role name"
23
23
  value = aws_iam_role.lambda.name
24
24
  }
25
+
26
+ output "memory_retain_fn_name" {
27
+ description = "Memory-retain Lambda function name. Strands runtime invokes this directly to push conversational turns into the active memory engine."
28
+ value = local.use_local_zips ? aws_lambda_function.handler["memory-retain"].function_name : ""
29
+ }
30
+
31
+ output "memory_retain_fn_arn" {
32
+ description = "Memory-retain Lambda ARN. Used to grant lambda:InvokeFunction to the agentcore-runtime role."
33
+ value = local.use_local_zips ? aws_lambda_function.handler["memory-retain"].arn : ""
34
+ }
35
+
36
+ output "email_inbound_fn_arn" {
37
+ description = "email-inbound Lambda ARN. Used by the SES module to wire the receipt rule Lambda action."
38
+ value = local.use_local_zips ? aws_lambda_function.handler["email-inbound"].arn : ""
39
+ }
40
+
41
+ output "email_inbound_fn_name" {
42
+ description = "email-inbound Lambda function name. Used by the SES module for lambda:InvokeFunction permissions."
43
+ value = local.use_local_zips ? aws_lambda_function.handler["email-inbound"].function_name : ""
44
+ }
@@ -146,6 +146,16 @@ variable "agentcore_memory_id" {
146
146
  default = ""
147
147
  }
148
148
 
149
+ variable "memory_engine" {
150
+ description = "Active long-term memory engine for this deployment. Exactly one engine is canonical for recall/inspect/export. Defaults to 'hindsight' for hosted ThinkWork; self-hosted/serverless deployments may choose 'agentcore'."
151
+ type = string
152
+ default = "hindsight"
153
+ validation {
154
+ condition = contains(["hindsight", "agentcore"], var.memory_engine)
155
+ error_message = "memory_engine must be 'hindsight' or 'agentcore'."
156
+ }
157
+ }
158
+
149
159
  variable "agentcore_function_name" {
150
160
  description = "AgentCore Lambda function name (for direct SDK invoke)"
151
161
  type = string
@@ -187,3 +197,15 @@ variable "cors_allowed_origins" {
187
197
  type = list(string)
188
198
  default = ["*"]
189
199
  }
200
+
201
+ variable "job_scheduler_role_arn" {
202
+ description = "IAM role ARN that EventBridge Scheduler assumes to invoke the job-trigger Lambda. Passed from the job-triggers module."
203
+ type = string
204
+ default = ""
205
+ }
206
+
207
+ variable "lastmile_tasks_api_url" {
208
+ description = "Base URL of the LastMile Tasks REST API used by the outbound sync path (POST /tasks, GET /workflows, etc). Leave blank to feature-flag the integration off; mobile-created tasks then land in sync_status='local' until the URL is set."
209
+ type = string
210
+ default = ""
211
+ }