@feltdb/core 0.7.2 → 0.7.4

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 (96) hide show
  1. package/dist/application-development.d.ts +76 -0
  2. package/dist/application-development.d.ts.map +1 -0
  3. package/dist/application-development.js +137 -0
  4. package/dist/application-manifest.d.ts +5 -1
  5. package/dist/application-manifest.d.ts.map +1 -1
  6. package/dist/application-manifest.js +10 -3
  7. package/dist/canonical-application.d.ts +26 -0
  8. package/dist/canonical-application.d.ts.map +1 -0
  9. package/dist/canonical-application.js +45 -0
  10. package/dist/cli/ai-model-config.js +97 -0
  11. package/dist/cli/commands.js +485 -30
  12. package/dist/cli/index.js +1 -1
  13. package/dist/cli/proposal-client.js +54 -0
  14. package/dist/cli/publish-engine.js +162 -0
  15. package/dist/cli/source-sync.js +139 -0
  16. package/dist/create/cli.js +1 -1
  17. package/dist/create/create.js +30 -6
  18. package/dist/create/managed-account.js +14 -7
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/crates/feltdb/src/application.rs +31 -1
  21. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +847 -0
  22. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +338 -29
  23. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  24. package/dist/create/server-source/crates/feltdb/src/lib.rs +1 -0
  25. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +46 -3
  26. package/dist/create/server-source/crates/feltdb-server/src/main.rs +315 -16
  27. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +20 -0
  28. package/dist/create/template/default-project/README.md +57 -0
  29. package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
  30. package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
  31. package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
  32. package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
  33. package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
  34. package/dist/create/template/default-project/feltdb.flow +169 -0
  35. package/dist/create/template/default-project/src/App.tsx +20 -0
  36. package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
  37. package/dist/create/template/default-project/src/feltdb.ts +87 -0
  38. package/dist/create/template/default-project/src/index.tsx +14 -0
  39. package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
  40. package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
  41. package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
  42. package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
  43. package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
  44. package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
  45. package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
  46. package/dist/create/template/default-project/src/styles-application.css +21 -0
  47. package/dist/create/template/default-project/src/styles.css +1 -0
  48. package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  49. package/dist/create/template/default-project/workflows/invitation.ts +30 -0
  50. package/dist/create/template/default-project/workflows/project-created.ts +2 -0
  51. package/dist/db.d.ts +49 -1
  52. package/dist/db.d.ts.map +1 -1
  53. package/dist/db.js +77 -0
  54. package/dist/error-codes.d.ts +20 -1
  55. package/dist/error-codes.d.ts.map +1 -1
  56. package/dist/error-codes.js +25 -0
  57. package/dist/flowspec.d.ts.map +1 -1
  58. package/dist/flowspec.js +27 -6
  59. package/dist/http-db.d.ts +15 -2
  60. package/dist/http-db.d.ts.map +1 -1
  61. package/dist/http-db.js +113 -5
  62. package/dist/index-core.d.ts +3 -0
  63. package/dist/index-core.d.ts.map +1 -1
  64. package/dist/index-core.js +3 -0
  65. package/dist/proposal.d.ts +85 -0
  66. package/dist/proposal.d.ts.map +1 -0
  67. package/dist/proposal.js +105 -0
  68. package/dist/studio/app.d.ts +5 -1
  69. package/dist/studio/app.d.ts.map +1 -1
  70. package/dist/studio/components/ApplicationDesigner.d.ts +10 -1
  71. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  72. package/dist/studio/components/AskFeltDB.d.ts +20 -0
  73. package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
  74. package/dist/studio/components/ProposalBrowser.d.ts +3 -0
  75. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
  76. package/dist/studio/components/index.d.ts +2 -0
  77. package/dist/studio/components/index.d.ts.map +1 -1
  78. package/dist/studio/components/index.js +4 -4
  79. package/dist/studio/{components-C5p2TfIU.js → components-Bm4sjhcX.js} +531 -192
  80. package/dist/studio/index.js +184 -172
  81. package/dist/studio/studio.css +1 -1
  82. package/dist/studio-app/assets/dist-DA1MYSzp.js +1 -0
  83. package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-B-Ccgccb.js} +1 -1
  84. package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
  85. package/dist/studio-app/assets/{index-C1GWyazR.css → index-BRrmvaKw.css} +1 -1
  86. package/dist/studio-app/assets/index-CEIqowCh.js +42 -0
  87. package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
  88. package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
  89. package/dist/studio-app/index.html +2 -2
  90. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  91. package/dist/workspace/local-development-authority.d.ts +3 -0
  92. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  93. package/dist/workspace/local-development-authority.js +62 -1
  94. package/package.json +1 -1
  95. package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
  96. package/dist/studio-app/assets/index-C71X92EK.js +0 -28
