Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/coral-app/tests/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod health_service_tests;
mod oidc_login_tests;
#[path = "grpc/private_access_tests.rs"]
mod private_access_tests;
#[path = "grpc/record_locations_tests.rs"]
mod record_locations_tests;
#[path = "grpc/server_lifecycle_tests.rs"]
mod server_lifecycle_tests;
#[path = "grpc/session_auth.rs"]
Expand Down
179 changes: 179 additions & 0 deletions crates/coral-app/tests/grpc/record_locations_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Recorded authored source locations survive a server restart and reach Reef
//! through the server's own private gRPC listener.
#![allow(
clippy::unwrap_used,
reason = "fixture setup fails the restart test if it cannot complete"
)]

use std::{fs, sync::Arc};

use coral_app::ServerBuilder;
use coral_grpc::coral::management::v1::{
self as wire, activity_service_client::ActivityServiceClient,
activity_source_location::Document,
};
use coral_workspace::{
AuthenticatedPrincipal, ChangeContext, TraceId, WorkspaceDisplayName, WorkspaceModule,
local_organization_owner,
};
use tempfile::TempDir;

const SAVED_FUNCTION: &str =
"export default function forecast(city: string) {\n console.info(city);\n}";
const PROGRAM: &str = "console.log(\"l\");\nawait coral.emit({ step: 1 });\ncoral.functions.report.forecast(\"Oslo\");";

/// Accepts one Run in the server's database and waits for it to succeed.
async fn record_run(
database: Arc<coral_db::Database>,
) -> (coral_workspace::WorkspaceId, coral_run::RunId) {
let owner = local_organization_owner();
let context = ChangeContext::new(owner.clone(), 1, TraceId::current_or_new());
let workspaces = WorkspaceModule::new(database.clone());
let workspace = workspaces
.create(&context, WorkspaceDisplayName::parse("Locations").unwrap())
.await
.unwrap();
let workspace = workspace.id().clone();
let principal = AuthenticatedPrincipal::user(owner);
coral_functions::SavedFunctionModule::new(
database.clone(),
workspaces.clone(),
coral_run::IsolateLimits::default(),
)
.unwrap()
.save(
&coral_functions::SavedFunctionChangeContext::new(
principal.clone(),
1,
TraceId::current_or_new(),
),
&workspace,
coral_functions::SavedFunctionSave::new(
coral_functions::SavedFunctionPath::parse("report.forecast").unwrap(),
coral_functions::SavedFunctionDescription::parse("Forecast one city").unwrap(),
coral_functions::SavedFunctionSource::parse(SAVED_FUNCTION).unwrap(),
),
)
.await
.unwrap();
let providers = coral_provider::ProviderModule::new(
coral_outbound::DestinationPolicy::default(),
database.clone(),
workspaces.clone(),
coral_catalog::BuiltinCatalog::running().unwrap(),
);
let runs = coral_run::RunModule::new(
coral_outbound::DestinationPolicy::default(),
database,
workspaces,
providers,
coral_run::RuntimeLimits::default(),
)
.unwrap();
let run = runs
.start(
&principal,
&workspace,
coral_run::RunSubmission::new(
coral_run::ProgramSource::parse(PROGRAM).unwrap(),
coral_run::RunTimeout::from_millis(60_000).unwrap(),
),
)
.await
.unwrap()
.run_id()
.clone();
let settled = runs
.observe(
&principal,
&workspace,
&run,
&coral_run::RunObservation::initial(std::time::Duration::from_secs(30)).unwrap(),
)
.await
.unwrap();
assert_eq!(settled.state(), coral_run::RunState::Succeeded);
(workspace, run)
}

fn reef<T>(message: T) -> tonic::Request<T> {
let mut request = tonic::Request::new(message);
request
.metadata_mut()
.insert("coral-audience", "reef".parse().unwrap());
request
}

#[tokio::test]
async fn recorded_locations_survive_a_restart_on_the_private_listener() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("config.toml"),
"[listeners.web]\npublic_url = 'http://127.0.0.1:5173'\n",
)
.unwrap();

