Skip to content

Commit 5652996

Browse files
bakeyclaude
andcommitted
feat(sources): feishu pack real-workspace verification — tasks/wiki reconciled, has_more_path termination
Phase-4 live pass against a real Feishu workspace (OAuth user token, gateway v1.3.3), which caught two contract-level defects the mock suite could not see: - Real task rows carry NO `completed` boolean — completion is `status` (todo|done) plus `completed_at`, whose "not completed" spelling is the digit string "0" (epoch-zero sentinel, documented at the column). The draft `completed` column and its Inexact pushdown are gone: the mapping would have re-trimmed every row to zero — the always-NULL class the live pass exists to catch. `status` is mapped instead; the action's `completed` input stays deliberately unmapped. - Feishu's wiki space listing answers its FINAL page with `has_more: false` beside a NON-empty `page_token` ("0||…"), so null-token cursor termination refetches a finished scan and dies as PaginationLoop. New engine field `has_more_path` on the Cursor strategy (optional, backward-compatible): when declared it is the authoritative termination signal; `hasMore: true` without a usable token is PaginationCursorInvalid, a non-boolean signal is the new PaginationHasMoreInvalid — never a silent truncation. Declared on all six feishu tables; 4 engine tests + a live-shape e2e pin both sides. Live evidence (skardi-server against the real gateway): registration passes the fingerprint gate on LIVE discovery; tasks 9 rows (6 todo / 3 done) with EVERY mapped column non-NULL somewhere (due on 2 rows); wiki_spaces completes on the real hasMore:false + non-empty-token page; wiki_nodes decodes epoch-second strings to real instants. tasks / wiki_spaces / wiki_nodes fixtures are now redacted live captures; wiki_nodes gains creator/url, wiki_spaces gains open_sharing. chats / messages / chat_members remain drafts: Feishu gates im APIs on the app's bot capability (232025) independently of OAuth scopes; their live pass lands when the test app has it enabled. Full suite: cargo test -p skardi --lib → 827 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 718375e commit 5652996

9 files changed

Lines changed: 593 additions & 98 deletions

File tree

crates/skardi/src/sources/providers/open_connector/error.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,19 @@ pub enum OpenConnectorError {
257257
found: String,
258258
},
259259

260+
/// The declared has-more signal was absent or not a boolean. Guessing
261+
/// either way could truncate or loop the scan; carries the JSON *kind*
262+
/// only, never the value.
263+
#[error(
264+
"Open Connector pagination has-more signal at '{path}' on page {page} is {found}, \
265+
expected a boolean"
266+
)]
267+
PaginationHasMoreInvalid {
268+
path: String,
269+
page: usize,
270+
found: String,
271+
},
272+
260273
/// Pagination failed to advance: the gateway returned an already-seen
261274
/// cursor, which would loop the scan forever.
262275
#[error(

crates/skardi/src/sources/providers/open_connector/packs/feishu.rs