@@ -30,7 +30,8 @@ use axum::{
30
30
  use base64::Engine;
31
31
  use feltdb::{
32
32
  application::{
33
- ActionOperation, ApplicationManifest, ApplicationStore, PreviewDataMode, PreviewPolicy,
33
+ diff_manifests, validate_manifest, ActionOperation, ApplicationManifest, ApplicationRevision,
34
+ ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
34
35
  },
35
36
  application_runtime::{
36
37
  resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
@@ -121,10 +122,27 @@ impl IntoResponse for ApiError {
121
122
  }
122
123
 
123
124
  fn api_error_body(status: StatusCode, message: String) -> Value {
124
- if status == StatusCode::FORBIDDEN {
125
- return json!({ "error": "AUTHORIZATION_DENIED" });
126
- }
127
- serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "error": message }))
125
+ let request_id = format!("req_{}", uuid::Uuid::new_v4().simple());
126
+ let code = match status {
127
+ StatusCode::UNAUTHORIZED => "AUTHENTICATION_REQUIRED",
128
+ StatusCode::FORBIDDEN => "FORBIDDEN",
129
+ StatusCode::NOT_FOUND => "NOT_FOUND",
130
+ StatusCode::CONFLICT => "CONFLICT",
131
+ StatusCode::TOO_MANY_REQUESTS => "QUERY_LIMIT_EXCEEDED",
132
+ StatusCode::SERVICE_UNAVAILABLE => "SERVICE_UNAVAILABLE",
133
+ StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => "VALIDATION_ERROR",
134
+ _ if status.is_server_error() => "EXECUTION_FAILED",
135
+ _ => "REQUEST_FAILED",
136
+ };
137
+ let parsed = serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
138
+ let mut body = if parsed.is_object() { parsed } else { json!({ "message": parsed }) };
139
+ let object = body.as_object_mut().expect("normalized error body is an object");
140
+ let resolved_code = object.get("code").and_then(Value::as_str).unwrap_or(code).to_string();
141
+ object.insert("error".into(), json!(resolved_code));
142
+ object.insert("code".into(), json!(resolved_code));
143
+ object.entry("message").or_insert_with(|| json!(if status == StatusCode::FORBIDDEN { "request is not authorized" } else { status.canonical_reason().unwrap_or("request failed") }));
144
+ object.insert("request_id".into(), json!(request_id));
145
+ body
128
146
  }
129
147
 
130
148
  impl From<feltdb::FlowError> for ApiError {
@@ -390,6 +408,31 @@ struct RuntimeEnvironmentRequest {
390
408
  environment: String,
391
409
  }
392
410
  #[derive(Deserialize)]
411
+ struct ApplicationDiscoveryRequest {
412
+ application_id: String,
413
+ #[serde(default = "production_environment")]
414
+ environment: String,
415
+ }
416
+ #[derive(Deserialize)]
417
+ struct ProposalScopeRequest { application_id: String, #[serde(default = "production_environment")] environment: String }
418
+ #[derive(Deserialize)]
419
+ struct CreateProposalRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
420
+ base_contract_hash: String, base_flow_hash: String, proposed_flow: String, summary: String,
421
+ #[serde(default)] warnings: Vec<String>, #[serde(default)] metadata: Value,
422
+ parent_proposal_id: Option<String>, expires_at: Option<String> }
423
+ #[derive(Deserialize)]
424
+ struct ProposalActorRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
425
+ proposed_manifest: Option<ApplicationManifest> }
426
+ #[derive(Deserialize)]
427
+ struct ProposalStatusRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
428
+ status: String, #[serde(default)] approve_authorization: bool }
429
+ #[derive(Deserialize, Default)]
430
+ struct EventScopeRequest { application_id: Option<String> }
431
+ #[derive(Deserialize)]
432
+ struct SignUpRequest { application_id: String, email: String, password: String, display_name: Option<String> }
433
+ #[derive(Deserialize)]
434
+ struct SignInRequest { email: String, password: String }
435
+ #[derive(Deserialize)]
393
436
  struct StateContractTarget {
394
437
  application_id: String,
395
438
  revision_id: String,
@@ -4403,6 +4446,190 @@ async fn get_active_application_runtime(
4403
4446
  json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
4404
4447
  ))
4405
4448
  }