let first = ServerBuilder::new()
.with_config_dir(temp.path())
.start()
.await
.unwrap();
let (workspace, run) = record_run(first.database()).await;
first.shutdown().await.unwrap();

let second = ServerBuilder::new()
.with_config_dir(temp.path())
.start()
.await
.unwrap();
let mut client = ActivityServiceClient::connect(second.endpoint_uri().to_owned())
.await
.unwrap();
let run_name = format!("workspaces/{workspace}/runs/{run}");
let detail = client
.inspect_run(reef(wire::InspectRunRequest {
name: run_name,
page_size: 100,
..Default::default()
}))
.await
.unwrap()
.into_inner();
let [snapshot] = detail.linked_functions.as_slice() else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The restart assertions validate only internal consistency: the SavedFunction document in each event is built from the same response's snapshot.saved_function, so a lost or placeholder snapshot identity on both sides would still pass. Since the PR's contract is preserving exact snapshot identity across restart, pin the returned snapshot against the recorded source: assert_eq!(snapshot.source, SAVED_FUNCTION); (and optionally check snapshot.path == "coral.functions.report.forecast").

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At crates/coral-app/tests/grpc/record_locations_tests.rs, line 142:

<comment>The restart assertions validate only internal consistency: the SavedFunction document in each event is built from the same response's `snapshot.saved_function`, so a lost or placeholder snapshot identity on both sides would still pass. Since the PR's contract is preserving exact snapshot identity across restart, pin the returned snapshot against the recorded source: `assert_eq!(snapshot.source, SAVED_FUNCTION);` (and optionally check `snapshot.path == "coral.functions.report.forecast"`).</comment>

<file context>
@@ -0,0 +1,179 @@
+        .await
+        .unwrap()
+        .into_inner();
+    let [snapshot] = detail.linked_functions.as_slice() else {
+        panic!("one linked snapshot")
+    };
</file context>

panic!("one linked snapshot")
};
let at = |document: Document, line, column| {
Some(wire::ActivitySourceLocation {
document: Some(document),
line,
column,
})
};
let program = |line, column| at(Document::Program(wire::ActivityEmpty {}), line, column);
let in_snapshot = |line, column| {
at(
Document::SavedFunction(snapshot.saved_function.clone()),
line,
column,
)
};
let outputs = detail
.events
.iter()
.filter(|event| {
matches!(
event.payload,
Some(
wire::activity_run_event::Payload::ProgramEmitted(_)
| wire::activity_run_event::Payload::ProgramLogged(_)
)
)
})
.map(|event| event.source_location.clone())
.collect::<Vec<_>>();
assert_eq!(
outputs,
vec![program(1, 9), program(2, 13), in_snapshot(2, 13)]
);
second.shutdown().await.unwrap();
}
39 changes: 39 additions & 0 deletions crates/coral-db/migrations/0037_record_source_locations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Where accepted authored source made each Operation call, Emission and Log.
-- Earlier records keep NULL: their location is unavailable. A Saved Function
-- location names the Run's retained snapshot, never the current definition.
ALTER TABLE operation_invocations ADD COLUMN source_document TEXT;
ALTER TABLE operation_invocations ADD COLUMN source_saved_function_id TEXT;
ALTER TABLE operation_invocations ADD COLUMN source_line BIGINT;
ALTER TABLE operation_invocations ADD COLUMN source_column BIGINT
CONSTRAINT operation_invocations_source_location_consistent
CHECK (COALESCE(
(source_document IS NULL AND source_saved_function_id IS NULL
AND source_line IS NULL AND source_column IS NULL)
OR
(source_document = 'program' AND source_saved_function_id IS NULL
AND source_line > 0 AND source_column > 0)
OR
(source_document = 'saved_function' AND LENGTH(source_saved_function_id) > 0
AND source_line > 0 AND source_column > 0),
FALSE
));