Lines changed: 115 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@
2121
//!
2222
//! - **Cursor pagination on every table** (`pageToken` in, top-level
2323
//! `$.pageToken` out; page size 100 — im/tasks maximum — or 50, the
24-
//! wiki maximum). Termination is complete on the null-token spelling
25-
//! the executors normalize to; `hasMore` is redundant with the null
26-
//! token and deliberately unused. A repeated token fails as
27-
//! `PaginationLoop`, a non-string token as `PaginationCursorInvalid` —
28-
//! never a silent truncation. No feishu executor filters fetched pages,
29-
//! so the cursor's termination signal is undamaged.
24+
//! wiki maximum), with `$.hasMore` declared as the AUTHORITATIVE
25+
//! termination signal (`has_more_path`). That is a live-verification
26+
//! correction, not a nicety: Feishu's wiki space listing answers its
27+
//! final page with `has_more: false` beside a NON-empty `page_token`
28+
//! (`"0||…"`, captured 2026-08-04), so the null-token spelling alone
29+
//! would refetch a finished scan and die as `PaginationLoop`. With the
30+
//! signal declared, `hasMore: true` without a usable token is contract
31+
//! drift (`PaginationCursorInvalid`), a non-boolean signal is
32+
//! `PaginationHasMoreInvalid` — never a silent truncation. No feishu
33+
//! executor filters fetched pages, so the signals are undamaged.
3034
//! - **Orderings are pinned for cursor stability**: `chats` and
3135
//! `messages` pin `sortType: ByCreateTimeAsc` because the API's
3236
//! activity-ordered default reshuffles rows mid-scan (skips and
@@ -41,9 +45,15 @@
4145
//! `endTime` is deliberately unmapped: it is EXCLUSIVE, and flooring
4246
//! an upper bound drops rows.
4347
//! - **`tasks` pins `type: my_tasks`** (the executor default, declared
44-
//! rather than inherited) and omits `completed` by default — Feishu's
45-
//! state=all. The `completed` equality pushdown stays `Inexact` like
46-
//! every enum-ish push.
48+
//! rather than inherited) and omits the action's `completed` input —
49+
//! Feishu's state=all. Live rows (2026-08-04) carry NO `completed`
50+
//! boolean: completion is `status` (todo|done) plus `completed_at`,
51+
//! whose "not completed" spelling is the digit string "0" (epoch-zero
52+
//! sentinel, documented at the column). The `completed` input is
53+
//! therefore deliberately unmapped — a `status` string cannot render
54+
//! into a boolean input without a value transform the filter engine
55+
//! deliberately lacks, and a mapping on an always-NULL draft column
56+
//! would have re-trimmed every row to zero.
4757
//! - **`wiki_nodes` lists ONE level** (children of `parentNodeToken`,
4858
//! space root when omitted) — the action's own shape; full-tree
4959
//! traversal is client-side recursion, documented in the pack doc.
@@ -55,11 +65,13 @@
5565
//! item schema LOOSE (`additionalProperties: true`, zero declared
5666
//! properties) — so ALL mapped columns ride passthrough outside the
5767
//! fingerprint gate, and the coverage-gap pin records that honestly.
58-
//! Column truth is therefore settled ONLY by real rows: the sets
59-
//! below are drafted from Feishu's API reference and MUST be
60-
//! reconciled against a live workspace (skill phase 4) before this
61-
//! pack is submission-ready; the fixtures are synthetic drafts to be
62-
//! re-derived as redacted live captures in that phase.
68+
//! Column truth is therefore settled ONLY by real rows. `tasks`,
69+
//! `wiki_spaces`, and `wiki_nodes` are reconciled against a live
70+
//! workspace (2026-08-04; their fixtures are redacted live captures).
71+
//! `chats` / `messages` / `chat_members` remain API-reference drafts
72+
//! with synthetic fixtures: Feishu gates the im APIs on the app's BOT
73+
//! capability (error 232025) independently of OAuth scopes, and the
74+
//! live pass completes once the test app has it enabled.
6375
6476
use std::sync::OnceLock;
6577

@@ -210,35 +222,46 @@ mod tests {
210222
}
211223