4449
+ async fn discover_application(
4450
+ State(state): State<AppState>,
4451
+ Extension(principal): Extension<Principal>,
4452
+ Query(input): Query<ApplicationDiscoveryRequest>,
4453
+ ) -> Result<Json<Value>, ApiError> {
4454
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4455
+ let revision_id = state.applications.pointers(&input.application_id)
4456
+ .get(&input.environment).cloned()
4457
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4458
+ let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4459
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4460
+ let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4461
+ let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
4462
+ Ok(Json(json!({
4463
+ "application_id": input.application_id,
4464
+ "environment": input.environment,
4465
+ "version": revision.revision_number,
4466
+ "revision_id": revision.revision_id,
4467
+ "contract_hash": contract_hash,
4468
+ "dsl_version": dsl_version,
4469
+ "snapshot": {
4470
+ "application": { "application_id": input.application_id, "environment": input.environment },
4471
+ "contract_version": revision.revision_number,
4472
+ "contract_hash": contract_hash,
4473
+ "dsl_version": dsl_version,
4474
+ "schema": revision.manifest.collections,
4475
+ "indexes": revision.manifest.indexes,
4476
+ "policies": revision.manifest.policies,
4477
+ "workflows": revision.manifest.workflows,
4478
+ "agents": revision.manifest.agents,
4479
+ "capabilities": revision.manifest.capabilities,
4480
+ "supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
4481
+ }
4482
+ })))
4483
+ }
4484
+ const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
4485
+ const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
4486
+
4487
+ fn proposal_event(state: &AppState, proposal_id: &str, from: Option<&str>, to: &str, actor: &str) -> Result<(), ApiError> {
4488
+ let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
4489
+ insert_canonical(state, PROPOSAL_EVENT_COLLECTION, &id, json!({ "id":id, "proposal_id":proposal_id,
4490
+ "from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(), "metadata":{} }))?;
4491
+ Ok(())
4492
+ }
4493
+ fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
4494
+ required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
4495
+ }
4496
+ fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
4497
+ value.get("application_id").and_then(Value::as_str) == Some(application_id)
4498
+ && value.get("environment").and_then(Value::as_str) == Some(environment)
4499
+ }
4500
+ async fn create_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Json(input): Json<CreateProposalRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4501
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
4502
+ let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
4503
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
4504
+ let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
4505
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
4506
+ let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4507
+ if input.base_contract_hash != *contract_hash { return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL: base contract does not match active contract".into())); }
4508
+ if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "proposed_flow and base_flow_hash are required".into())); }
4509
+ let id = format!("p_{}", uuid::Uuid::new_v4().simple()); let now = unix_seconds_i64();
4510
+ let value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
4511
+ "status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
4512
+ "base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
4513
+ "contract_diff":[], "source_plan":{"files":[]}, "summary":input.summary, "warnings":input.warnings,
4514
+ "created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
4515
+ "parent_proposal_id":input.parent_proposal_id });
4516
+ insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?; proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
4517
+ Ok((StatusCode::CREATED, Json(value)))
4518
+ }
4519
+ async fn list_proposals(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4520
+ application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
4521
+ let proposals: Vec<Value> = state.db.list_collection(PROPOSAL_COLLECTION)?.into_iter().map(|row| row.value)
4522
+ .filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment)).collect();
4523
+ Ok(Json(json!({"proposals":proposals})))
4524
+ }
4525
+ async fn get_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4526
+ application_scope(&state, &principal.key_id, &input.application_id, "application:read")?; let value = proposal_value(&state, &id)?;
4527
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4528
+ Ok(Json(value))
4529
+ }
4530
+ async fn proposal_history(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
4531
+ let _ = get_proposal(State(state.clone()), Extension(principal), Path(id.clone()), Query(input)).await?;
4532
+ let events: Vec<Value> = state.db.list_collection(PROPOSAL_EVENT_COLLECTION)?.into_iter().map(|row| row.value)
4533
+ .filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())).collect();
4534
+ Ok(Json(json!({"events":events})))
4535
+ }
4536
+ fn move_proposal(state: &AppState, id: &str, mut value: Value, to: &str, actor: &str) -> Result<Value, ApiError> {
4537
+ let from = value.get("status").and_then(Value::as_str).unwrap_or("").to_string();
4538
+ let allowed = matches!((from.as_str(),to), ("proposed","validated")|("proposed","rejected")|("validated","previewed")|("validated","rejected")|("previewed","approved")|("previewed","rejected")|("approved","applied")|("approved","expired"));
4539
+ if !allowed { return Err(ApiError(StatusCode::CONFLICT, format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"))); }
4540
+ value["status"] = json!(to); if to == "approved" { value["approved_by"] = json!(actor); value["approved_at"] = json!(unix_seconds_i64()); }
4541
+ if to == "applied" { value["applied_at"] = json!(unix_seconds_i64()); }
4542
+ let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?; proposal_event(state, id, Some(&from), to, actor)?; Ok(value)
4543
+ }
4544
+ async fn validate_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4545
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?; let mut value = proposal_value(&state, &id)?;
4546
+ if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
4547
+ let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or(ApiError(StatusCode::NOT_FOUND,"active contract not found".into()))?;
4548
+ let revision = state.applications.revision(&tenant,&input.application_id,&revision_id).ok_or(ApiError(StatusCode::NOT_FOUND,"application revision not found".into()))?;
4549
+ let hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
4550
+ if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) { return Err(ApiError(StatusCode::CONFLICT,"STALE_PROPOSAL".into())); }
4551
+ if let Some(manifest) = input.proposed_manifest {
4552
+ let report = validate_manifest(&manifest, &tenant, &input.application_id, Some(&revision.manifest));
4553
+ if !report.valid { return Err(ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"INVALID_PROPOSAL","validation":report}))); }
4554
+ let proposed = ApplicationRevision { revision_id: format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
4555
+ revision_number:revision.revision_number + 1, parent_revision_id:Some(revision.revision_id.clone()), manifest_hash:String::new(),
4556
+ manifest, created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
4557
+ value["contract_diff"] = serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes).map_err(|e|ApiError(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?;
4558
+ }
4559
+ value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
4560
+ Ok(Json(move_proposal(&state,&id,value,"validated",&principal.key_id)?))
4561
+ }
4562
+ async fn preview_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
4563
+ application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4564
+ if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4565
+ Ok(Json(json!({"proposal":move_proposal(&state,&id,value,"previewed",&principal.key_id)?})))
4566
+ }
4567
+ async fn transition_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap, Path(id): Path<String>, Json(input): Json<ProposalStatusRequest>) -> Result<Json<Value>, ApiError> {
4568
+ application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
4569
+ if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
4570
+ if !matches!(input.status.as_str(),"approved"|"rejected"|"expired"|"applied") { return Err(ApiError(StatusCode::BAD_REQUEST,"unsupported proposal status".into())); }
4571
+ if input.status == "applied" && headers.get("FeltDB-Repository-Apply").and_then(|v|v.to_str().ok()) != Some("1") { return Err(ApiError(StatusCode::FORBIDDEN,"REPOSITORY_AUTHORITY_REQUIRED".into())); }
4572
+ if input.status == "approved" && value.get("warnings").and_then(Value::as_array).map(|items|items.iter().any(|item|item.as_str().unwrap_or("").starts_with("Authorization "))).unwrap_or(false) && !input.approve_authorization { return Err(ApiError(StatusCode::FORBIDDEN,"AUTHORIZATION_APPROVAL_REQUIRED".into())); }
4573
+ Ok(Json(move_proposal(&state,&id,value,&input.status,&principal.key_id)?))
4574
+ }
4575
+ fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_server::identity::IdentitySession, token: String) -> Json<Value> {
4576
+ Json(json!({
4577
+ "access_token": token,
4578
+ "token_type": "Bearer",
4579
+ "expires_at": session.expires_at,
4580
+ "actor": { "user_id": identity.subject_id, "identity_id": identity.identity_id, "email": identity.email, "display_name": identity.display_name },
4581
+ "session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
4582
+ }))
4583
+ }
4584
+ async fn auth_sign_up(State(state): State<AppState>, Json(input): Json<SignUpRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
4585
+ if state.tenancy.application_tenant(&input.application_id).is_none() { return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into())); }
4586
+ let (identity, session, token) = state.identities.sign_up(&input.email, &input.password, input.display_name)
4587
+ .map_err(|error| ApiError(if error == "identity already exists" { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }, error))?;
4588
+ state.tenancy.register_platform_user(&identity.subject_id, identity.email.as_deref().unwrap_or(&identity.identity_id)).map_err(control_error)?;
4589
+ state.tenancy.register_application_actor(&identity.subject_id, &input.application_id).map_err(control_error)?;
4590
+ Ok((StatusCode::CREATED, auth_response(identity, session, token)))
4591
+ }
4592
+ async fn auth_sign_in(State(state): State<AppState>, Json(input): Json<SignInRequest>) -> Result<Json<Value>, ApiError> {
4593
+ let (identity, session, token) = state.identities.sign_in(&input.email, &input.password)
4594
+ .map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
4595
+ Ok(auth_response(identity, session, token))
4596
+ }
4597
+ async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
4598
+ Json(json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }))
4599
+ }
4600
+ async fn auth_sign_out(State(state): State<AppState>, Extension(principal): Extension<Principal>) -> Result<StatusCode, ApiError> {
4601
+ let session = principal.session_id.ok_or(ApiError(StatusCode::BAD_REQUEST, "request is not an actor session".into()))?;
4602
+ state.identities.revoke_session(&principal.key_id, &session, "user_logout").map_err(control_error)?;
4603
+ Ok(StatusCode::NO_CONTENT)
4604
+ }
4605
+ async fn application_openapi() -> Json<Value> {
4606
+ Json(json!({
4607
+ "openapi": "3.1.0",
4608
+ "info": { "title": "FeltDB Application Service", "version": "1.0.0" },
4609
+ "servers": [{ "url": "/v1" }],
4610
+ "components": {
4611
+ "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } },
4612
+ "schemas": {
4613
+ "Error": { "type": "object", "required": ["code", "message", "request_id"], "properties": {
4614
+ "code": { "type": "string" }, "message": { "type": "string" }, "request_id": { "type": "string" }
4615
+ }},
4616
+ "ApplicationEvent": { "type": "object", "required": ["event_id", "type", "resource", "resource_id", "actor", "timestamp", "payload"] }
4617
+ }
4618
+ },
4619
+ "security": [{ "bearer": [] }],
4620
+ "paths": {
4621
+ "/health": { "get": { "security": [], "responses": { "200": { "description": "Runtime health" } } } },
4622
+ "/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
4623
+ "/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
4624
+ "/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
4625
+ "/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
4626
+ "/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
4627
+ "/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
4628
+ "/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
4629
+ "/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
4630
+ }
4631
+ }))
4632
+ }
4406
4633
  async fn get_revision_runtime(
4407
4634
  State(state): State<AppState>,
4408
4635
  Extension(principal): Extension<Principal>,
@@ -6029,6 +6256,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6029
6256
  axum::routing::post(validate_state_schema),
6030
6257
  )
