Skip to content

Commit 23cb727

Browse files
committed
Merge #TASK-1254 gateway workflow thread type restore
2 parents ba77997 + 54f6211 commit 23cb727

11 files changed

Lines changed: 450 additions & 24 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Task 1254: iOS Restore Guard And Gateway Thread Type Consistency
2+
3+
## Problem
4+
5+
Build 108 includes `3557c4c3` (`Fix iOS home cold-start restore`). That patch
6+
made the iOS persisted last-opened restore path reject resolved workflow-run
7+
destinations and clear polluted restore defaults.
8+
9+
The pending-intent theory is rejected. `pendingThreadId` is queued only by
10+
URL/widget route handling before the gateway is ready, so it is deferred user
11+
navigation, not automatic restore. Cold-start deep links to workflow runs must
12+
continue to open workflow runs, just like warm links.
13+
14+
The current iOS restore path is also narrower than the second draft claimed:
15+
iOS cold-start restore resolves missing recent-list entries through
16+
`/api/threads/{id}`, and the app test below confirms that a workflow summary
17+
fetched from that endpoint is rejected and clears polluted restore state. iOS
18+
does not currently decode the `thread` or `session` summary blocks returned by
19+
`/api/threads/history`.
20+
21+
There is still a real gateway contract bug worth fixing: gateway history detail
22+
summaries can report a stored workflow-run thread as `chat`. That is independent
23+
contract hygiene for clients and tools that do consume the history envelope, not
24+
a proven iOS cold-start root cause.
25+
26+
## Evidence
27+
28+
Rejected iOS candidate:
29+
30+
- `queuePendingThreadLink` is reached through `openMobileRouteFromLink` and
31+
`queuePendingMobileRoute`, which are triggered from `.onOpenURL`;
32+
- the pending route is in memory and not a persisted automatic restore source;
33+
- blocking it would break explicit workflow-run links during cold start.
34+
35+
Confirmed iOS restore guard:
36+
37+
- `GaryxMobileModel.restoreLastOpenedThread(id:)` consults recent in-memory
38+
summaries, then refreshed recent threads, then `client().getThread(threadId:)`
39+
(`/api/threads/{id}`);
40+
- every resolved summary goes through the existing restoration policy before the
41+
app opens a destination;
42+
- `GaryxThreadTranscript` does not decode `/api/threads/history` `thread` or
43+
`session` blocks, so that endpoint is not a current iOS restore input.
44+
45+
Confirmed gateway contract split:
46+
47+
- list summaries derive `thread_type` from stored `thread_kind` with a `chat`
48+
fallback;
49+
- `/api/threads/{id}` metadata currently clones raw metadata and does not
50+
normalize `thread_type`;
51+
- `/api/threads/history` derives both `thread_type` and `session_type` from
52+
`infer_thread_type(thread_id)`, so a normal workflow-run id with stored
53+
`thread_kind: workflow_run` comes back as `chat`;
54+
- the same fallback is inconsistent for legacy ids without `thread_kind`:
55+
metadata/list treat them as `chat`, while history currently treats
56+
`cron::...` as `cron`.
57+
58+
Added deterministic regression coverage:
59+
60+
- App coverage:
61+
`GaryxLastOpenedWorkflowRestoreTests.testColdLaunchRestoreRejectsWorkflowRunFetchedAfterRecentListOmission`
62+
uses the captured workflow fixture, starts with empty in-memory `threads`,
63+
stubs empty recent/pins plus `/api/threads/{id}`, and verifies restore lands on
64+
home and clears polluted defaults. This passes today and documents that the
65+
direct iOS getThread fallback is not the current hole.
66+
- Gateway red test:
67+
`api::tests::test_thread_history_detail_preserves_workflow_thread_type` seeds a
68+
transcript-backed thread with `thread_kind: workflow_run`, calls
69+
`/api/threads/history?thread_id=...`, and currently fails because
70+
`thread.thread_type == "chat"` instead of `"workflow_run"`.
71+
- Gateway fallback red test:
72+
`api::tests::test_thread_history_detail_defaults_missing_thread_kind_to_chat`
73+
seeds a `cron::...` shaped legacy id without `thread_kind` and verifies history
74+
defaults to `chat`, matching metadata/list behavior.
75+
- Metadata red tests:
76+
`routes::tests::thread_metadata_preserves_workflow_thread_type` and
77+
`routes::tests::thread_metadata_defaults_missing_thread_kind_to_chat` verify the
78+
single-thread metadata response uses the same type derivation as history and
79+
list summaries.
80+
81+
## Design
82+
83+
Introduce one gateway helper for thread-summary type derivation:
84+
85+
```rust
86+
thread_kind_from_value(data).unwrap_or_else(|| "chat".to_owned())
87+
```
88+
89+
Use that helper in all JSON summary surfaces that build from a raw thread record:
90+
91+
- `routes::thread_summary(...)`, used by list/create/update style summaries;
92+
- `routes::thread_metadata_response(...)`, used by `/api/threads/{id}`;
93+
- `api::summarize_thread(...)`, used by `/api/threads/history` `thread` and
94+
`session` envelopes.
95+
96+
The helper deliberately has no id-pattern fallback. If old thread metadata lacks
97+
`thread_kind`, every raw-record summary surface reports `chat`. That matches the
98+
existing list fallback and eliminates the history-vs-metadata split for
99+
`cron::...` or group-shaped legacy ids without stored kind data.
100+
101+
The fix is server-side because `thread_kind` is gateway/router state, and
102+
clients should not guess workflow identity from ids or workflow metadata side
103+
channels.
104+
105+
## Why This Is Not A Patch
106+
107+
The fix does not add an iOS-only `if workflow_run` guard and does not special
108+
case one cold-start branch. It makes the gateway surfaces that summarize the
109+
same raw thread record share the same type derivation and fallback.
110+
111+
The iOS policy remains:
112+
113+
- automatic persisted restore may open chat destinations only;
114+
- workflow-run and unresolved destinations clear polluted restore state and land
115+
on home;
116+
- direct user opens, including cold-start deep links/widgets, may open workflow
117+
runs.
118+
119+
This design does not claim that `/api/threads/history` is the current iOS
120+
cold-start failure input. It preserves the existing iOS restore fix with app
121+
coverage and fixes an adjacent gateway contract bug that could mislead history
122+
consumers.
123+
124+
## Validation Plan
125+
126+
Before implementation:
127+
128+
- Red gateway test:
129+
`cargo test -p garyx-gateway test_thread_history_detail_preserves_workflow_thread_type -- --nocapture`
130+
fails with `left: String("chat")`, `right: "workflow_run"`.
131+
- Red gateway fallback test:
132+
`cargo test -p garyx-gateway test_thread_history_detail_defaults_missing_thread_kind_to_chat -- --nocapture`
133+
fails because history reports `cron` for a `cron::...` id without
134+
`thread_kind`.
135+
- Red metadata tests:
136+
`cargo test -p garyx-gateway thread_metadata_preserves_workflow_thread_type -- --nocapture`
137+
and
138+
`cargo test -p garyx-gateway thread_metadata_defaults_missing_thread_kind_to_chat -- --nocapture`
139+
fail because metadata does not normalize `thread_type`.
140+
- App getThread fallback coverage:
141+
`xcodebuild test -project mobile/garyx-mobile/GaryxMobile.xcodeproj -scheme GaryxMobile -destination 'id=<simulator>' -only-testing:GaryxMobileTests/GaryxLastOpenedWorkflowRestoreTests/testColdLaunchRestoreRejectsWorkflowRunFetchedAfterRecentListOmission CODE_SIGNING_ALLOWED=NO`
142+
passes and confirms that branch is already protected.
143+
144+
After implementation:
145+
146+
- Green gateway regressions above.
147+
- Green relevant gateway history tests:
148+
`cargo test -p garyx-gateway thread_history_detail -- --nocapture`.
149+
- Green route metadata summary tests:
150+
`cargo test -p garyx-gateway thread_metadata_ -- --nocapture`.
151+
- Green iOS restore tests:
152+
`xcodebuild test -project mobile/garyx-mobile/GaryxMobile.xcodeproj -scheme GaryxMobile -destination 'id=<simulator>' -only-testing:GaryxMobileTests/GaryxLastOpenedWorkflowRestoreTests CODE_SIGNING_ALLOWED=NO`.
153+
- Green SwiftPM Core restore policy tests:
154+
`swift test --package-path mobile/garyx-mobile --filter GaryxLastOpenedThreadRestorationPolicyTests`.
155+
- Real app-target compile:
156+
`xcodebuild build -project mobile/garyx-mobile/GaryxMobile.xcodeproj -scheme GaryxMobile -destination 'id=<simulator>' CODE_SIGNING_ALLOWED=NO`.