212224
#[test]
213-
fn tasks_fixture_converts_booleans_and_empty_lists() {
225+
fn tasks_fixture_converts_status_and_the_epoch_zero_sentinel() {
226+
// Redacted live capture (2026-08-04): completion is `status`
227+
// (todo|done) plus `completed_at` — there is NO `completed`
228+
// boolean on the wire, which is why that draft column is gone.
214229
let batch = convert_fixture(table("tasks"), include_str!("fixtures/feishu/tasks.json"));
215-
assert_eq!(batch.num_rows(), 2);
216-
assert!(!boolean(&batch, "completed").value(0));
217-
assert!(boolean(&batch, "completed").value(1));
218-
assert!(utf8(&batch, "summary").is_null(1), "JSON null → SQL NULL");
219-
// JSON null due is SQL NULL; the empty members array is the JSON
220-
// text `[]`, not NULL — an empty list is data.
221-
assert!(utf8(&batch, "due").is_null(1));
222-
assert_eq!(utf8(&batch, "members").value(1), "[]");
230+
assert_eq!(batch.num_rows(), 3);
231+
assert_eq!(utf8(&batch, "status").value(0), "todo");
232+
assert_eq!(utf8(&batch, "status").value(2), "done");
233+
// Feishu spells "not completed" as "0" — the epoch-zero sentinel
234+
// the YAML documents; a done task carries a real instant.
235+
assert_eq!(millis(&batch, "completed_at").value(0), 0);
236+
assert_eq!(millis(&batch, "completed_at").value(2), 1_780_322_529_638);
237+
assert_eq!(millis(&batch, "created_at").value(0), 1_775_202_331_888);
238+
// A present JSON null `due` is SQL NULL; members survive as
239+
// opaque JSON.
240+
assert!(utf8(&batch, "due").is_null(0));
241+
let members: Value =
242+
serde_json::from_str(utf8(&batch, "members").value(0)).expect("valid JSON");
243+
assert!(members.is_array());
223244
}
224245

225246
#[test]
226247
fn wiki_nodes_fixture_converts_epoch_second_strings() {
248+
// Redacted live capture (2026-08-04).
227249
let batch = convert_fixture(
228250
table("wiki_nodes"),
229251
include_str!("fixtures/feishu/wiki_nodes.json"),
230252
);
231-
assert_eq!(batch.num_rows(), 2);
253+
assert_eq!(batch.num_rows(), 3);
232254
// Epoch-SECONDS digit strings scale to millis.
233255
assert_eq!(
234256
millis(&batch, "obj_create_time").value(0),
235-
1_735_689_600_000
257+
1_636_114_726_000
236258
);
237-
assert!(millis(&batch, "obj_create_time").is_null(1));
238-
assert!(boolean(&batch, "has_child").value(0));
259+
assert!(!boolean(&batch, "has_child").value(0));
239260
// Feishu spells "no parent" as the empty string — that is data,
240261
// preserved verbatim, not coerced to NULL.
241262
assert_eq!(utf8(&batch, "parent_node_token").value(0), "");
263+
assert!(utf8(&batch, "creator").value(0).starts_with("ou_"));
264+
assert!(utf8(&batch, "url").value(0).starts_with("https://"));
242265
}
243266

244267
#[test]
@@ -255,9 +278,13 @@ mod tests {
255278
table("wiki_spaces"),
256279
include_str!("fixtures/feishu/wiki_spaces.json"),
257280
);
258-
assert_eq!(spaces.num_rows(), 2);
259-
assert_eq!(utf8(&spaces, "space_id").value(0), "70001");
260-
assert!(utf8(&spaces, "description").is_null(1));
281+
// Redacted live capture (2026-08-04): note the envelope carries a
282+
// NON-empty pageToken beside hasMore:false — the shape the
283+
// has_more_path termination exists for, pinned end to end by
284+
// `wiki_spaces_terminate_on_has_more_false_despite_a_token`.
285+
assert_eq!(spaces.num_rows(), 1);
286+
assert_eq!(utf8(&spaces, "space_id").value(0), "700000038");
287+
assert_eq!(utf8(&spaces, "open_sharing").value(0), "closed");
261288
}
262289

263290
#[test]
@@ -583,7 +610,11 @@ bindings:
583610
}
584611