6031
6258
  .route("/v1/schema", get(get_state_schema))
6259
+ .route("/v1/application", get(discover_application))
6260
+ .route("/v1/proposals", get(list_proposals).post(create_proposal))
6261
+ .route("/v1/proposals/{id}", get(get_proposal))
6262
+ .route("/v1/proposals/{id}/history", get(proposal_history))
6263
+ .route("/v1/proposals/{id}/validate", axum::routing::post(validate_proposal))
6264
+ .route("/v1/proposals/{id}/preview", axum::routing::post(preview_proposal))
6265
+ .route("/v1/proposals/{id}/status", axum::routing::patch(transition_proposal))
6266
+ .route("/v1/auth/session", get(auth_session))
6267
+ .route("/v1/auth/signout", axum::routing::post(auth_sign_out))
6268
+ .route("/v1/events", get(events))
6269
+ .route("/v1/workflows/{name}/run", axum::routing::post(start_workflow_run))
6270
+ .route("/v1/agents/{name}/run", axum::routing::post(start_agent_run))
6271
+ .route("/v1/capabilities/{name}/run", axum::routing::post(execute_application_capability))
6032
6272
  .route("/v1/state/version", get(get_state_version))
6033
6273
  .route("/v1/query", axum::routing::post(execute_canonical_query))