garyx-gateway/src/api.rs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::agent_teams::UpsertAgentTeamRequest;
3434
use crate::custom_agents::UpsertCustomAgentRequest;
3535
use crate::server::AppState;
3636
use crate::thread_runtime::{build_thread_runtime_summary, provider_type_from_key};
37+
use crate::thread_type::thread_summary_type_from_record;
3738
use crate::wikis::UpsertWikiRequest;
3839

3940
// ---------------------------------------------------------------------------
@@ -1228,6 +1229,7 @@ pub(crate) async fn thread_history_for_key(
12281229

12291230
fn summarize_thread(thread_id: &str, data: &Value, messages: &[Value]) -> Value {
12301231
let message_count = history_message_count(data);
1232+
let thread_type = thread_summary_type_from_record(data);
12311233

12321234
let last_user_message = last_message_content(messages, "user");
12331235
let last_assistant_message = last_message_content(messages, "assistant");
@@ -1253,8 +1255,8 @@ fn summarize_thread(thread_id: &str, data: &Value, messages: &[Value]) -> Value
12531255
"created_at": get_value("created_at", "_created_at"),
12541256
"last_user_message": last_user_message,
12551257
"last_assistant_message": last_assistant_message,
1256-
"session_type": infer_thread_type(thread_id),
1257-
"thread_type": infer_thread_type(thread_id),
1258+
"session_type": thread_type.clone(),
1259+
"thread_type": thread_type,
12581260
})
12591261
}
12601262

