-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): consolidate temporal-context retrieval GET and CLI #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - `tepp-temporal-context-get get` mints LineageWeave `GET /v1/temporal-context/{idempotency_key}` onto spawned `tepp-loopback` TCP (ADR 0084). Metric-free `inference_status=temporal_association_only` receipts. Event labels and actor lists never appear. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open collection GET or cancel lineages. Not GAP-010 Figma/export, not persistence. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - `GET /v1/temporal-context/{idempotency_key}` returns one accepted LineageWeave temporal-context identity on `tepp-loopback` (ADR 0083). Metric-free `inference_status=temporal_association_only`. Event labels and actor lists never appear. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open collection GET or cancel lineages. Not GAP-010 Figma/export, not persistence. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,8 +19,10 @@ use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; | |
| use crate::{ | ||
| AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, | ||
| ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, | ||
| ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, | ||
| ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, | ||
| TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS, TemporalContextRequest, TemporalContextRetrieved, | ||
| build_temporal_context, project_history_projection, requests_are_idempotent_matches, | ||
| temporal_context_retrieval_path_id, | ||
| }; | ||
|
|
||
| const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; | ||
|
|
@@ -41,6 +43,7 @@ pub struct AnalysisRunLiveService { | |
| next_request_serial: u64, | ||
| accepted_runs: HashMap<String, (AnalysisRunRequest, AnalysisRunAccepted)>, | ||
| accepted_project_histories: HashMap<String, (ProjectHistoryRequest, ProjectHistoryProjection)>, | ||
| accepted_temporal_contexts: HashMap<String, TemporalContextRetrieved>, | ||
| } | ||
|
|
||
| impl Default for AnalysisRunLiveService { | ||
|
|
@@ -60,6 +63,7 @@ impl AnalysisRunLiveService { | |
| next_request_serial: 1, | ||
| accepted_runs: HashMap::new(), | ||
| accepted_project_histories: HashMap::new(), | ||
| accepted_temporal_contexts: HashMap::new(), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -143,33 +147,85 @@ impl AnalysisRunLiveService { | |
| let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?; | ||
| let mut lines = header_block.split("\r\n"); | ||
| let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; | ||
| let headers = parse_headers(&mut lines)?; | ||
| if method == "GET" { | ||
| return self.get_temporal_context(path, &headers, body); | ||
|
Comment on lines
+151
to
+152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| if method != "POST" | ||
| || (path != NARUON_ANALYSIS_RUN_PATH | ||
| && path != TEMPORAL_CONTEXT_PATH | ||
| && path != PROJECT_HISTORY_PATH) | ||
| { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| let headers = parse_headers(&mut lines)?; | ||
| let consumer = require_headers( | ||
| &headers, | ||
| self.bound_addr, | ||
| path == NARUON_ANALYSIS_RUN_PATH || path == PROJECT_HISTORY_PATH, | ||
| )?; | ||
| if path == TEMPORAL_CONTEXT_PATH { | ||
| if consumer != LINEAGEWEAVE_CONSUMER_CODE { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| let context_request = TemporalContextRequest::from_json(body)?; | ||
| let response = build_temporal_context(&context_request)?; | ||
| return Ok(json_response(200, "OK", response.to_json()?)); | ||
| return self.accept_temporal_context(consumer, &headers, body); | ||
| } | ||
| if path == PROJECT_HISTORY_PATH { | ||
| return self.accept_project_history(consumer, &headers, body); | ||
| } | ||
| self.accept_analysis_run(consumer, &headers, body) | ||
| } | ||
|
|
||
| fn accept_temporal_context( | ||
| &mut self, | ||
| consumer: &str, | ||
| headers: &HashMap<String, String>, | ||
| body: &str, | ||
| ) -> Result<NaruonLiveResponse, ApiError> { | ||
| if consumer != LINEAGEWEAVE_CONSUMER_CODE { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| let context_request = TemporalContextRequest::from_json(body)?; | ||
| if let Some(idempotency_key) = headers.get("idempotency-key") { | ||
| let item = TemporalContextRetrieved::new( | ||
| idempotency_key.clone(), | ||
| context_request.knowledge_cutoff.clone(), | ||
| TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS, | ||
| )?; | ||
| let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); | ||
| if let Some(stored) = self.accepted_temporal_contexts.get(&replay_key) { | ||
| if stored.knowledge_cutoff != item.knowledge_cutoff { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
|
Comment on lines
+192
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Changed retries reuse accepted keys Reusing a key with the same cutoff but different events passes the Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } else { | ||
| self.accepted_temporal_contexts.insert(replay_key, item); | ||
| } | ||
|
Comment on lines
+196
to
+198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Failed submissions remain retrievable A large valid POST can fail during response serialization after Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| let response = build_temporal_context(&context_request)?; | ||
| Ok(json_response(200, "OK", response.to_json()?)) | ||
| } | ||
|
|
||
| fn get_temporal_context( | ||
| &self, | ||
| path: &str, | ||
| headers: &HashMap<String, String>, | ||
| body: &str, | ||
| ) -> Result<NaruonLiveResponse, ApiError> { | ||
| if !body.is_empty() { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| if headers.contains_key("idempotency-key") { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| let idempotency_key = temporal_context_retrieval_path_id(path)?; | ||
| let consumer = require_headers(headers, self.bound_addr, false)?; | ||
| if consumer != LINEAGEWEAVE_CONSUMER_CODE { | ||
| return Err(ApiError::InvalidWirePayload); | ||
| } | ||
| let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); | ||
| let stored = self | ||
| .accepted_temporal_contexts | ||
| .get(&replay_key) | ||
| .ok_or(ApiError::InvalidWirePayload)?; | ||
| Ok(json_response(200, "OK", stored.to_json()?)) | ||
| } | ||
|
|
||
| fn accept_analysis_run( | ||
| &mut self, | ||
| consumer: &str, | ||
|
|
@@ -320,6 +376,7 @@ mod tests { | |
| DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, | ||
| NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, | ||
| NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, | ||
| TemporalContextRetrieved, | ||
| }; | ||
|
|
||
| fn sample_run() -> AnalysisRunRequest { | ||
|
|
@@ -734,6 +791,58 @@ mod tests { | |
| assert_eq!(replay.body, accepted.body); | ||
| } | ||
|
|
||
| #[test] | ||
| fn temporal_context_get_by_id_is_metric_free_and_fail_closed() { | ||
| let temporal_body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; | ||
| let mut service = AnalysisRunLiveService::new(); | ||
| let posted = format!( | ||
| "POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: idem-a\r\ncontent-length: {}\r\n\r\n{temporal_body}", | ||
| temporal_body.len() | ||
| ); | ||
| assert_eq!(service.handle_http_request(&posted).status_code, 200); | ||
| let got = service.handle_http_request( | ||
| &format!( | ||
| "GET {TEMPORAL_CONTEXT_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" | ||
| ), | ||
| ); | ||
| assert_eq!(got.status_code, 200, "{}", got.body); | ||
| assert!(!got.body.contains("event_label")); | ||
| assert!(!got.body.contains("rmse")); | ||
| let row = TemporalContextRetrieved::from_json(&got.body).expect("row"); | ||
| assert_eq!(row.idempotency_key, "idem-a"); | ||
| assert_eq!(row.inference_status, "temporal_association_only"); | ||
| assert_eq!( | ||
| service | ||
| .handle_http_request( | ||
| &format!( | ||
| "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" | ||
| ) | ||
| ) | ||
| .status_code, | ||
| 400 | ||
| ); | ||
| assert_eq!( | ||
| service | ||
| .handle_http_request( | ||
| &format!( | ||
| "GET {TEMPORAL_CONTEXT_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" | ||
| ) | ||
| ) | ||
| .status_code, | ||
| 400 | ||
| ); | ||
| assert_eq!( | ||
| service | ||
| .handle_http_request( | ||
| &format!( | ||
| "GET {TEMPORAL_CONTEXT_PATH}/missing HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" | ||
| ) | ||
| ) | ||
| .status_code, | ||
| 400 | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parser_helpers_cover_framing_header_and_limit_edges() { | ||
| assert_eq!( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| //! Operator CLI for loopback `LineageWeave` temporal-context GET-by-id. | ||
|
|
||
| use std::io::{self, IsTerminal}; | ||
| use std::process::ExitCode; | ||
|
|
||
| use tepp_api::{ | ||
| execute_temporal_context_retrieval_cli, read_temporal_context_retrieval_cli_stdin, | ||
| render_temporal_context_retrieval_cli_stdout, ApiError, TemporalContextRetrievalCliInvocation, | ||
| }; | ||
|
|
||
| fn main() -> ExitCode { | ||
| match run() { | ||
| Ok(()) => ExitCode::SUCCESS, | ||
| Err(_) => ExitCode::FAILURE, | ||
| } | ||
| } | ||
|
|
||
| fn run() -> Result<(), ApiError> { | ||
| let args: Vec<String> = std::env::args().skip(1).collect(); | ||
| let body = read_temporal_context_retrieval_cli_stdin(io::stdin().is_terminal(), io::stdin())?; | ||
| let invocation = TemporalContextRetrievalCliInvocation::from_args(&args, body)?; | ||
| let response = execute_temporal_context_retrieval_cli(&invocation)?; | ||
| let stdout = render_temporal_context_retrieval_cli_stdout(&invocation, &response)?; | ||
| println!("{stdout}"); | ||
| if response.status_code == 200 { | ||
| Ok(()) | ||
| } else { | ||
| Err(ApiError::InvalidWirePayload) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Registry lifetime matches declared scope
The registry disappears when
tepp-loopbackrestarts. ADR 0083 explicitly leaves persistence to GAP-003B, so this is not a defect here.Was this helpful? React with 👍 or 👎 to provide feedback.