6034
6274
  .route(
@@ -6393,6 +6633,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6393
6633
 
6394
6634
  let mut app = Router::new()
6395
6635
  .route("/health", get(health))
6636
+ .route("/v1/health", get(health))
6637
+ .route("/v1/openapi.json", get(application_openapi))
6638
+ .route("/v1/auth/signup", axum::routing::post(auth_sign_up))
6639
+ .route("/v1/auth/signin", axum::routing::post(auth_sign_in))
6396
6640
  .route("/ready", get(readiness))
6397
6641
  .route("/runtime", get(runtime))
6398
6642
  .route("/metrics", get(network_metrics))
@@ -6422,6 +6666,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6422
6666
  axum::http::header::CONTENT_TYPE,
6423
6667
  axum::http::HeaderName::from_static("feltdb-protocol"),
6424
6668
  axum::http::HeaderName::from_static("if-version"),
6669
+ axum::http::HeaderName::from_static("idempotency-key"),
6425
6670
  ]),
6426
6671
  );
6427
6672
  }
@@ -6472,6 +6717,7 @@ async fn authenticate(
6472
6717
  .and_then(|header| header.to_str().ok())
6473
6718
  .and_then(|header| header.strip_prefix("Bearer "));
6474
6719
  let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
6720
+ let actor = token.and_then(|token| state.identities.authenticate_session(token));
6475
6721
  let human = request
6476
6722
  .headers()
6477
6723
  .get(COOKIE)
@@ -6483,9 +6729,16 @@ async fn authenticate(
6483
6729
  .find_map(|cookie| cookie.strip_prefix("feltdb_session="))
6484
6730
  })
6485
6731
  .and_then(|token| state.sessions.verify(token));