585612
#[tokio::test]
586-
async fn tasks_pin_their_population_and_push_completed_equality() {
613+
async fn tasks_pin_their_population_and_keep_status_predicates_local() {
614+
// The action HAS a `completed` boolean input, but real rows carry
615+
// `status` (todo|done) and no boolean — so nothing is mapped and a
616+
// status predicate must run entirely in DataFusion while the
617+
// request carries exactly the pins and pagination, nothing else.
587618
let gateway = MockGateway::start(|req| {
588619
if req.method == "GET" && req.path == "/v1/health" {
589620
return MockResponse::ok("{}");
@@ -594,8 +625,8 @@ bindings:
594625
if req.method == "POST" && req.path == "/v1/actions/feishu.list_tasks" {
595626
return MockResponse::ok(&envelope_ok(
596627
&json!({"items": [
597-
{"guid": "t-1", "completed": true},
598-
{"guid": "t-2", "completed": false}],
628+
{"guid": "t-1", "status": "done"},
629+
{"guid": "t-2", "status": "todo"}],
599630
"pageToken": null, "hasMore": false})
600631
.to_string(),
601632
));
@@ -606,11 +637,7 @@ bindings:
606637
let (gateway, ctx) =
607638
setup_with_gateway(gateway, "SKARDI_TEST_OC_FEISHU_TASKS", "tasks").await;
608639

609-
let batches = collect(
610-
&ctx,
611-
"SELECT guid FROM saas.ws.tasks WHERE completed = true",
612-
)
613-
.await;
640+
let batches = collect(&ctx, "SELECT guid FROM saas.ws.tasks WHERE status = 'done'").await;
614641
let guids: Vec<String> = batches
615642
.iter()
616643
.flat_map(|b| {
@@ -624,11 +651,59 @@ bindings:
624651
(0..col.len()).map(move |i| col.value(i).to_string())
625652
})
626653
.collect();
627-
assert_eq!(guids, vec!["t-1"], "Inexact push re-trimmed locally");
654+
assert_eq!(guids, vec!["t-1"], "status filtering happened locally");
628655

629656
let input = &execute_inputs(&gateway)[0];
630657
assert_eq!(input["type"], "my_tasks", "population pin");
631-
assert_eq!(input["completed"], true, "boolean equality pushed");
658+
let mut keys: Vec<&str> = input
659+
.as_object()
660+
.expect("input object")
661+
.keys()
662+
.map(String::as_str)
663+
.collect();
664+
keys.sort_unstable();
665+
assert_eq!(
666+
keys,
667+
vec!["pageSize", "type"],
668+
"the status predicate is not pushed and no `completed` input is invented"
669+
);
670+
}
671+
672+
#[tokio::test]
673+
async fn wiki_spaces_terminate_on_has_more_false_despite_a_token() {
674+
// The live wire shape this pack's has_more_path exists for
675+
// (captured 2026-08-04): Feishu wiki answers the FINAL page with
676+
// hasMore:false but a NON-empty pageToken ("0||…"). Null-token
677+
// termination alone would refetch that token and fail as a
678+
// PaginationLoop; the has-more signal must end the scan after one
679+
// request.
680+
let gateway = MockGateway::start(|req| {
681+
if req.method == "GET" && req.path == "/v1/health" {
682+
return MockResponse::ok("{}");
683+
}
684+
if req.method == "GET" && req.path.starts_with("/v1/actions/") {
685+
return feishu_discovery(&req.path);
686+
}
687+
if req.method == "POST" && req.path == "/v1/actions/feishu.list_wiki_spaces" {
688+
return MockResponse::ok(&envelope_ok(
689+
&json!({"items": [{"space_id": "700000038", "name": "s"}],
690+
"pageToken": "0||7000000000000000001", "hasMore": false})
691+
.to_string(),
692+
));
693+
}
694+
MockResponse::new(404, "{}")
695+
})
696+
.await;
697+
let (gateway, ctx) =
698+
setup_with_gateway(gateway, "SKARDI_TEST_OC_FEISHU_WIKI_HM", "wiki_spaces").await;
699+
700+
let batches = collect(&ctx, "SELECT space_id FROM saas.ws.wiki_spaces").await;
701+
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
702+
assert_eq!(
703+
execute_inputs(&gateway).len(),
704+
1,
705+
"hasMore:false ended the scan; the non-empty token was never refetched"
706+
);
632707
}
633708

634709
#[tokio::test]

crates/skardi/src/sources/providers/open_connector/packs/feishu.yaml

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@
99
# (`pageSize` / `pageToken` / `chatId`; `additionalProperties: false`).
1010
# Design rationale lives in the module docs of packs/feishu.rs.
1111
#
12-
# DRAFT COLUMN STATUS: item schemas are declared LOOSE by the gateway
13-
# (empty properties + additionalProperties), so every column below rides
14-
# passthrough and none is protected by the fingerprint gate. Column sets
15-
# are drafted from Feishu's API reference and MUST be reconciled against
16-
# real rows (skill phase 4) before this pack is submission-ready.
12+
# COLUMN STATUS: item schemas are declared LOOSE by the gateway (empty
13+
# properties + additionalProperties), so every column below rides
14+
# passthrough and none is protected by the fingerprint gate — real rows
15+
# are the only column truth. tasks / wiki_spaces / wiki_nodes are
16+
# reconciled against a live workspace (2026-08-04): tasks carry
17+
# status/completed_at (no `completed` boolean), wiki spaces answer the
18+
# final page with a NON-empty pageToken and hasMore:false (the reason
19+
# has_more_path exists). chats / messages / chat_members column sets
20+
# remain drafted from Feishu's API reference pending the workspace app's
21+
# bot capability (Feishu 232025 gates the im APIs on it).
1722
kind: pack
1823
pack: feishu
1924
version: 1
@@ -29,6 +34,7 @@ tables:
2934
next_cursor_path: "$.pageToken"
3035
page_size_input: pageSize
3136
page_size: 100
37+
has_more_path: "$.hasMore"
3238
# ByCreateTimeAsc is the STABLE ordering. The API default
3339
# (ByActiveTimeDesc) reshuffles rows whenever a chat receives a
3440
# message mid-scan, which can skip or duplicate chats across cursor
@@ -55,6 +61,7 @@ tables:
5561
next_cursor_path: "$.pageToken"
5662
page_size_input: pageSize
5763
page_size: 100
64+
has_more_path: "$.hasMore"
5865
# containerIdType is pinned to `chat`: this table IS chat history
5966
# (thread listing is a different action, list_thread_messages).
6067
# ByCreateTimeAsc for the same cursor-stability reason as chats —
@@ -103,6 +110,7 @@ tables:
103110
next_cursor_path: "$.pageToken"
104111
page_size_input: pageSize
105112
page_size: 100
113+
has_more_path: "$.hasMore"
106114
resources:
107115
required: [chatId]
108116
columns:
@@ -121,23 +129,28 @@ tables:
121129
next_cursor_path: "$.pageToken"
122130
page_size_input: pageSize
123131
page_size: 100
132+
has_more_path: "$.hasMore"
124133
# `my_tasks` is the executor's default; pinned explicitly so the
125-
# table's population is declared, not inherited. Omitting `completed`
126-
# returns tasks in BOTH states — the state=all default.
134+
# table's population is declared, not inherited. Omitting the
135+
# action's `completed` input returns tasks in BOTH states — the
136+
# state=all default. The input is deliberately unmapped as a filter:
137+
# real rows carry NO `completed` boolean (live-verified 2026-08-04 —
138+
# completion is `status: todo|done` plus `completed_at`), and a
139+
# string-enum column cannot render into a boolean input without a
140+
# value transform the engine deliberately lacks.
127141
fixed_inputs:
128142
type: my_tasks
129-
filters:
130-
# Boolean equality forwards faithfully, but stays Inexact like
131-
# every enum-ish push — re-filtering is cheap and the provider's
132-
# interpretation is not contract-tested.
133-
- { column: completed, op: eq, input: completed, fidelity: inexact }
134143
columns:
135144
- { name: guid, path: guid, type: utf8, nullable: false }
136145
- { name: summary, path: summary, type: utf8, nullable: true }
137146
- { name: description, path: description, type: utf8, nullable: true }
138-
- { name: completed, path: completed, type: boolean, nullable: true }
147+
- { name: status, path: status, type: utf8, nullable: true }
139148
- { name: created_at, path: created_at, type: timestamp_ms_string_utc, nullable: true }
140149
- { name: updated_at, path: updated_at, type: timestamp_ms_string_utc, nullable: true }
150+
# Feishu spells "not completed" as the digit string "0", which
151+
# decodes to 1970-01-01T00:00:00Z — filter on `status = 'done'`
152+
# rather than on this column's epoch-zero sentinel.
153+
- { name: completed_at, path: completed_at, type: timestamp_ms_string_utc, nullable: true }
141154
- { name: due, path: due, type: json, nullable: true }
142155
- { name: members, path: members, type: json, nullable: true }
143156
- { name: url, path: url, type: utf8, nullable: true }
@@ -152,12 +165,14 @@ tables:
152165
next_cursor_path: "$.pageToken"
153166
page_size_input: pageSize
154167
page_size: 50
168+
has_more_path: "$.hasMore"
155169
columns:
156170
- { name: space_id, path: space_id, type: utf8, nullable: false }
157171
- { name: name, path: name, type: utf8, nullable: true }
158172
- { name: description, path: description, type: utf8, nullable: true }
159173
- { name: space_type, path: space_type, type: utf8, nullable: true }
160174
- { name: visibility, path: visibility, type: utf8, nullable: true }
175+
- { name: open_sharing, path: open_sharing, type: utf8, nullable: true }
161176

162177
wiki_nodes:
163178
action: feishu.list_wiki_nodes
@@ -169,6 +184,7 @@ tables:
169184
next_cursor_path: "$.pageToken"
170185
page_size_input: pageSize
171186
page_size: 50
187+
has_more_path: "$.hasMore"
172188
# One level per scan: the action lists the children of ONE parent
173189
# (space root when parentNodeToken is omitted) — same shape as
174190
# Notion's block_children. Full-tree traversal is a client-side
@@ -189,3 +205,5 @@ tables:
189205
- { name: obj_edit_time, path: obj_edit_time, type: timestamp_s_string_utc, nullable: true }
190206
- { name: node_create_time, path: node_create_time, type: timestamp_s_string_utc, nullable: true }
191207
- { name: owner, path: owner, type: utf8, nullable: true }
208+
- { name: creator, path: creator, type: utf8, nullable: true }
209+
- { name: url, path: url, type: utf8, nullable: true }

0 commit comments

Comments
 (0)