feat: discover subject session history

This commit is contained in:
2026-10-02 16:41:29 +09:00
parent 43e3b42055
commit ee6aa9de41
34 changed files with 5167 additions and 12 deletions
+107
View File
@@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn session_public_search(
&self,
_request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public search is not supported by this Runtime".to_string(),
})
}
fn session_public_read(
&self,
_request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
message: "Session public read is not supported by this Runtime".to_string(),
})
}
fn observation_source(
&self,
_worker_id: &str,
@@ -2073,6 +2093,36 @@ impl RuntimeRegistry {
})
}
pub fn session_public_search(
&self,
runtime_id: &str,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
self.runtime(runtime_id)?
.session_public_search(request)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "session_public_search_failed".to_string(),
message,
})
}
pub fn session_public_read(
&self,
runtime_id: &str,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
self.runtime(runtime_id)?
.session_public_read(request)
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "session_public_read_failed".to_string(),
message,
})
}
pub fn observation_source(
&self,
worker: &RuntimeWorkerRef,
@@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string())
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
if request.workspace_id != self.workspace_id {
return Err(
"Session public search Workspace does not match embedded Runtime".to_string(),
);
}
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
self.runtime
.session_public_search_scoped(&scope, &request)
.map_err(|error| error.to_string())
}
fn session_public_read(
&self,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
if request.workspace_id != self.workspace_id {
return Err(
"Session public read Workspace does not match embedded Runtime".to_string(),
);
}
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
self.runtime
.session_public_read_scoped(&scope, &request)
.map_err(|error| error.to_string())
}
fn observation_source(
&self,
worker_id: &str,
@@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
return "workers:delete";
}
if path.starts_with("/v1/session-public/") && method == "POST" {
return "workers:read";
}
if path.starts_with("/v1/workers/") && method == "GET" {
return "workers:read";
}
@@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message)
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.session_public_search(request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn session_public_read(
&self,
request: runtime_api::SessionPublicReadRequest,
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.session_public_read(request).await },
)
.map_err(|diagnostic| diagnostic.message)
}
fn observation_source(
&self,
worker_id: &str,
@@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives (
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_session_archive_observe_grants (
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
CREATE INDEX worker_session_archive_observe_grants_controller
ON worker_session_archive_observe_grants(
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
+131
View File
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
pub operation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSessionArchiveRecord {
pub archive_id: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub session_id: String,
pub checksum_sha256: String,
pub content_bytes: u64,
pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String,
pub committed_at: String,
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerOrphanDiagnostic {
pub diagnostic_id: String,
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
let observe_grants = {
let mut statement = tx.prepare(
"SELECT grant_id, controller_runtime_id, controller_worker_id,
permissions_json, created_at
FROM worker_control_grants
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
AND subject_worker_id = ?3 AND revoked_at IS NULL",
)?;
statement
.query_map(
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
},
)?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
let permissions: Vec<String> = serde_json::from_str(&permissions)
.map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?;
if permissions.iter().any(|permission| permission == "observe") {
tx.execute(
"INSERT OR IGNORE INTO worker_session_archive_observe_grants
(workspace_id,archive_id,controller_runtime_id,controller_worker_id,
subject_runtime_id,subject_worker_id,source_grant_id,granted_at)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![
workspace_id,
a.archive_id,
controller_runtime_id,
controller_worker_id,
plan.worker.runtime_id,
plan.worker.worker_id,
grant_id,
created_at
],
)?;
}
}
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
match plan.diagnostics_disposition {
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
@@ -592,6 +652,77 @@ impl SqliteWorkspaceStore {
) -> crate::Result<Option<WorkerTombstone>> {
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
}
pub fn worker_session_archive(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
session_id: &str,
) -> crate::Result<Option<WorkerSessionArchiveRecord>> {
self.with_conn(|connection| {
connection
.query_row(
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
policy_revision, operation_id, committed_at, expires_at
FROM worker_session_archives
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND session_id = ?4",
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
session_id
],
|row| {
Ok(WorkerSessionArchiveRecord {
archive_id: row.get(0)?,
workspace_id: workspace_id.to_string(),
worker: worker.clone(),
session_id: session_id.to_string(),
checksum_sha256: row.get(1)?,
content_bytes: row.get::<_, i64>(2)? as u64,
policy_id: row.get(3)?,
policy_revision: row.get::<_, i64>(4)? as u64,
operation_id: row.get(5)?,
committed_at: row.get(6)?,
expires_at: row.get(7)?,
})
},
)
.optional()
.map_err(StoreError::from)
})
}
pub fn worker_session_archive_observable_by(
&self,
workspace_id: &str,
archive_id: &str,
controller: &RuntimeWorkerRef,
subject: &RuntimeWorkerRef,
) -> crate::Result<bool> {
self.with_conn(|connection| {
connection
.query_row(
"SELECT EXISTS(
SELECT 1 FROM worker_session_archive_observe_grants
WHERE workspace_id = ?1 AND archive_id = ?2
AND controller_runtime_id = ?3 AND controller_worker_id = ?4
AND subject_runtime_id = ?5 AND subject_worker_id = ?6
)",
params![
workspace_id,
archive_id,
controller.runtime_id,
controller.worker_id,
subject.runtime_id,
subject.worker_id
],
|row| row.get(0),
)
.map_err(StoreError::from)
})
}
}
fn orphan_diagnostic(
File diff suppressed because it is too large Load Diff
+56 -3
View File
@@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 75;
const LATEST_SCHEMA_VERSION: i64 = 76;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str =
"ordered Worker projection subscriptions without revision reconciliation";
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
"Runtime-scoped durable Worker catalog identity";
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
"preserve explicit observe grants for committed Worker Session archives";
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
@@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
apply: migrate_runtime_scoped_worker_identity_v74_to_v75,
},
Migration {
version: 76,
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
apply: migrate_archive_observe_grants_v75_to_v76,
},
];
#[derive(Clone, Copy)]
@@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul
}
}
fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
if current != 75 {
return Err(Error::Store(format!(
"expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants (
workspace_id TEXT NOT NULL,
archive_id TEXT NOT NULL,
controller_runtime_id TEXT NOT NULL,
controller_worker_id TEXT NOT NULL,
subject_runtime_id TEXT NOT NULL,
subject_worker_id TEXT NOT NULL,
source_grant_id TEXT NOT NULL,
granted_at TEXT NOT NULL,
PRIMARY KEY (
workspace_id, archive_id,
controller_runtime_id, controller_worker_id,
source_grant_id
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller
ON worker_session_archive_observe_grants(
workspace_id, controller_runtime_id, controller_worker_id, archive_id
);
"#,
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(include_str!("latest_schema.sql"))?;
@@ -16617,6 +16665,10 @@ mod tests {
version: 75,
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 76,
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
},
]
);
@@ -16699,6 +16751,7 @@ mod tests {
75,
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
),
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -16985,7 +17038,7 @@ mod tests {
.collect::<Vec<_>>(),
vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
73, 74, 75
73, 74, 75, 76
]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -16994,7 +17047,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
+175
View File
@@ -175,6 +175,12 @@ impl SubjectSessionAttribution {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectSessionAttributionPage {
pub items: Vec<SubjectSessionAttribution>,
pub has_more: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevisionProposalIntent {
@@ -1208,6 +1214,93 @@ impl SubjektivStore {
})
}
/// Reads one bounded immutable attribution page without materializing the
/// subject's complete history. `snapshot_at` fixes the initial discovery
/// boundary; later Host attributions require a fresh traversal.
pub fn subject_session_attribution_page(
&self,
subject_id: &str,
session_id: Option<&str>,
snapshot_at: &str,
after: Option<(&str, &str)>,
limit: usize,
) -> Result<SubjectSessionAttributionPage> {
validate_label("subject id", subject_id)?;
validate_label("attribution snapshot", snapshot_at)?;
if let Some(session_id) = session_id {
validate_label("session id", session_id)?;
}
if let Some((attributed_at, session_id)) = after {
validate_label("attribution cursor timestamp", attributed_at)?;
validate_label("attribution cursor session id", session_id)?;
}
if !(1..=100).contains(&limit) {
return Err(SubjektivError::InvalidRecord(
"subject Session attribution page limit must be between 1 and 100".to_string(),
));
}
let query_limit = limit.saturating_add(1);
self.database.try_with_connection(|connection| {
let raw = match (session_id, after) {
(Some(session_id), _) => {
let mut statement = connection.prepare(
"SELECT record_json FROM subject_session_attributions
WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3
ORDER BY attributed_at DESC, session_id ASC
LIMIT ?4",
)?;
let rows = statement.query_map(
params![subject_id, session_id, snapshot_at, query_limit as i64],
|row| row.get::<_, String>(0),
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
}
(None, Some((after_at, after_session_id))) => {
let mut statement = connection.prepare(
"SELECT record_json FROM subject_session_attributions
WHERE subject_id = ?1 AND attributed_at <= ?2
AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4))
ORDER BY attributed_at DESC, session_id ASC
LIMIT ?5",
)?;
let rows = statement.query_map(
params![
subject_id,
snapshot_at,
after_at,
after_session_id,
query_limit as i64
],
|row| row.get::<_, String>(0),
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
}
(None, None) => {
let mut statement = connection.prepare(
"SELECT record_json FROM subject_session_attributions
WHERE subject_id = ?1 AND attributed_at <= ?2
ORDER BY attributed_at DESC, session_id ASC
LIMIT ?3",
)?;
let rows = statement.query_map(
params![subject_id, snapshot_at, query_limit as i64],
|row| row.get::<_, String>(0),
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
}
};
let mut items = raw
.into_iter()
.map(|raw| parse_session_attribution(&raw))
.collect::<Result<Vec<_>>>()?;
let has_more = items.len() > limit;
if has_more {
items.truncate(limit);
}
Ok(SubjectSessionAttributionPage { items, has_more })
})
}
pub fn staging_candidate(
&self,
subject_id: &str,
@@ -3678,6 +3771,88 @@ mod tests {
assert!(store.session_attribution("session-2").unwrap().is_none());
}
#[test]
fn attribution_pages_are_bounded_descending_and_snapshot_fixed() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
for (session, at) in [
("session-1", "2026-09-28T10:00:00.000Z"),
("session-2", "2026-09-28T11:00:00.000Z"),
("session-3", "2026-09-28T11:00:00.000Z"),
] {
store
.record_session_attribution(attribution(
&subject.id,
"runtime-1",
"worker-1",
session,
at,
))
.unwrap();
}
let first = store
.subject_session_attribution_page(
&subject.id,
None,
"2026-09-28T11:30:00.000Z",
None,
2,
)
.unwrap();
assert_eq!(
first
.items
.iter()
.map(|item| item.session_id.as_str())
.collect::<Vec<_>>(),
vec!["session-2", "session-3"]
);
assert!(first.has_more);
store
.record_session_attribution(attribution(
&subject.id,
"runtime-2",
"worker-2",
"session-later",
"2026-09-28T12:00:00.000Z",
))
.unwrap();
let last = first.items.last().unwrap();
let second = store
.subject_session_attribution_page(
&subject.id,
None,
"2026-09-28T11:30:00.000Z",
Some((&last.attributed_at, &last.session_id)),
2,
)
.unwrap();
assert_eq!(second.items.len(), 1);
assert_eq!(second.items[0].session_id, "session-1");
assert!(!second.has_more);
assert!(
second
.items
.iter()
.all(|item| item.session_id != "session-later")
);
let exact = store
.subject_session_attribution_page(
&subject.id,
Some("session-1"),
"2026-09-28T11:30:00.000Z",
None,
20,
)
.unwrap();
assert_eq!(exact.items.len(), 1);
assert!(!exact.has_more);
}
#[test]
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
let temp = tempfile::tempdir().unwrap();
@@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_registry_projection_removals",
"worker_removal_operations",
"worker_retention_audit_events",
"worker_session_archive_observe_grants",
"worker_session_archives",
"worker_singleton_owners",
"worker_tombstones",