6486
- match (machine, human) {
6487
- (Some(principal), _) => principal,
6488
- (None, Some(session)) => {
6732
+ match (machine, actor, human) {
6733
+ (Some(principal), _, _) => principal,
6734
+ (None, Some(session), _) => Principal {
6735
+ key_id: session.subject_id,
6736
+ scopes: vec!["application:read".into(), "application:revision:read".into(), "state:read".into(), "state:write".into(), "events:read".into(), "workflows:run".into(), "agents:run".into(), "capabilities:run".into()],
6737
+ subject_type: "human".into(),
6738
+ identity_id: Some(session.identity_id),
6739
+ session_id: Some(session.session_id),
6740
+ },
6741
+ (None, None, Some(session)) => {
6489
6742
  if state
6490
6743
  .tenancy
6491
6744
  .register_platform_user(&session.user_id, &session.identity)
@@ -6503,7 +6756,7 @@ async fn authenticate(
6503
6756
  session_id: Some(session.id),
6504
6757
  }
6505
6758
  }
6506
- (None, None) => {
6759
+ (None, None, None) => {
6507
6760
  state.metrics.authentication_failure();
6508
6761
  audit(
6509
6762
  &state,
@@ -8476,13 +8729,35 @@ async fn define_capability(
8476
8729
 
8477
8730
  async fn execute_capability(
8478
8731
  State(state): State<AppState>,
8732
+ Extension(principal): Extension<Principal>,
8479
8733
  Path(name): Path<String>,
8480
8734
  Json(input): Json<Value>,
8481
8735
  ) -> Result<Json<Value>, ApiError> {
8736
+ if !principal.permits("capabilities:run") { return Err(ApiError(StatusCode::FORBIDDEN, "capability execution is not permitted".into())); }
8482
8737
  validate_segment(&name)?;
8483
8738
  Ok(Json(execute_named_capability(&state, &name, &input)?))
8484
8739
  }
8485
8740
 
8741
+ async fn execute_application_capability(
8742
+ State(state): State<AppState>,
8743
+ Extension(principal): Extension<Principal>,
8744
+ Path(name): Path<String>,
8745
+ Query(input): Query<ApplicationDiscoveryRequest>,
8746
+ Json(payload): Json<Value>,
8747
+ ) -> Result<Json<Value>, ApiError> {
8748
+ let tenant = application_scope(&state, &principal.key_id, &input.application_id, "capabilities:run")?;
8749
+ let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
8750
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
8751
+ let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
8752
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
8753
+ let capability = revision.manifest.capabilities.iter().find(|value| value.name == name)
8754
+ .ok_or(ApiError(StatusCode::NOT_FOUND, "capability not found".into()))?;
8755
+ if capability.visibility != "public" {
8756
+ return Err(ApiError(StatusCode::FORBIDDEN, "capability is not externally invocable".into()));
8757
+ }
8758
+ Ok(Json(execute_named_capability(&state, &name, &payload)?))
8759
+ }
8760
+
8486
8761
  fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
8487
8762
  if program.steps.is_empty() || program.steps.len() > 16 {
8488
8763
  return Err(ApiError(
@@ -8668,9 +8943,11 @@ async fn define_workflow(
8668
8943
 
8669
8944
  async fn start_workflow_run(
8670
8945
  State(state): State<AppState>,
8946
+ Extension(principal): Extension<Principal>,
8671
8947
  Path(name): Path<String>,
8672
8948
  Json(request): Json<StartRun>,
8673
8949
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
8950
+ if !principal.permits("workflows:run") { return Err(ApiError(StatusCode::FORBIDDEN, "workflow execution is not permitted".into())); }
8674
8951
  validate_segment(&name)?;
8675
8952
  let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
8676
8953
  let steps = definition["steps"].as_array().ok_or_else(|| {
@@ -8828,9 +9105,11 @@ async fn define_agent(
8828
9105
 
8829
9106
  async fn start_agent_run(
8830
9107
  State(state): State<AppState>,
9108
+ Extension(principal): Extension<Principal>,
8831
9109
  Path(name): Path<String>,
8832
9110
  Json(request): Json<StartAgentRun>,
8833
9111
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9112
+ if !principal.permits("agents:run") { return Err(ApiError(StatusCode::FORBIDDEN, "agent execution is not permitted".into())); }
8834
9113
  validate_segment(&name)?;
8835
9114
  required_value(&state, "_flow_agents", &name, "agent not found")?;
8836
9115
  if request.goal.trim().is_empty() {
@@ -9198,6 +9477,7 @@ async fn list_records(
9198
9477
  State(state): State<AppState>,
9199
9478
  Path(collection): Path<String>,
9200
9479
  ) -> Result<Json<Vec<RecordResponse>>, ApiError> {
9480
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
9201
9481
  validate_segment(&collection)?;
9202
9482
  let rows = state.db.list_collection(&collection)?;
9203
9483
  Ok(Json(rows.into_iter().map(record_response).collect()))
@@ -9207,6 +9487,7 @@ async fn get_record(
9207
9487
  State(state): State<AppState>,
9208
9488
  Path((collection, id)): Path<(String, String)>,
9209
9489
  ) -> Result<Json<RecordResponse>, ApiError> {
9490
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
9210
9491
  let key = record_key(&collection, &id)?;
9211
9492
  let value = state
9212
9493
  .db
@@ -9443,6 +9724,7 @@ async fn create_record(
9443
9724
  Path(collection): Path<String>,
9444
9725
  Json(mut record): Json<CreateRecord>,
9445
9726
  ) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
9727
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
9446
9728
  validate_segment(&collection)?;
9447
9729
  let id = record
9448
9730
  .id
@@ -9476,6 +9758,7 @@ async fn update_record(
9476
9758
  Path((collection, id)): Path<(String, String)>,
9477
9759
  Json(changes): Json<Map<String, Value>>,
9478
9760
  ) -> Result<Json<RecordResponse>, ApiError> {
9761
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
9479
9762
  let key = record_key(&collection, &id)?;
9480
9763
  let current = state
9481
9764
  .db
@@ -9591,6 +9874,7 @@ async fn delete_record(
9591
9874
  State(state): State<AppState>,
9592
9875
  Path((collection, id)): Path<(String, String)>,
9593
9876
  ) -> Result<StatusCode, ApiError> {
9877
+ if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
9594
9878
  let key = record_key(&collection, &id)?;
9595
9879
  if state.db.get_value(&key)?.is_none() {
9596
9880
  return Err(ApiError(
@@ -9604,14 +9888,29 @@ async fn delete_record(
9604
9888
 
9605
9889
  async fn events(
9606
9890
  State(state): State<AppState>,
9607
- ) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
9891
+ Extension(principal): Extension<Principal>,
9892
+ Query(scope): Query<EventScopeRequest>,
9893
+ ) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
9894
+ if !principal.permits("events:read") { return Err(ApiError(StatusCode::FORBIDDEN, "event access is not permitted".into())); }
9895
+ if let Some(application_id) = scope.application_id { application_scope(&state, &principal.key_id, &application_id, "events:read")?; }
9608
9896
  let mut receiver = state.db.subscribe_changes();
9609
9897
  let namespace = state.namespace.clone();
9898
+ let actor = principal.key_id;
9610
9899
  let stream = async_stream::stream! {
9611
9900
  loop {
9612
9901
  match receiver.recv().await {
9613
9902
  Ok(change) => {
9614
- let data = json!({ "namespace": namespace.as_ref(), "type": "state.changed", "change": change });
9903
+ let resource = change.capability.clone();
9904
+ let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
9905
+ let data = json!({
9906
+ "event_id": uuid::Uuid::new_v4().to_string(),
9907
+ "type": "state.changed",
9908
+ "resource": resource,
9909
+ "resource_id": resource_id,
9910
+ "actor": actor,
9911
+ "timestamp": unix_seconds_i64().max(0) as u64,
9912
+ "payload": { "operation": "mutation", "namespace": namespace.as_ref() }
9913
+ });
9615
9914
  yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
9616
9915
  }
9617
9916
  Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
@@ -9621,7 +9920,7 @@ async fn events(
9621
9920
  }
9622
9921
  }
9623
9922
  };
9624
- Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
9923
+ Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
9625
9924
  }
9626
9925
 
9627
9926
  fn record_response(row: StoredRow) -> RecordResponse {
@@ -9775,10 +10074,10 @@ mod authority_gate_tests {
9775
10074
  "missing scope: state:read",
9776
10075
  r#"{"code":"AUTHORIZATION_DENIED","message":"policy denied"}"#,
9777
10076
  ] {
9778
- assert_eq!(
9779
- api_error_body(StatusCode::FORBIDDEN, internal_message.into()),
9780
- serde_json::json!({ "error": "AUTHORIZATION_DENIED" })
9781
- );
10077
+ let body = api_error_body(StatusCode::FORBIDDEN, internal_message.into());
10078
+ assert_eq!(body["error"], "FORBIDDEN");
10079
+ assert_eq!(body["code"], "FORBIDDEN");
10080
+ assert!(body["request_id"].as_str().unwrap().starts_with("req_"));
9782
10081
  }
9783
10082
  }
9784
10083
  }
@@ -241,6 +241,14 @@ pub fn application_role_grants() -> Vec<RoleGrant> {
241
241
  "deployment:execute".into(),
242
242
  ],
243
243
  },
244
+ RoleGrant {
245
+ role: "actor".into(),
246
+ capabilities: vec![
247
+ "application:read".into(), "application:revision:read".into(),
248
+ "state:read".into(), "state:write".into(), "events:read".into(),
249
+ "workflows:run".into(), "agents:run".into(), "capabilities:run".into(),
250
+ ],
251
+ },
244
252
  RoleGrant {
245
253
  role: "viewer".into(),
246
254
  capabilities: vec![
@@ -271,6 +279,18 @@ impl TenancyStore {
271
279
  records: Arc::new(RwLock::new(records)),
272
280
  })
273
281
  }
282
+ pub fn register_application_actor(&self, user_id: &str, application_id: &str) -> Result<(), String> {
283
+ let mut records = self.records.write().map_err(|_| "tenancy store lock poisoned")?;
284
+ if !records.applications.iter().any(|application| application.id == application_id && application.status == "active") {
285
+ return Err("application not found".into());
286
+ }
287
+ if !records.application_memberships.iter().any(|membership| membership.application_id == application_id && membership.user_id == user_id && membership.status == MembershipStatus::Active) {
288
+ let timestamp = now();
289
+ records.application_memberships.push(ApplicationMembership { application_id: application_id.into(), user_id: user_id.into(), role: "actor".into(), status: MembershipStatus::Active, created_at: timestamp, updated_at: timestamp });
290
+ self.persist(&records)?;
291
+ }
292
+ Ok(())
293
+ }
274
294
  fn persist(&self, records: &PlatformRecords) -> Result<(), String> {
275
295
  if let Some(parent) = self.path.parent() {
276
296
  fs::create_dir_all(parent).map_err(|error| error.to_string())?;
@@ -0,0 +1,57 @@
1
+ # My FeltDB App
2
+
3
+ This small application demonstrates FeltDB's complete programming model:
4
+
5
+ - Authentication and authorization
6
+ - Multi-tenant application state
7
+ - Atomic workflows
8
+ - Agents with bounded capabilities
9
+ - Reactive UI through `@feltdb/core/react`
10
+ - Shared inspection through FeltDB Studio
11
+
12
+ ## Run it
13
+
14
+ ```bash
15
+ npm install
16
+ npm run feltdb:dev
17
+ ```
18
+
19
+ In a second terminal:
20
+
21
+ ```bash
22
+ npm run dev
23
+ ```
24
+
25
+ Then sign up, create a project, invite an existing user, and run an assistant. Open the same state in Studio:
26
+
27
+ ## Choose an AI model
28
+
29
+ `npm run feltdb:ai` uses private browser-local WebLLM by default. Use
30
+ `feltdb ai model set --provider local-webllm --model <model-id>` for a larger
31
+ local model, or configure an OpenAI-compatible model with a credential supplied
32
+ through `--api-key-stdin`. `feltdb ai model show` displays the active choice.
33
+ Hosted credentials remain in the operating-system vault and are never exposed
34
+ to the application or Studio.
35
+
36
+ ```bash
37
+ npm run feltdb:studio
38
+ ```
39
+
40
+ ## How the example fits together
41
+
42
+ - `feltdb.flow` defines state, authorization, workflows, capabilities, and agents.
43
+ - `workflows/` implements project creation, invitation lifecycle, and agent-assisted summary examples.
44
+ - `capabilities/` contains the narrow read and summary operations available to assistants.
45
+ - `agents/` registers the Project and Activity assistants and persists their execution lifecycle.
46
+ - `src/` contains the authenticated React application.
47
+
48
+ These folders make the concepts easy to find; they are not mandatory application architecture. Agents and workflows use the same FeltDB collections as the UI, so they do not bypass `authenticated`, `member`, `owner`, or `self(user)` policies.
49
+
50
+ For managed FeltDB, generate with `--runtime managed`; the model and application remain the same while the runtime configuration changes.
51
+
52
+ Preview and publish the same definition to its configured cloud environment:
53
+
54
+ ```bash
55
+ npm run feltdb:publish -- --dry-run
56
+ npm run feltdb:publish -- --yes
57
+ ```
@@ -0,0 +1,18 @@
1
+ import { db, recordAgentActivity } from '../src/feltdb';
2
+ import { activitySummary, type ActivitySummary } from '../capabilities/activity-summary';
3
+
4
+ export const activityAssistant = db.defineAgent({ name: 'activity-assistant', version: 1, description: 'Explains authorized application activity.', capabilities: [activitySummary.id], goals: ['Summarize recent authorized audit events'], constraints: { maxIterations: 1 } });
5
+
6
+ export async function runActivityAssistant(input: { organization: string; user: string }): Promise<ActivitySummary> {
7
+ const execution = await db.createAgentExecution(activityAssistant, 'Summarize recent activity', []);
8
+ try {
9
+ await db.startAgentExecution(execution);
10
+ const result = await activitySummary.execute() as ActivitySummary;
11
+ await recordAgentActivity({ organization: input.organization, user: input.user, assistant: 'Activity Assistant', subject: execution.executionId });
12
+ await db.completeAgentExecution(execution, execution.executionId);
13
+ return result;
14
+ } catch (error) {
15
+ await db.failAgentExecution(execution, error instanceof Error ? error.message : String(error));
16
+ throw error;
17
+ }
18
+ }
@@ -0,0 +1,19 @@
1
+ import { db, recordAgentActivity } from '../src/feltdb';
2
+ import { projectSearch } from '../capabilities/project-search';
3
+ import { projectSummary, type ProjectSummary } from '../capabilities/project-summary';
4
+
5
+ export const projectAssistant = db.defineAgent({ name: 'project-assistant', version: 1, description: 'Summarizes authorized project state.', capabilities: [projectSearch.id, projectSummary.id], goals: ['Explain project state without widening the actor authorization boundary'], constraints: { maxIterations: 1 } });
6
+
7
+ export async function runProjectAssistant(input: { projectId: string; organization: string; user: string }): Promise<ProjectSummary> {
8
+ const execution = await db.createAgentExecution(projectAssistant, 'Summarize the selected project', [input.projectId]);
9
+ try {
10
+ await db.startAgentExecution(execution);
11
+ const result = await projectSummary.execute(input.projectId) as ProjectSummary;
12
+ await recordAgentActivity({ organization: input.organization, user: input.user, assistant: 'Project Assistant', subject: input.projectId });
13
+ await db.completeAgentExecution(execution, input.projectId);
14
+ return result;
15
+ } catch (error) {
16
+ await db.failAgentExecution(execution, error instanceof Error ? error.message : String(error));
17
+ throw error;
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { Capability } from '@feltdb/core';
2
+ import { auditEvents, type AuditEvent } from '../src/feltdb';
3
+
4
+ export interface ActivitySummary { summary: string; total: number; actions: string[] }
5
+
6
+ export const activitySummary: Capability = {
7
+ id: 'activity-summary',
8
+ name: 'Activity summary',
9
+ async execute(): Promise<ActivitySummary> {
10
+ const events = (await auditEvents.all() as AuditEvent[]).slice(-20);
11
+ const actions = [...new Set(events.map(event => event.action))];
12
+ return { total: events.length, actions, summary: events.length ? `${events.length} recent events across ${actions.length} action types: ${actions.join(', ')}.` : 'No application activity has been recorded yet.' };
13
+ },
14
+ };