ALTER TABLE program_run_events ADD COLUMN source_document TEXT;
ALTER TABLE program_run_events ADD COLUMN source_saved_function_id TEXT;
ALTER TABLE program_run_events ADD COLUMN source_line BIGINT;
ALTER TABLE program_run_events ADD COLUMN source_column BIGINT
CONSTRAINT program_run_events_source_location_consistent
CHECK (COALESCE(
(source_document IS NULL AND source_saved_function_id IS NULL
AND source_line IS NULL AND source_column IS NULL)
OR
(event_type IN ('program.emitted', 'program.logged')
AND (
(source_document = 'program' AND source_saved_function_id IS NULL
AND source_line > 0 AND source_column > 0)
OR
(source_document = 'saved_function' AND LENGTH(source_saved_function_id) > 0
AND source_line > 0 AND source_column > 0)
)),
FALSE
));
35 changes: 33 additions & 2 deletions crates/coral-grpc/proto/coral/management/v1/activity.proto
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ message ActivityInvocation {
optional int64 dispatched_at_unix_nanos = 15;
// workspaces/{workspace}/agents/{agent} if an Agent made the call; empty for a User.
string agent = 16;
// Where the Run's accepted source made the call. Absent if not recorded.
ActivitySourceLocation source_location = 18;
}
// Selects one Run. List filters do not apply.
message InspectRunRequest {
Expand All @@ -279,16 +281,39 @@ message InspectRunRequest {
}
// Source of a Saved Function as it was when the Run was accepted.
message ActivitySourceSnapshot {
// Saved Function name at the time. It may have changed or been deleted since.
// workspaces/{workspace}/savedFunctions/{saved_function} at the time. Unique among
// the Run's snapshots. The Saved Function may have changed or been deleted since.
string saved_function = 1;
// Path the Program called it by.
string path = 2;
// TypeScript source as accepted.
string source = 3;
}
// Where the Run's accepted source made one record: an Operation call, an
// emitted value or a log message. Absent when Coral did not record one,
// including for every record made before Coral recorded them.
message ActivitySourceLocation {
// The accepted source that holds the call. Absent for a kind unknown to this
// client: treat the location as unavailable.
oneof document {
// The Run's submitted Program source.
ActivityEmpty program = 1;
// workspaces/{workspace}/savedFunctions/{saved_function}: the saved_function of
// exactly one of the Run's linked_functions snapshots.
string saved_function = 2;
}
// Line number, starting at 1. Lines end at every ECMAScript line terminator:
// CRLF, CR, LF, U+2028 and U+2029, also inside strings and comments.
uint32 line = 3;
// Column number, starting at 1, in UTF-16 code units. For a call such as
// console.log(...), the start of the called name. A binding passed to a
// builtin, as in items.forEach(console.log), records the builtin's call.
uint32 column = 4;
}
// One stack frame in code the author wrote.
message ActivityErrorFrame {
// Program or Saved Function name.
// "Program" or "Saved Function coral.functions.{path}". Unlike
// ActivitySourceLocation, it names a Saved Function by path, not by snapshot.
string source = 1;
// Line number, starting at 1.
uint32 line = 2;
Expand Down Expand Up @@ -451,6 +476,9 @@ message ActivityRunEvent {
// The Run ended.
ActivityRunEnded ended = 9;
}
// Where the Run's accepted source emitted or logged. Absent for other
// kinds, or if not recorded.
ActivitySourceLocation source_location = 10;
}
// Link to an approval or connection request. It may no longer be pending.
message ActivityRequestReference {
Expand Down Expand Up @@ -805,6 +833,9 @@ message ActivityEventSummary {
string kind = 2;
// When it happened.
int64 occurred_at_unix_nanos = 3;
// Where the Run's accepted source emitted or logged. Absent for other
// kinds, or if not recorded.
ActivitySourceLocation source_location = 5;
}
// One child row of a Run, in order.
message ActivityRunEntry {
Expand Down
51 changes: 49 additions & 2 deletions crates/coral-grpc/src/generated/coral.management.v1.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading