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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ memorylake actor create --custom-id user-001 --display-name "Alice Chen" \
[--metadata '{"tier":"premium"}']

memorylake actor list [--type HUMAN|ASSISTANT] [--name FUZZY] [--tags vip,cn] [--page-size N]
memorylake actor me
memorylake actor get <id> [--by-custom-id]
memorylake actor update <id> [--display-name NAME] [--description D] \
[--tags vip,cn | --clear-tags] [--metadata JSON]
Expand All @@ -145,6 +146,17 @@ memorylake actor list --workspace <id> # bindings, not actors
`--custom-id` is unique account-wide. On update, `--metadata` **replaces** the
stored value rather than merging into it.

`actor me` reports the actor your API key represents, so a script never has to
guess which of several human actors is yours:

```bash
memorylake actor me | jq -r .id
```

**It is not necessarily bound to a workspace** — the actor comes with the
account, while joining a workspace is a separate step. To know who can write in
one, use `actor list --workspace <id>`; to put this actor there, `actor bind`.

Tags are short labels for grouping and filtering: up to 20 per actor, each 1-64
characters, no commas. Matching is exact and case-sensitive — `VIP` and `vip` are
two different tags. Prefer them over `--metadata` for anything you want to filter
Expand Down
13 changes: 11 additions & 2 deletions crates/cli/src/commands/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand, ValueEnum};
use memorylake_core::api::actors::{
ActorType, CreateActorRequest, ListActorsParams, UpdateActorRequest, bind_actor, create_actor,
delete_actor, get_actor, get_actor_by_custom_id, list_actors, list_workspace_actors,
unbind_actor, update_actor,
delete_actor, get_actor, get_actor_by_custom_id, get_my_actor, list_actors,
list_workspace_actors, unbind_actor, update_actor,
};
use memorylake_core::{Client, Paths, ResolveOverrides, resolve};
use serde_json::{Map, Value};
Expand Down Expand Up @@ -111,6 +111,11 @@ pub enum ActorCommand {
#[arg(long, value_parser = parse_metadata_object)]
metadata: Option<Map<String, Value>>,
},
/// Show the actor representing the current API key.
///
/// A result does not mean that actor is bound to any workspace -- it often
/// is not. Use `actor list --workspace <id>` to see who can write there.
Me,
/// Get a single actor by id.
Get {
/// Actor id (or custom_id when `--by-custom-id` is set).
Expand Down Expand Up @@ -214,6 +219,10 @@ pub fn run(command: ActorCommand, profile: Option<String>, base_url: Option<Stri
.context("create actor")?;
println!("{}", serde_json::to_string_pretty(&data)?);
}
ActorCommand::Me => {
let data = get_my_actor(&client).context("get my actor")?;
println!("{}", serde_json::to_string_pretty(&data)?);
}
ActorCommand::Get { id, by_custom_id } => {
let data = if by_custom_id {
get_actor_by_custom_id(&client, &id).context("get actor by custom_id")?
Expand Down
33 changes: 33 additions & 0 deletions crates/cli/tests/actor/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,39 @@ fn actor_lifecycle_end_to_end() {
let _ = fs::remove_dir_all(&home);
}

/// `actor me` names a real actor, not an alias.
///
/// Creates nothing, so it needs no guard.
#[test]
fn actor_me_returns_a_resolvable_actor() {
let api_key = require_api_key();
let home = temp_home();
login_default(&home, &api_key);

let me = json_of(&home, &["actor", "me"]);
let id = me["id"].as_str().expect("me returns an id").to_string();
assert!(!id.is_empty());

// The point of the cross-check: prove the id addresses the same actor
// everywhere else, rather than being an alias only this endpoint knows.
let fetched = json_of(&home, &["actor", "get", id.as_str()]);
assert_eq!(fetched["id"], Value::String(id.clone()));
assert_eq!(
fetched["display_name"], me["display_name"],
"`actor me` and `actor get` must describe the same actor"
);

// Documented and measured: a result says nothing about workspace
// membership. Asserting the shape of `tags` here keeps `me` honest about
// returning the same record `get` does.
assert!(
me["tags"].is_array(),
"tags must be present as an array: {me}"
);

let _ = fs::remove_dir_all(&home);
}

/// Tags, end to end: what the server stores, how it filters, and what each of
/// the two tag flags does.
///
Expand Down
33 changes: 32 additions & 1 deletion crates/cli/tests/actor/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,37 @@ fn tags_must_not_be_empty_or_have_doubled_commas() {
let _ = fs::remove_dir_all(&home);
}

#[test]
fn me_takes_no_arguments() {
let home = temp_home();
// A positional or a flag must be a usage error, not silently ignored --
// `actor me act-1` most likely means the caller wanted `actor get act-1`.
for args in [
["actor", "me", "act-1"].as_slice(),
["actor", "me", "--workspace", "ws-1"].as_slice(),
["actor", "me", "--by-custom-id"].as_slice(),
] {
let err = assert_failure(&run(&home, args), args);
assert!(
err.contains("unexpected argument") || err.contains("Usage"),
"unexpected error output for {args:?}: {err}"
);
}
let _ = fs::remove_dir_all(&home);
}

#[test]
fn me_without_login_fails_like_the_others() {
let home = temp_home();
let args = ["actor", "me"];
let err = assert_failure(&run(&home, &args), &args);
assert!(
err.contains("not logged in") || err.contains("resolve API credentials"),
"unexpected error output: {err}"
);
let _ = fs::remove_dir_all(&home);
}

#[test]
fn metadata_must_be_valid_json() {
let home = temp_home();
Expand Down Expand Up @@ -178,7 +209,7 @@ fn help_lists_actor_subcommands() {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert!(output.status.success(), "actor --help failed: {stdout}");
for subcommand in [
"list", "create", "get", "update", "delete", "bind", "unbind",
"list", "create", "get", "me", "update", "delete", "bind", "unbind",
] {
assert!(
stdout.contains(subcommand),
Expand Down
58 changes: 57 additions & 1 deletion crates/cli/tests/actor/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! prove the endpoints exist; these prove the CLI calls the documented ones.

use crate::common::assert_success;
use crate::common::stub::{exchange, request_line};
use crate::common::stub::{exchange, exchange_sequence, request_body, request_line};

const EMPTY_PAGE: &str = r#"{"success":true,"data":{"items":[],"continuation_token":null}}"#;
const ONE_ACTOR: &str = r#"{"success":true,"data":{"id":"act-1","custom_id":"user-1","actor_type":"HUMAN","display_name":"Alice"}}"#;
Expand Down Expand Up @@ -175,6 +175,62 @@ fn create_sends_tags_as_a_json_array() {
);
}

#[test]
fn me_calls_the_defaults_endpoint_exactly_once() {
// One request, not a list-then-filter: the whole point of the endpoint is
// that the server names the actor, so a fan-out would defeat it.
let (requests, output) = exchange_sequence(&[ONE_ACTOR], &["actor", "me"]);
assert_success(&output, &["actor", "me"]);
assert_eq!(requests.len(), 1, "{requests:?}");

let line = request_line(&requests[0]);
assert_eq!(line.split(' ').next(), Some("GET"), "{line}");
assert_eq!(
line.split(' ').nth(1),
Some("/api/v3/defaults/my-actor"),
"no query string is sent: {line}"
);
assert!(
request_body(&requests[0]).is_empty(),
"a GET must carry no body: {:?}",
request_body(&requests[0])
);
}

#[test]
fn me_prints_the_actor_as_json_like_get_does() {
let (_, output) = exchange(
r#"{"success":true,"data":{"id":"act-1","custom_id":"user::abc","actor_type":"HUMAN","display_name":"Ada","created_by":"user::abc"}}"#,
&["actor", "me"],
);
let stdout = assert_success(&output, &["actor", "me"]);
assert!(stdout.contains("\"id\": \"act-1\""), "{stdout}");
assert!(stdout.contains("\"custom_id\": \"user::abc\""), "{stdout}");
// `created_by` is sent by the API; dropping it would make it invisible.
assert!(stdout.contains("\"created_by\": \"user::abc\""), "{stdout}");
}

#[test]
fn me_on_an_older_deployment_says_so_rather_than_not_found() {
// A 404 here can only mean the route is absent -- every key has an actor --
// so a bare "NOT_FOUND" would read as "you have no actor", the opposite of
// the truth.
let (_, output) = exchange(
r#"{"success":false,"message":"No static resource api/v3/defaults/my-actor.","error_code":"NOT_FOUND"}"#,
&["actor", "me"],
);
assert!(!output.status.success(), "a 404 must fail the command");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("deployment") && stderr.contains("/api/v3/defaults/my-actor"),
"the error must blame the deployment and name the endpoint: {stderr}"
);
assert!(
stderr.contains("No static resource"),
"the server's own message must survive for diagnosis: {stderr}"
);
}

#[test]
fn get_uses_the_id_path_and_by_custom_id_query() {
let (request, output) = exchange(ONE_ACTOR, &["actor", "get", "act-1"]);
Expand Down
140 changes: 140 additions & 0 deletions crates/core/src/api/actors/me.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! The caller's own actor (`GET /api/v3/defaults/my-actor`).

use crate::client::Client;
use crate::error::{Error, Result};

use super::types::Actor;

/// Path of the endpoint. Takes no caller-supplied segment, so unlike
/// `actor_path` it needs no encoding.
const MY_ACTOR_PATH: &str = "/api/v3/defaults/my-actor";

/// Wire value of the API error code returned when the route does not exist.
const NOT_FOUND_CODE: &str = "NOT_FOUND";

/// Fetch the actor representing the API key making the call.
///
/// Every key has one, so a successful call always yields an actor and there is
/// no "no default actor" outcome to handle.
///
/// **A 200 does not mean the actor is bound to any workspace.** It frequently
/// is not: the actor is created with the account, while workspace membership is
/// a separate, explicit act. Callers that need an actor to write memories with
/// must still check `list_workspace_actors`, and must not assume this actor
/// appears there.
///
/// A `NOT_FOUND` is reported as an unsupported deployment rather than passed
/// through. Since the endpoint has no "missing" case of its own, a 404 can only
/// mean the server predates it — and a bare "NOT_FOUND" would otherwise read as
/// "you have no actor", which is the opposite of the truth.
pub fn get_my_actor(client: &Client) -> Result<Actor> {
client
.get_data(MY_ACTOR_PATH, &[])
.map_err(|err| match err {
Error::Api {
code: Some(code),
message,
} if code == NOT_FOUND_CODE => Error::Api {
message: format!(
"this MemoryLake deployment has no `{MY_ACTOR_PATH}` endpoint, so the \
calling key's actor cannot be looked up; upgrade the server or name \
the actor explicitly\n{message}"
),
code: Some(code),
},
other => other,
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{json_ok, one_shot_server};

/// Captured from production on 2026-08-21, verbatim.
const REAL_RESPONSE: &str = r#"{"success":true,"data":{"id":"actor-fd25f63e2bc0441f80b0e10fca8335fb","tags":[],"metadata":{},"custom_id":"user::e7f4fbd1149f44109589aece3310b0eb","actor_type":"HUMAN","display_name":"1594834522","created_at":"2026-07-07T05:11:09.50066Z","created_by":"user::e7f4fbd1149f44109589aece3310b0eb","updated_at":"2026-07-07T05:11:09.50066Z"}}"#;

fn client_for(base_url: &str) -> Client {
Client::new(base_url, "sk-test").expect("build client")
}

#[test]
fn decodes_a_real_response() {
let (base_url, handle) = one_shot_server(json_ok(REAL_RESPONSE));
let actor = get_my_actor(&client_for(&base_url)).expect("decode real response");

assert_eq!(actor.id, "actor-fd25f63e2bc0441f80b0e10fca8335fb");
assert_eq!(
actor.custom_id.as_deref(),
Some("user::e7f4fbd1149f44109589aece3310b0eb")
);
assert_eq!(actor.actor_type, super::super::types::ActorType::Human);
assert_eq!(actor.display_name, "1594834522");
assert!(actor.tags.is_empty());

let request = handle.join().expect("server thread");
assert!(
request.head.starts_with(&format!("GET {MY_ACTOR_PATH} ")),
"{}",
request.head
);
}

#[test]
fn preserves_an_actor_type_this_build_does_not_know() {
// The crate decodes leniently everywhere else; this endpoint must not
// be the one place a server-side addition breaks the command.
let body = r#"{"success":true,"data":{"id":"act-1","actor_type":"SUPERVISOR","display_name":"Ada"}}"#;
let (base_url, handle) = one_shot_server(json_ok(body));
let actor = get_my_actor(&client_for(&base_url)).expect("decode unknown actor_type");

assert_eq!(
actor.actor_type,
super::super::types::ActorType::Other("SUPERVISOR".to_string())
);
let _ = handle.join();
}

#[test]
fn reports_a_missing_route_as_an_unsupported_deployment() {
let body = r#"{"success":false,"message":"No static resource api/v3/defaults/my-actor.","error_code":"NOT_FOUND"}"#;
let response = format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let (base_url, handle) = one_shot_server(response);

let err = get_my_actor(&client_for(&base_url)).expect_err("404 must be an error");
let rendered = err.to_string();
assert!(
rendered.contains("has no") && rendered.contains(MY_ACTOR_PATH),
"the message must name the endpoint and blame the deployment: {rendered}"
);
// The server's own words are kept so the failure stays diagnosable.
assert!(rendered.contains("No static resource"), "{rendered}");
assert!(
matches!(err, Error::Api { code: Some(ref code), .. } if code == NOT_FOUND_CODE),
"the machine-readable code must survive the rewording"
);
let _ = handle.join();
}

#[test]
fn other_api_errors_pass_through_untouched() {
let body = r#"{"success":false,"message":"denied","error_code":"ACCESS_DENIED"}"#;
let response = format!(
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let (base_url, handle) = one_shot_server(response);

let err = get_my_actor(&client_for(&base_url)).expect_err("403 must be an error");
let rendered = err.to_string();
assert!(rendered.contains("denied"), "{rendered}");
assert!(
!rendered.contains("deployment"),
"only NOT_FOUND is reworded: {rendered}"
);
let _ = handle.join();
}
}
2 changes: 2 additions & 0 deletions crates/core/src/api/actors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod create;
mod delete;
mod get;
mod list;
mod me;
mod types;
mod update;

Expand All @@ -13,5 +14,6 @@ pub use create::{CreateActorRequest, create_actor};
pub use delete::delete_actor;
pub use get::{get_actor, get_actor_by_custom_id};
pub use list::{ActorList, ListActorsParams, list_actors};
pub use me::get_my_actor;
pub use types::{ACTOR_TYPE_ASSISTANT, ACTOR_TYPE_HUMAN, Actor, ActorBinding, ActorType};
pub use update::{UpdateActorRequest, update_actor};
7 changes: 7 additions & 0 deletions crates/core/src/api/actors/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ pub struct Actor {
/// Creation timestamp (ISO 8601).
#[serde(default)]
pub created_at: Option<String>,
/// Id of the user or agent that created this actor, in the API's subject
/// form (`user::…`, `sa::…`).
///
/// Absent for actors created before the server began recording it, so this
/// stays optional rather than being treated as always present.
#[serde(default)]
pub created_by: Option<String>,
/// Last update timestamp (ISO 8601).
#[serde(default)]
pub updated_at: Option<String>,
Expand Down
Loading