@@ -1280,16 +1282,6 @@ fn last_message_content(messages: &[Value], role: &str) -> Option<String> {
12801282
None
12811283
}
12821284

1283-
fn infer_thread_type(thread_id: &str) -> &'static str {
1284-
if thread_id.starts_with("cron::") {
1285-
"cron"
1286-
} else if thread_id.contains("::group::") {
1287-
"group"
1288-
} else {
1289-
"chat"
1290-
}
1291-
}
1292-
12931285
fn raw_content_type_name(content: &Value) -> &'static str {
12941286
match content {
12951287
Value::Null => "null",

garyx-gateway/src/api/tests.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,81 @@ async fn test_thread_history_detail_with_thread_id_and_tool_messages() {
11221122
assert_eq!(json["messages"][1]["message"]["content"], "world");
11231123
}
11241124

1125+
#[tokio::test]
1126+
async fn test_thread_history_detail_preserves_workflow_thread_type() {
1127+
let state = test_state();
1128+
seed_transcript_backed_thread(
1129+
&state,
1130+
"thread::workflow-history",
1131+
json!({
1132+
"thread_kind": "workflow_run",
1133+
"workflow_run_id": "thread::workflow-history",
1134+
"workflow_definition_id": "test-workflow",
1135+
"label": "Synthetic workflow run",
1136+
"messages": [
1137+
{"role": "assistant", "content": "workflow output", "timestamp": "2026-03-01T00:00:01Z"}
1138+
]
1139+
}),
1140+
)
1141+
.await;
1142+
1143+
let router = api_router(state);
1144+
1145+
let req = Request::builder()
1146+
.uri("/api/threads/history?thread_id=thread%3A%3Aworkflow-history&limit=10")
1147+
.body(Body::empty())
1148+
.unwrap();
1149+
1150+
let resp = router.oneshot(req).await.unwrap();
1151+
assert_eq!(resp.status(), 200);
1152+
1153+
let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
1154+
.await
1155+
.unwrap();
1156+
let json: Value = serde_json::from_slice(&body).unwrap();
1157+
assert_eq!(json["ok"], true);
1158+
assert_eq!(json["thread"]["thread_type"], "workflow_run");
1159+
assert_eq!(json["session"]["thread_type"], "workflow_run");
1160+
assert_eq!(json["thread"]["session_type"], "workflow_run");
1161+
assert_eq!(json["session"]["session_type"], "workflow_run");
1162+
}
1163+
1164+
#[tokio::test]
1165+
async fn test_thread_history_detail_defaults_missing_thread_kind_to_chat() {
1166+
let state = test_state();
1167+
seed_transcript_backed_thread(
1168+
&state,
1169+
"cron::legacy-history",
1170+
json!({
1171+
"label": "Legacy cron-shaped thread",
1172+
"messages": [
1173+
{"role": "assistant", "content": "legacy output", "timestamp": "2026-03-01T00:00:01Z"}
1174+
]
1175+
}),
1176+
)
1177+
.await;
1178+
1179+
let router = api_router(state);
1180+
1181+
let req = Request::builder()
1182+
.uri("/api/threads/history?thread_id=cron%3A%3Alegacy-history&limit=10")
1183+
.body(Body::empty())
1184+
.unwrap();
1185+
1186+
let resp = router.oneshot(req).await.unwrap();
1187+
assert_eq!(resp.status(), 200);
1188+
1189+
let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
1190+
.await
1191+
.unwrap();
1192+
let json: Value = serde_json::from_slice(&body).unwrap();
1193+
assert_eq!(json["ok"], true);
1194+
assert_eq!(json["thread"]["thread_type"], "chat");
1195+
assert_eq!(json["session"]["thread_type"], "chat");
1196+
assert_eq!(json["thread"]["session_type"], "chat");
1197+
assert_eq!(json["session"]["session_type"], "chat");
1198+
}
1199+
11251200
#[tokio::test]
11261201
async fn test_thread_history_detail_pages_before_global_index() {
11271202
let state = test_state();

garyx-gateway/src/automation.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ use garyx_models::{Principal, TaskNotificationTarget};
1616
use garyx_router::{
1717
CreateTaskInput, FileTaskCounterStore, TaskRuntimeInput, TaskService, WorkspaceMode,
1818
};
19-
use garyx_router::{
20-
history_message_count, is_thread_key, thread_kind_from_value, workspace_dir_from_value,
21-
};
19+
use garyx_router::{history_message_count, is_thread_key, workspace_dir_from_value};
2220
use serde::{Deserialize, Deserializer, Serialize};
2321
use serde_json::{Value, json};
2422
use uuid::Uuid;
@@ -31,6 +29,7 @@ use crate::app_db::{
3129
use crate::cron::{CronJob, JobRunStatus, RunRecord};
3230
use crate::garyx_db::AutomationThreadRunRecord;
3331
use crate::server::AppState;
32+
use crate::thread_type::thread_summary_type_from_record;
3433
use crate::transcript_run_projection::active_run_id_from_transcript_store;
3534

3635
const AUTOMATION_KEY_PREFIX: &str = "automation::";
@@ -1111,7 +1110,7 @@ fn automation_thread_summary(
11111110
json!({
11121111
"id": thread_id,
11131112
"threadId": thread_id,
1114-
"threadType": thread_kind_from_value(data).unwrap_or_else(|| "chat".to_owned()),
1113+
"threadType": thread_summary_type_from_record(data),
11151114
"title": title,
11161115
"label": title,
11171116
"workspaceDir": workspace_dir_from_value(data),

garyx-gateway/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod tasks;
4040
pub mod thread_logs;
4141
mod thread_meta_projection;
4242
mod thread_runtime;
43+
mod thread_type;
4344
mod tool_image;
4445
mod transcript_run_projection;
4546
mod wikis;

garyx-gateway/src/recent_thread_projection.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use async_trait::async_trait;
22
use garyx_bridge::MultiProviderBridge;
33
use garyx_router::{
44
ThreadStore, ThreadStoreError, ThreadTranscriptStore, history_message_count,
5-
is_hidden_thread_value, is_thread_key, thread_kind_from_value, workspace_dir_from_value,
5+
is_hidden_thread_value, is_thread_key, workspace_dir_from_value,
66
};
77
use serde_json::Value;
88
use std::sync::{Arc, Weak};
@@ -11,6 +11,7 @@ use tracing::warn;
1111
use crate::garyx_db::{GaryxDbService, RecentThreadDraft};
1212
use crate::task_projection::task_projection_draft_from_thread_data;
1313
use crate::thread_meta_projection::thread_meta_projection_from_thread_data_with_active_run;
14+
use crate::thread_type::thread_summary_type_from_record;
1415
use crate::transcript_run_projection::active_run_id_from_transcript_store;
1516

1617
pub(crate) const RECENT_THREAD_MISSING_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
@@ -429,7 +430,7 @@ fn recent_thread_draft_from_thread_data_with_active_run(
429430
.unwrap_or("New Thread")
430431
.to_owned();
431432
let workspace_dir = workspace_dir_from_value(data);
432-
let thread_type = thread_kind_from_value(data).unwrap_or_else(|| "chat".to_owned());
433+
let thread_type = thread_summary_type_from_record(data);
433434
let provider_type = data
434435
.get("provider_type")
435436
.and_then(Value::as_str)

garyx-gateway/src/routes.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ use garyx_models::routing::{DELIVERY_TARGET_TYPE_CHAT_ID, DELIVERY_TARGET_TYPE_O
2424
use garyx_router::{
2525
ChannelBinding, KnownChannelEndpoint, THREAD_TRANSCRIPT_REPLAY_CAP, ThreadEnsureOptions,
2626
ThreadTranscriptRecord, WorkspaceMode, bindings_from_value, detach_endpoint_from_thread,
27-
history_message_count, is_thread_key, thread_kind_from_value, update_thread_record,
28-
workspace_dir_from_value, workspace_git_status as router_workspace_git_status,
27+
history_message_count, is_thread_key, update_thread_record, workspace_dir_from_value,
28+
workspace_git_status as router_workspace_git_status,
2929
};
3030
use serde::Deserialize;
3131
use serde_json::{Map, Value, json};
@@ -48,6 +48,7 @@ use crate::server::AppState;
4848
use crate::skills::SkillStoreError;
4949
use crate::thread_meta_projection::backfill_thread_meta_projection_if_incomplete;
5050
use crate::thread_runtime::build_thread_runtime_summary;
51+
use crate::thread_type::thread_summary_type_from_record;
5152
use crate::workspace_mode::{
5253
ensure_implicit_thread_workspace_for_config, worktree_base_dir_for_config,
5354
};
@@ -1372,7 +1373,7 @@ pub(crate) fn thread_summary(thread_id: &str, data: &Value) -> Value {
13721373
json!({
13731374
"thread_id": thread_id,
13741375
"thread_key": thread_id,
1375-
"thread_type": thread_kind_from_value(data).unwrap_or_else(|| "chat".to_owned()),
1376+
"thread_type": thread_summary_type_from_record(data),
13761377
"label": label,
13771378
"workspace_dir": workspace_dir,
13781379
"channel_bindings": channel_bindings,
@@ -1537,6 +1538,10 @@ async fn thread_metadata_response(state: &Arc<AppState>, thread_id: &str, data:
15371538
.or_insert_with(|| Value::String(thread_id.to_owned()));
15381539
obj.entry("thread_key".to_owned())
15391540
.or_insert_with(|| Value::String(thread_id.to_owned()));
1541+
obj.insert(
1542+
"thread_type".to_owned(),
1543+
Value::String(thread_summary_type_from_record(data)),
1544+
);
15401545
if let Some(block) = team_block {
15411546
obj.insert("team".to_owned(), block);
15421547
}

0 commit comments

Comments
 (0)