From 569b9448204d556ac6614549be781d844f3a2049 Mon Sep 17 00:00:00 2001 From: Pawel Date: Sun, 11 Oct 2026 07:46:50 +0200 Subject: [PATCH 01/20] feat(trimble-connect): BCF 2.1 topics + ToDos commands (floless.app#2184) Topics API is regional: resolve the project's location via /projects + /regions and send the token only to the five published open* Topics hosts; no redirects. ToDos on the Core base. Plan hardened with Codex (3 rounds). --- .../commands/create-comment.md | 65 + .../commands/create-document-reference.md | 64 + .../trimble-connect/commands/create-todo.md | 63 + .../trimble-connect/commands/create-topic.md | 76 + .../commands/create-viewpoint.md | 93 + .../trimble-connect/commands/delete-todo.md | 47 + .../trimble-connect/commands/delete-topic.md | 58 + .../trimble-connect/commands/get-todo.md | 58 + .../trimble-connect/commands/get-topic.md | 59 + .../commands/get-viewpoint-snapshot.md | 62 + .../trimble-connect/commands/list-comments.md | 69 + .../commands/list-document-references.md | 67 + .../trimble-connect/commands/list-todos.md | 66 + .../trimble-connect/commands/list-topics.md | 74 + .../commands/list-viewpoints.md | 68 + .../trimble-connect/commands/update-todo.md | 63 + .../trimble-connect/commands/update-topic.md | 83 + .../trimble-connect/manifest.yaml | 619 ++++- cli/src/runtime/mod.rs | 1 + cli/src/runtime/trimble_bcf.rs | 2188 +++++++++++++++++ cli/src/runtime/trimble_ops.rs | 26 +- 21 files changed, 3955 insertions(+), 14 deletions(-) create mode 100644 20-agents/aeco/construction/trimble-connect/commands/create-comment.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/create-document-reference.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/create-todo.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/create-topic.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/create-viewpoint.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/delete-todo.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/delete-topic.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/get-todo.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/get-topic.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/get-viewpoint-snapshot.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/list-comments.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/list-document-references.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/list-todos.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/list-topics.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/list-viewpoints.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/update-todo.md create mode 100644 20-agents/aeco/construction/trimble-connect/commands/update-topic.md create mode 100644 cli/src/runtime/trimble_bcf.rs diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-comment.md b/20-agents/aeco/construction/trimble-connect/commands/create-comment.md new file mode 100644 index 000000000..c53bf2a45 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/create-comment.md @@ -0,0 +1,65 @@ +# `trimble-connect.create-comment` — comment on a BCF topic + +Write command. Adds a comment, optionally about one of the topic's viewpoints +(`viewpoint-id`) or replying to another comment (`reply-to-comment-id`). + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `comment` | string | Comment text. | +| `viewpoint-id` | string (optional) | Viewpoint guid this comment is about. | +| `reply-to-comment-id` | string (optional) | Comment guid this replies to. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +comment-id: string +comment: object +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. POST {bcf}/topics/{topic-id}/comments (Bearer) + Content-Type: application/json + { "comment", "viewpoint_guid", "reply_to_comment_guid" } (only those given) + → 201 comment +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.list-comments](./list-comments.md) +- [trimble-connect.create-viewpoint](./create-viewpoint.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-document-reference.md b/20-agents/aeco/construction/trimble-connect/commands/create-document-reference.md new file mode 100644 index 000000000..be33b0beb --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/create-document-reference.md @@ -0,0 +1,64 @@ +# `trimble-connect.create-document-reference` — attach a document reference to a topic + +Write command. Give exactly one of `document-id` (the guid of a project BCF document) or +`url` (a link to the document), plus an optional `description`. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `document-id` | string (optional) | Guid of a project BCF document. Give this or `url`. | +| `url` | string (optional) | A link to the referenced document. Give this or `document-id`. | +| `description` | string (optional) | What the document is. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +document-reference-id: string +document-reference: object +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. POST {bcf}/topics/{topic-id}/document_references (Bearer) + Content-Type: application/json + { "document_guid" | "url", "description" } + → 201 document reference +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.list-document-references](./list-document-references.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-todo.md b/20-agents/aeco/construction/trimble-connect/commands/create-todo.md new file mode 100644 index 000000000..8174e2c10 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/create-todo.md @@ -0,0 +1,63 @@ +# `trimble-connect.create-todo` — create a ToDo + +Write command. `status` and `priority` are checked against the Core definition's fixed +values; `type` is one of the project's ToDo types and passes through. `assignees` takes +user ids (sent as `{id, type: USER}`) or `{id, type: USER|GROUP}` objects. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `description` | string | What needs doing. | +| `title` | string (optional) | Short title. | +| `type` | string (optional) | A ToDo type of the project (e.g. Request, Issue). | +| `label` | string (optional) | Label, unique within the project. | +| `due-date` | string (optional) | ISO 8601 date-time. | +| `percent-complete` | int (optional) | Progress, 0-100. | +| `status` | `NEW` \| `IN_PROGRESS` \| `RESOLVED` \| `BLOCKED` \| `CLOSED` (optional) | Status. | +| `priority` | `LOW` \| `NORMAL` \| `HIGH` \| `CRITICAL` (optional) | Priority. | +| `assignees` | list (optional) | User ids, or `{id, type: USER|GROUP}` objects. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184): +it builds TC's camelCase body from the kebab-case inputs, which the generic REST renderer +cannot express. ToDos are Core API 2.0, on the agent's own base. A non-2xx **fails the +node** with the step, the status and TC's own message. + +```yaml +todo-id: string +todo: object +``` + +## REST translation + +```http +POST {core}/todos (Bearer) +Content-Type: application/json +{ "projectId", "description", "title", "type", "label", "dueDate", + "percentComplete", "status", "priority", "assignees": [{"id", "type"}] } (only those given) +→ 201 todo +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `create todo: HTTP 400 …` | e.g. a `label` already used in the project | Use another label | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [projects.md](../skills/projects.md) — the API reference +- [trimble-connect.update-todo](./update-todo.md) +- [trimble-connect.list-todos](./list-todos.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-topic.md b/20-agents/aeco/construction/trimble-connect/commands/create-topic.md new file mode 100644 index 000000000..93c800f04 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/create-topic.md @@ -0,0 +1,76 @@ +# `trimble-connect.create-topic` — create a BCF topic + +Write command (`mode: write`, so `--dry-run` stubs it). Type, status, priority and stage +are the project's BCF *extension* values (they differ per project), so they are passed +through as given; TC refuses one the project does not define. Pass `guid` to make a +retried create safe: the second attempt cannot make a second topic. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `title` | string | Topic title. | +| `description` | string (optional) | Topic description. | +| `topic-type` | string (optional) | e.g. "Issue", "Clash", "Request" (a project extension value). | +| `topic-status` | string (optional) | e.g. "Open", "In Progress", "Closed" (a project extension value). | +| `priority` | string (optional) | e.g. "Normal", "High" (a project extension value). | +| `labels` | list (optional) | Labels (list of strings). | +| `assigned-to` | string (optional) | Assignee email. | +| `due-date` | string (optional) | ISO 8601 date-time. | +| `stage` | string (optional) | Stage (a project extension value). | +| `guid` | string (optional) | Client-chosen topic guid, so a retried create cannot make a second topic. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +topic-id: string +topic: object +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. POST {bcf}/topics (Bearer) + Content-Type: application/json + { "title", "description", "topic_type", "topic_status", "priority", + "labels", "assigned_to", "due_date", "stage", "guid" } (only those given) + → 201 topic +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `create topic: HTTP 400 …` | A value the project's extensions do not allow | Use a value from the project's topic settings | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.update-topic](./update-topic.md) +- [trimble-connect.create-comment](./create-comment.md) +- [trimble-connect.create-viewpoint](./create-viewpoint.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-viewpoint.md b/20-agents/aeco/construction/trimble-connect/commands/create-viewpoint.md new file mode 100644 index 000000000..f62e28ce3 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/create-viewpoint.md @@ -0,0 +1,93 @@ +# `trimble-connect.create-viewpoint` — add a viewpoint (camera and/or snapshot) to a topic + +Write command. Cameras, lines, clipping planes and components are given in **BCF's own +wire shape** (snake_case keys, `{x, y, z}` points) and are checked before anything is +sent: at most one camera; every point has finite numeric `x`/`y`/`z`; direction and up +vectors are non-zero; `field_of_view` is in (0, 180); `view_to_world_scale` > 0; +`components.selection` / `coloring` are lists of objects and `visibility` an object. +A camera or a snapshot is required. The snapshot is a base64 PNG or JPEG (max 20 MiB) +whose structure is checked; its type is inferred, and a `snapshot-type` that disagrees +is refused. Object/list inputs may also be given as JSON text. + +```yaml +- id: view + agent: trimble-connect + command: create-viewpoint + config: + project-id: "{{ inputs.project }}" + topic-id: "{{ issue.topic-id }}" + perspective-camera: + camera_view_point: { x: 12.0, y: -4.5, z: 3.2 } + camera_direction: { x: -0.6, y: 0.7, z: -0.3 } + camera_up_vector: { x: 0, y: 0, z: 1 } + field_of_view: 60 + snapshot: "{{ render.bytes }}" +``` + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `perspective-camera` | object (optional) | BCF perspective_camera: camera_view_point, camera_direction, camera_up_vector ({x,y,z}) and field_of_view. | +| `orthogonal-camera` | object (optional) | BCF orthogonal_camera: the same points plus view_to_world_scale. | +| `lines` | list (optional) | BCF lines: [{start_point, end_point}]. | +| `clipping-planes` | list (optional) | BCF clipping_planes: [{location, direction}]. | +| `components` | object (optional) | BCF components: {selection, coloring, visibility}. | +| `snapshot` | bytes (optional) | PNG or JPEG image, base64 (max 20 MiB). | +| `snapshot-type` | `png` \| `jpg` (optional) | Declared image type; inferred from the bytes when omitted, refused when it disagrees. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +viewpoint-id: string +viewpoint: object +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. POST {bcf}/topics/{topic-id}/viewpoints (Bearer) + Content-Type: application/json + { "perspective_camera" | "orthogonal_camera", "lines", "clipping_planes", "components", + "snapshot": { "snapshot_type": "png" | "jpg", "snapshot_data": "" } } + → 201 viewpoint +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… is the zero vector` / `… out of range` / `not a complete PNG or JPEG` | Malformed viewpoint — refused before any request | Fix the camera / image | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [viewpoints.md](../skills/viewpoints.md) — the API reference +- [trimble-connect.get-viewpoint-snapshot](./get-viewpoint-snapshot.md) +- [trimble-connect.list-viewpoints](./list-viewpoints.md) +- [trimble-connect.create-comment](./create-comment.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/delete-todo.md b/20-agents/aeco/construction/trimble-connect/commands/delete-todo.md new file mode 100644 index 000000000..652a332fa --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/delete-todo.md @@ -0,0 +1,47 @@ +# `trimble-connect.delete-todo` — delete a ToDo + +Write command. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `todo-id` | string | ToDo id. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184): +it builds TC's camelCase body from the kebab-case inputs, which the generic REST renderer +cannot express. ToDos are Core API 2.0, on the agent's own base. A non-2xx **fails the +node** with the step, the status and TC's own message. + +```yaml +todo-id: string +deleted: bool +``` + +## REST translation + +```http +DELETE {core}/todos/{todo-id} (Bearer) → 204 +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `delete todo: HTTP 404 …` | No such ToDo (already deleted?) | List them with `list-todos` | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [projects.md](../skills/projects.md) — the API reference +- [trimble-connect.create-todo](./create-todo.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/delete-topic.md b/20-agents/aeco/construction/trimble-connect/commands/delete-topic.md new file mode 100644 index 000000000..b524b6b33 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/delete-topic.md @@ -0,0 +1,58 @@ +# `trimble-connect.delete-topic` — delete a BCF topic + +Write command. Deletes the topic with its comments, viewpoints and document references. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +topic-id: string +deleted: bool +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. DELETE {bcf}/topics/{topic-id} (Bearer) → 204 +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `delete topic: HTTP 404 …` | No such topic (already deleted?) | List them with `list-topics` | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.create-topic](./create-topic.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/get-todo.md b/20-agents/aeco/construction/trimble-connect/commands/get-todo.md new file mode 100644 index 000000000..f89019e65 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/get-todo.md @@ -0,0 +1,58 @@ +# `trimble-connect.get-todo` — one ToDo + +Stateless read. Returns one ToDo's details. To find ToDos use [`list-todos`](./list-todos.md). + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `todo-id` | string | ToDo id. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +The REST transport returns the HTTP exchange envelope — `{ status, headers, body }` — +so an app can branch on `status` (a 4xx is returned as data, not raised). + +```yaml +status: int +headers: object +body: + id: string + label: string + title: string + description: string + type: string + status: string # NEW | IN_PROGRESS | RESOLVED | BLOCKED | CLOSED + priority: string # LOW | NORMAL | HIGH | CRITICAL + percentComplete: int + dueDate: string + projectId: string + assignees: array +``` + +## REST translation + +```http +GET {base}/todos/{todo-id} +Authorization: Bearer **** +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `404` in `body` | Id invalid or no access | Check it with `list-todos` | +| `401` in `body` (`INVALID_SESSION`) | Access token expired | `aware connect trimble-connect --refresh` | +| `tc.auth-missing` | No credential provisioned | `aware connect trimble-connect --oauth` | + +## See also + +- [projects.md](../skills/projects.md) — the API reference +- [trimble-connect.update-todo](./update-todo.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/get-topic.md b/20-agents/aeco/construction/trimble-connect/commands/get-topic.md new file mode 100644 index 000000000..98f787f7c --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/get-topic.md @@ -0,0 +1,59 @@ +# `trimble-connect.get-topic` — one BCF topic + +Stateless read. Returns the topic as TC sends it (BCF 2.1, snake_case) under `topic`. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +topic-id: string +topic: object +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id} (Bearer) +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `get topic: HTTP 404 …` | No such topic in the project | List them with `list-topics` | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.list-topics](./list-topics.md) +- [trimble-connect.update-topic](./update-topic.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/get-viewpoint-snapshot.md b/20-agents/aeco/construction/trimble-connect/commands/get-viewpoint-snapshot.md new file mode 100644 index 000000000..01b69e398 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/get-viewpoint-snapshot.md @@ -0,0 +1,62 @@ +# `trimble-connect.get-viewpoint-snapshot` — a viewpoint's snapshot image + +Stateless read. Returns the image bytes base64-encoded (the same shape as `download`), at +most 20 MiB. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `viewpoint-id` | string | Viewpoint guid. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +bytes: string +encoding: string +content-type: string +size-kb: number +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id}/viewpoints/{viewpoint-id}/snapshot (Bearer) → image bytes +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `get viewpoint snapshot: HTTP 404 …` | The viewpoint has no snapshot | Check `list-viewpoints` | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [viewpoints.md](../skills/viewpoints.md) — the API reference +- [trimble-connect.create-viewpoint](./create-viewpoint.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/list-comments.md b/20-agents/aeco/construction/trimble-connect/commands/list-comments.md new file mode 100644 index 000000000..ca4f70f64 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/list-comments.md @@ -0,0 +1,69 @@ +# `trimble-connect.list-comments` — one page of a topic's comments + +Stateless read. BCF 2.1 pages with `top` + `skiptoken`: the response's `next` header names the next page, +whose opaque token comes back as `next-skiptoken` (null on the last page). Pass it as +`skiptoken` to read on. A 204 is an empty list. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `top` | int (optional) | Page size. | +| `skiptoken` | string (optional) | The previous page's `next-skiptoken`. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +items: + type: array + items: + guid: string + comment: string + author: string + date: string + viewpoint_guid: string + reply_to_comment_guid: string +next-skiptoken: string +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id}/comments?top={top}&skiptoken={skiptoken} (Bearer) +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.create-comment](./create-comment.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/list-document-references.md b/20-agents/aeco/construction/trimble-connect/commands/list-document-references.md new file mode 100644 index 000000000..6af951dd9 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/list-document-references.md @@ -0,0 +1,67 @@ +# `trimble-connect.list-document-references` — one page of a topic's document references + +Stateless read. BCF 2.1 pages with `top` + `skiptoken`: the response's `next` header names the next page, +whose opaque token comes back as `next-skiptoken` (null on the last page). Pass it as +`skiptoken` to read on. A 204 is an empty list. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `top` | int (optional) | Page size. | +| `skiptoken` | string (optional) | The previous page's `next-skiptoken`. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +items: + type: array + items: + guid: string + document_guid: string + url: string + description: string +next-skiptoken: string +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id}/document_references?top={top}&skiptoken={skiptoken} (Bearer) +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.create-document-reference](./create-document-reference.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/list-todos.md b/20-agents/aeco/construction/trimble-connect/commands/list-todos.md new file mode 100644 index 000000000..b1976d739 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/list-todos.md @@ -0,0 +1,66 @@ +# `trimble-connect.list-todos` — a project's ToDos + +Stateless read. All of a project's ToDos, or those on one file (its latest version, or +`version-id`). TC pages the list: a 206 (`partial: true`) with `content-range` +(`items 0-99/240`) means more exist — don't read a missing ToDo in a partial page as +absent; pass `range` for the next page. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `file-id` | string (optional) | Only the ToDos on this file (its latest version). | +| `version-id` | string (optional) | Only the ToDos on this file version (needs `file-id`). | +| `range` | string (optional) | A page, e.g. `items=100-199` (zero-based, inclusive). | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184): +it builds TC's camelCase body from the kebab-case inputs, which the generic REST renderer +cannot express. ToDos are Core API 2.0, on the agent's own base. A non-2xx **fails the +node** with the step, the status and TC's own message. + +```yaml +partial: bool +content-range: string +items: + type: array + items: + id: string + label: string + title: string + description: string + status: string + priority: string + dueDate: string + assignees: array +``` + +## REST translation + +```http +GET {core}/todos?projectId={project-id}[&fileId={file-id}[&versionId={version-id}]] (Bearer) +Range: items=0-99 (when `range` is given) +→ 200/206 [todo…] + Content-Range +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [projects.md](../skills/projects.md) — the API reference +- [trimble-connect.get-todo](./get-todo.md) +- [trimble-connect.create-todo](./create-todo.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/list-topics.md b/20-agents/aeco/construction/trimble-connect/commands/list-topics.md new file mode 100644 index 000000000..2146f248d --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/list-topics.md @@ -0,0 +1,74 @@ +# `trimble-connect.list-topics` — one page of a project's BCF topics + +Stateless read. BCF 2.1 pages with `top` + `skiptoken`: the response's `next` header names the next page, +whose opaque token comes back as `next-skiptoken` (null on the last page). Pass it as +`skiptoken` to read on. A 204 is an empty list. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `top` | int (optional) | Page size; TC's maximum when omitted. | +| `skiptoken` | string (optional) | The previous page's `next-skiptoken`; omit for the first page. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +items: + type: array + items: + guid: string + title: string + topic_type: string + topic_status: string + priority: string + assigned_to: string + due_date: string + labels: array + creation_date: string + modified_date: string +next-skiptoken: string +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics?top={top}&skiptoken={skiptoken} (Bearer) + → 200 [topic…] + `next` header 204: empty +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.get-topic](./get-topic.md) +- [trimble-connect.create-topic](./create-topic.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/list-viewpoints.md b/20-agents/aeco/construction/trimble-connect/commands/list-viewpoints.md new file mode 100644 index 000000000..8d74c0622 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/list-viewpoints.md @@ -0,0 +1,68 @@ +# `trimble-connect.list-viewpoints` — one page of a topic's viewpoints + +Stateless read. BCF 2.1 pages with `top` + `skiptoken`: the response's `next` header names the next page, +whose opaque token comes back as `next-skiptoken` (null on the last page). Pass it as +`skiptoken` to read on. A 204 is an empty list. To fetch a viewpoint's image use `get-viewpoint-snapshot`. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `top` | int (optional) | Page size. | +| `skiptoken` | string (optional) | The previous page's `next-skiptoken`. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +items: + type: array + items: + guid: string + perspective_camera: object + orthogonal_camera: object + snapshot: object +next-skiptoken: string +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id}/viewpoints?top={top}&skiptoken={skiptoken} (Bearer) +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [viewpoints.md](../skills/viewpoints.md) — the API reference +- [trimble-connect.create-viewpoint](./create-viewpoint.md) +- [trimble-connect.get-viewpoint-snapshot](./get-viewpoint-snapshot.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/update-todo.md b/20-agents/aeco/construction/trimble-connect/commands/update-todo.md new file mode 100644 index 000000000..ed792b082 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/update-todo.md @@ -0,0 +1,63 @@ +# `trimble-connect.update-todo` — change some fields of a ToDo + +Write command. TC's `PATCH` changes only the fields sent. An input left out or `""` is +not a change; an explicit `null` clears `label` or `due-date`, and empties `assignees` +(`title` and `description` cannot be cleared). The Core definition declares no +`If-Match` for ToDos. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `todo-id` | string | ToDo id. | +| `description` | string (optional) | New description. | +| `title` | string (optional) | New title. | +| `type` | string (optional) | New type. | +| `label` | string (optional) | New label; null clears. | +| `due-date` | string (optional) | New due date; null clears. | +| `percent-complete` | int (optional) | Progress, 0-100. | +| `status` | `NEW` \| `IN_PROGRESS` \| `RESOLVED` \| `BLOCKED` \| `CLOSED` (optional) | New status. | +| `priority` | `LOW` \| `NORMAL` \| `HIGH` \| `CRITICAL` (optional) | New priority. | +| `assignees` | list (optional) | Replacement assignees; null clears. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184): +it builds TC's camelCase body from the kebab-case inputs, which the generic REST renderer +cannot express. ToDos are Core API 2.0, on the agent's own base. A non-2xx **fails the +node** with the step, the status and TC's own message. + +```yaml +todo-id: string +todo: object +``` + +## REST translation + +```http +PATCH {core}/todos/{todo-id} (Bearer) +Content-Type: application/json +{ the given fields, camelCase } +→ 200 todo +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `update-todo needs at least one field …` | No effective change given | Name a field to change | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [projects.md](../skills/projects.md) — the API reference +- [trimble-connect.create-todo](./create-todo.md) +- [trimble-connect.get-todo](./get-todo.md) diff --git a/20-agents/aeco/construction/trimble-connect/commands/update-topic.md b/20-agents/aeco/construction/trimble-connect/commands/update-topic.md new file mode 100644 index 000000000..8906b1476 --- /dev/null +++ b/20-agents/aeco/construction/trimble-connect/commands/update-topic.md @@ -0,0 +1,83 @@ +# `trimble-connect.update-topic` — change some fields of a BCF topic + +Write command. TC's `PUT topic` replaces the **whole** topic, so the topic is read first and +every field of the PUT schema it carries (`topic_type`, `topic_status`, `reference_links`, +`title`, `priority`, `index`, `labels`, `assigned_to`, `assigned_to_uuid`, `assignees`, +`stage`, `description`, `due_date`, `marker`, `bim_snippet`) is sent back with your +changes laid over it — a field you do not name survives. An input left out or `""` +is not a change; an explicit `null` clears `description`, `assigned-to`, `due-date`, +`stage` or `labels` (`title` cannot be cleared). When the result equals the topic as +read, nothing is written and `changed` is `false`. + +**Concurrency:** BCF 2.1 on Trimble Connect offers no `If-Match` / ETag, so an edit +someone else makes between this command's read and its write is overwritten. + +## Lifecycle + +`single` — one call, one response + +## Inputs + +| Field | Type | Description | +|---|---|---| +| `project-id` | string | Project id. | +| `topic-id` | string | Topic guid. | +| `title` | string (optional) | New title (cannot be cleared). | +| `description` | string (optional) | New description; null clears. | +| `topic-type` | string (optional) | New type. | +| `topic-status` | string (optional) | New status. | +| `priority` | string (optional) | New priority. | +| `labels` | list (optional) | Replacement labels; null clears. | +| `assigned-to` | string (optional) | New assignee email; null clears. | +| `due-date` | string (optional) | New due date; null clears. | +| `stage` | string (optional) | New stage; null clears. | + +The agent authenticates with the single `trimble-connect` credential from +`aware connect trimble-connect` (the token is refreshed automatically, #198). + +## Outputs + +Handled in-process by the runtime (`cli/src/runtime/trimble_bcf.rs`, floless.app#2184). +BCF lives on Trimble's regional Topics API, not under the agent's Core base, so every +BCF command first resolves the project's region — `GET {core}/projects/{project-id}` +gives its `location`, and the public `GET {core}/regions` maps that to the region's +`topic-api` — and sends the token only to one of the five published Topics hosts +(`open11` / `open21` / `open22` / `open31` / `open32` `.connect.trimble.com`). Redirects +are never followed. A non-2xx **fails the node** with the step, the status and TC's own +message — it is not returned as data. + +```yaml +topic-id: string +topic: object +changed: bool +``` + +## REST translation + +```http +0. GET {core}/projects/{project-id} (Bearer) → location + GET {core}/regions (no auth) → topic-api for that location + {bcf} = {topic-api}bcf/2.1/projects/{project-id} +1. GET {bcf}/topics/{topic-id} (Bearer) → current topic +2. PUT {bcf}/topics/{topic-id} (Bearer) — skipped when nothing changes + Content-Type: application/json + { every PUT-schema field of the current topic, with the changes applied } +``` + +## Failure modes + +| Error | Cause | Recovery | +|---|---|---| +| `missing required input …` / `… must be …` | Bad input — refused before any request | Fix the node config | +| `BCF region: project: HTTP 404 …` | No such project, or no access | Check `project-id` with `list-projects` | +| `BCF region: … not a known Trimble Connect Topics host` | TC named a Topics URL outside the published five | Report it; the token was not sent | +| `… redirects are not followed` | TC answered 3xx | Report it | +| `update-topic needs at least one field …` | No effective change given | Name a field to change | +| `update topic: HTTP 400 …` | A value the project's extensions do not allow | Use a value from the project's topic settings | +| `… HTTP 401 …` | Token expired and could not be refreshed | `aware connect trimble-connect --refresh` | + +## See also + +- [topics.md](../skills/topics.md) — the API reference +- [trimble-connect.get-topic](./get-topic.md) +- [trimble-connect.create-topic](./create-topic.md) diff --git a/20-agents/aeco/construction/trimble-connect/manifest.yaml b/20-agents/aeco/construction/trimble-connect/manifest.yaml index 965be18c6..394482703 100644 --- a/20-agents/aeco/construction/trimble-connect/manifest.yaml +++ b/20-agents/aeco/construction/trimble-connect/manifest.yaml @@ -1,15 +1,16 @@ agent: trimble-connect -version: 0.3.0 +version: 0.4.0 sdk-target: 2.4.0 display-name: Trimble Connect description: | - Manage a Trimble Connect project's folders and files: list and inspect - projects, members, folders and files; create, find, rename, move, copy and - delete folders and files; upload and download file content. Built from - Trimble Connect's published Core API 2.0 definition and refined to capture - the idempotency, rate-limit and auth-refresh patterns the REST surface alone - doesn't encode. BCF topics, viewpoints and ToDos are documented in skills - but not yet exposed as commands. + Manage a Trimble Connect project's folders, files, BCF topics and ToDos: + list and inspect projects, members, folders and files; create, find, rename, + move, copy and delete folders and files; upload and download file content; + list, create and update BCF 2.1 topics with their comments, viewpoints + (snapshots included) and document references; list, create, update and + delete ToDos. Built from Trimble Connect's published Core API 2.0 and Topics + (BCF 2.1) definitions and refined to capture the idempotency, rate-limit, + region and auth-refresh patterns the REST surface alone doesn't encode. stateful: false @@ -30,6 +31,12 @@ provenance: requires: network: - https://app.connect.trimble.com + # BCF 2.1 Topics API — one host per region, resolved per project via /regions + - https://open11.connect.trimble.com + - https://open21.connect.trimble.com + - https://open22.connect.trimble.com + - https://open31.connect.trimble.com + - https://open32.connect.trimble.com secrets: - trimble-connect # cross-references ~/.aware/credentials/trimble-connect.json @@ -517,6 +524,602 @@ commands: parent-id: string project-id: string + # ── BCF 2.1 topics (runtime handler — cli/src/runtime/trimble_bcf.rs) ────── + # Trimble's Topics API (api.swaggerhub.com/apis/Trimble-Connect/topic/v2) is + # served from a REGIONAL host, not under the Core base above: the handler reads + # the project's `location`, maps it through the public `GET /regions` to that + # region's `topic-api`, and sends the token only to one of the five published + # Topics hosts (requires.network). Redirects are never followed. BCF 2.1 pages + # with `top` + `skiptoken` (the next page's token comes back as + # `next-skiptoken`) and has no If-Match / ETag. Like the management commands, + # these raise on a non-2xx. + + list-topics: + lifecycle: single + description: One page of a project's BCF topics (issues). + inputs: + project-id: + type: string + description: Project id. + top: + type: int + required: false + description: Page size; TC's maximum when omitted. + skiptoken: + type: string + required: false + description: The previous page's `next-skiptoken`; omit for the first page. + outputs: + type: single + schema: + items: + type: array + items: + guid: string + title: string + topic_type: string + topic_status: string + priority: string + assigned_to: string + due_date: string + labels: array + creation_date: string + modified_date: string + next-skiptoken: string # null on the last page + + get-topic: + lifecycle: single + description: One BCF topic. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + outputs: + type: single + schema: + topic-id: string + topic: object # the BCF 2.1 topic as TC returns it (snake_case) + + create-topic: + lifecycle: single + mode: write + description: | + Create a BCF topic. Type, status, priority and stage are the project's + BCF extension values, passed through as given. + inputs: + project-id: + type: string + description: Project id. + title: + type: string + description: Topic title. + description: + type: string + required: false + description: Topic description. + topic-type: + type: string + required: false + description: e.g. "Issue", "Clash", "Request" (a project extension value). + topic-status: + type: string + required: false + description: e.g. "Open", "In Progress", "Closed" (a project extension value). + priority: + type: string + required: false + description: e.g. "Normal", "High" (a project extension value). + labels: + type: list + required: false + description: Labels (list of strings). + assigned-to: + type: string + required: false + description: Assignee email. + due-date: + type: string + required: false + description: ISO 8601 date-time. + stage: + type: string + required: false + description: Stage (a project extension value). + guid: + type: string + required: false + description: Client-chosen topic guid, so a retried create cannot make a second topic. + outputs: + type: single + schema: + topic-id: string + topic: object + + update-topic: + lifecycle: single + mode: write + description: | + Change some fields of a BCF topic. TC's PUT replaces the whole topic, so the + topic is read first and every field you don't name is sent back unchanged. + `null` clears description, assigned-to, due-date, stage or labels. When + nothing would change, nothing is written (`changed: false`). BCF 2.1 on TC + has no If-Match: an edit made elsewhere between the read and the write is + overwritten. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + title: + type: string + required: false + description: New title (cannot be cleared). + description: + type: string + required: false + description: New description; null clears. + topic-type: + type: string + required: false + description: New type. + topic-status: + type: string + required: false + description: New status. + priority: + type: string + required: false + description: New priority. + labels: + type: list + required: false + description: Replacement labels; null clears. + assigned-to: + type: string + required: false + description: New assignee email; null clears. + due-date: + type: string + required: false + description: New due date; null clears. + stage: + type: string + required: false + description: New stage; null clears. + outputs: + type: single + schema: + topic-id: string + topic: object + changed: bool # false when the topic already had these values + + delete-topic: + lifecycle: single + mode: write + description: Delete a BCF topic (with its comments, viewpoints and document references). + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + outputs: + type: single + schema: + topic-id: string + deleted: bool + + list-comments: + lifecycle: single + description: One page of a topic's comments. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + top: + type: int + required: false + description: Page size. + skiptoken: + type: string + required: false + description: The previous page's `next-skiptoken`. + outputs: + type: single + schema: + items: + type: array + items: + guid: string + comment: string + author: string + date: string + viewpoint_guid: string + reply_to_comment_guid: string + next-skiptoken: string + + create-comment: + lifecycle: single + mode: write + description: Add a comment to a topic, optionally about a viewpoint or replying to a comment. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + comment: + type: string + description: Comment text. + viewpoint-id: + type: string + required: false + description: Viewpoint guid this comment is about. + reply-to-comment-id: + type: string + required: false + description: Comment guid this replies to. + outputs: + type: single + schema: + comment-id: string + comment: object + + list-viewpoints: + lifecycle: single + description: One page of a topic's viewpoints. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + top: + type: int + required: false + description: Page size. + skiptoken: + type: string + required: false + description: The previous page's `next-skiptoken`. + outputs: + type: single + schema: + items: + type: array + items: + guid: string + perspective_camera: object + orthogonal_camera: object + snapshot: object + next-skiptoken: string + + create-viewpoint: + lifecycle: single + mode: write + description: | + Add a viewpoint (camera, markup, component state and/or a PNG/JPEG + snapshot) to a topic. Cameras, lines, clipping planes and components use + BCF's own wire shape and are checked before anything is sent. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + perspective-camera: + type: object + required: false + description: "BCF perspective_camera: camera_view_point, camera_direction, camera_up_vector ({x,y,z}) and field_of_view." + orthogonal-camera: + type: object + required: false + description: "BCF orthogonal_camera: the same points plus view_to_world_scale." + lines: + type: list + required: false + description: "BCF lines: [{start_point, end_point}]." + clipping-planes: + type: list + required: false + description: "BCF clipping_planes: [{location, direction}]." + components: + type: object + required: false + description: "BCF components: {selection, coloring, visibility}." + snapshot: + type: bytes + required: false + description: PNG or JPEG image, base64 (max 20 MiB). + snapshot-type: + type: enum + values: [png, jpg] + required: false + description: Declared image type; inferred from the bytes when omitted, refused when it disagrees. + outputs: + type: single + schema: + viewpoint-id: string + viewpoint: object + + get-viewpoint-snapshot: + lifecycle: single + description: A viewpoint's snapshot image, base64. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + viewpoint-id: + type: string + description: Viewpoint guid. + outputs: + type: single + schema: + bytes: string # base64-encoded image + encoding: string # "base64" + content-type: string # e.g. image/png + size-kb: number + + list-document-references: + lifecycle: single + description: One page of a topic's document references. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + top: + type: int + required: false + description: Page size. + skiptoken: + type: string + required: false + description: The previous page's `next-skiptoken`. + outputs: + type: single + schema: + items: + type: array + items: + guid: string + document_guid: string + url: string + description: string + next-skiptoken: string + + create-document-reference: + lifecycle: single + mode: write + description: Attach a document reference to a topic — a project BCF document or a URL. + inputs: + project-id: + type: string + description: Project id. + topic-id: + type: string + description: Topic guid. + document-id: + type: string + required: false + description: Guid of a project BCF document. Give this or `url`. + url: + type: string + required: false + description: A link to the referenced document. Give this or `document-id`. + description: + type: string + required: false + description: What the document is. + outputs: + type: single + schema: + document-reference-id: string + document-reference: object + + # ── ToDos (Core API 2.0 — same base) ─────────────────────────────────────── + + list-todos: + lifecycle: single + description: | + A project's ToDos, or those on one file (version). TC pages the list: a + 206 with `content-range` (`items 0-99/240`) means more exist; pass + `range` for the next page. + inputs: + project-id: + type: string + description: Project id. + file-id: + type: string + required: false + description: Only the ToDos on this file (its latest version). + version-id: + type: string + required: false + description: Only the ToDos on this file version (needs `file-id`). + range: + type: string + required: false + description: "A page, e.g. `items=100-199` (zero-based, inclusive)." + outputs: + type: single + schema: + partial: bool # true on a 206 — more items may exist + content-range: string # e.g. "items 0-99/240"; null when TC sent none + items: + type: array + items: + id: string + label: string + title: string + description: string + status: string + priority: string + dueDate: string + assignees: array + + get-todo: + lifecycle: single + description: One ToDo. + method: GET + path: /todos/{todo-id} + inputs: + todo-id: + type: string + in: path + description: ToDo id. + outputs: + type: single + schema: + status: int + headers: object + body: + id: string + label: string + title: string + description: string + type: string + status: string + priority: string + percentComplete: int + dueDate: string + projectId: string + assignees: array + + create-todo: + lifecycle: single + mode: write + description: Create a ToDo in a project. + inputs: + project-id: + type: string + description: Project id. + description: + type: string + description: What needs doing. + title: + type: string + required: false + description: Short title. + type: + type: string + required: false + description: A ToDo type of the project (e.g. Request, Issue). + label: + type: string + required: false + description: Label, unique within the project. + due-date: + type: string + required: false + description: ISO 8601 date-time. + percent-complete: + type: int + required: false + description: Progress, 0-100. + status: + type: enum + values: [NEW, IN_PROGRESS, RESOLVED, BLOCKED, CLOSED] + required: false + description: Status. + priority: + type: enum + values: [LOW, NORMAL, HIGH, CRITICAL] + required: false + description: Priority. + assignees: + type: list + required: false + description: "User ids, or `{id, type: USER|GROUP}` objects." + outputs: + type: single + schema: + todo-id: string + todo: object + + update-todo: + lifecycle: single + mode: write + description: Change some fields of a ToDo. `null` clears label, due-date or assignees. + inputs: + todo-id: + type: string + description: ToDo id. + description: + type: string + required: false + description: New description. + title: + type: string + required: false + description: New title. + type: + type: string + required: false + description: New type. + label: + type: string + required: false + description: New label; null clears. + due-date: + type: string + required: false + description: New due date; null clears. + percent-complete: + type: int + required: false + description: Progress, 0-100. + status: + type: enum + values: [NEW, IN_PROGRESS, RESOLVED, BLOCKED, CLOSED] + required: false + description: New status. + priority: + type: enum + values: [LOW, NORMAL, HIGH, CRITICAL] + required: false + description: New priority. + assignees: + type: list + required: false + description: Replacement assignees; null clears. + outputs: + type: single + schema: + todo-id: string + todo: object + + delete-todo: + lifecycle: single + mode: write + description: Delete a ToDo. + inputs: + todo-id: + type: string + description: ToDo id. + outputs: + type: single + schema: + todo-id: string + deleted: bool + skills: - auth-flow.md - idempotency.md diff --git a/cli/src/runtime/mod.rs b/cli/src/runtime/mod.rs index 2cd350044..164b42971 100644 --- a/cli/src/runtime/mod.rs +++ b/cli/src/runtime/mod.rs @@ -18,6 +18,7 @@ pub mod progress; pub mod provenance; pub mod report_reservation; pub mod template; +pub mod trimble_bcf; pub mod trimble_files; pub mod trimble_ops; // other submodules added later in v0.3 diff --git a/cli/src/runtime/trimble_bcf.rs b/cli/src/runtime/trimble_bcf.rs new file mode 100644 index 000000000..371cb67e1 --- /dev/null +++ b/cli/src/runtime/trimble_bcf.rs @@ -0,0 +1,2188 @@ +//! Trimble Connect BCF 2.1 topics and ToDos (floless.app#2184). +//! +//! Two APIs, one handler: +//! +//! - **BCF 2.1** (topics, comments, viewpoints + snapshots, document +//! references) is Trimble's Topics API, published as +//! `api.swaggerhub.com/apis/Trimble-Connect/topic/v2`. It does not live under +//! the agent's Core base: it is served from a *regional host* +//! (`open11.connect.trimble.com` for North America, `open21` for Europe, …). +//! The host is discovered per project — `GET {core}/projects/{id}` gives the +//! project's `location`, and the public `GET {core}/regions` maps that +//! location to the region's `topic-api` — and only an exact, known Trimble +//! Topics origin is ever sent the bearer token ([`prod_topics_host`]). +//! Redirects are never followed, so a discovered or answering host cannot move +//! the request somewhere else. +//! - **ToDos** are Core API 2.0 (`/todos`, same base as the folder commands). +//! +//! Like [`super::trimble_ops`], the commands need what the generic REST +//! renderer cannot express: snake_case (BCF) or camelCase (Core) bodies from +//! kebab-case inputs, the region discovery above, a read-modify-write for BCF's +//! whole-topic `PUT`, base64 snapshots in and out, and BCF's `next`-header +//! paging. Inputs are validated before the credential is read. A non-2xx fails +//! the node with the step, the status and TC's own message. +//! +//! What the definition does NOT offer is not invented here: BCF 2.1 on Trimble +//! Connect has no `If-Match`/ETag and no `Range` (it pages with `top` + +//! `skiptoken`), and `/todos` declares no `If-Match`. + +use std::io::Read as _; +use std::time::Duration; + +use base64::Engine as _; +use serde_json::{Map, Value, json}; + +use crate::agent_resolution::AgentCatalogue; +use crate::error::AwareError; +use crate::runtime::invoker::percent_encode_path; +use crate::runtime::trimble_files::{auth_and_base, ok_response}; + +/// The trimble-connect commands this module owns. `get-todo` is declarative +/// REST (a path parameter is all it needs), so it is not here. +pub(super) const COMMANDS: &[&str] = &[ + "list-topics", + "get-topic", + "create-topic", + "update-topic", + "delete-topic", + "list-comments", + "create-comment", + "list-viewpoints", + "create-viewpoint", + "get-viewpoint-snapshot", + "list-document-references", + "create-document-reference", + "list-todos", + "create-todo", + "update-todo", + "delete-todo", +]; + +/// Whether `command` is a trimble-connect command handled here. +pub(super) fn handles(command: &str) -> bool { + COMMANDS.contains(&command) +} + +/// The Topics API hosts Trimble publishes (one per production region). The +/// bearer token goes to no other BCF host. +const TOPICS_HOSTS: &[&str] = &[ + "open11.connect.trimble.com", + "open21.connect.trimble.com", + "open22.connect.trimble.com", + "open31.connect.trimble.com", + "open32.connect.trimble.com", +]; + +/// A snapshot image larger than this is refused, both ways. +const MAX_SNAPSHOT_BYTES: usize = 20 * 1024 * 1024; + +/// Where a discovered Topics API may be, and how long one request may take. +/// Production uses [`Policy::PROD`]; the wire-mock tests pass a policy that +/// admits their 127.0.0.1 mock — there is no runtime switch to widen it. +#[derive(Clone, Copy)] +pub(super) struct Policy { + allow: fn(&url::Url) -> bool, + timeout: Duration, +} + +impl Policy { + const PROD: Policy = Policy { + allow: prod_topics_host, + timeout: Duration::from_secs(120), + }; +} + +/// `https://` on the default port, with no credentials in +/// the URL. +fn prod_topics_host(url: &url::Url) -> bool { + url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && url.host_str().is_some_and(|h| TOPICS_HOSTS.contains(&h)) +} + +/// Run one of [`COMMANDS`]. Blocking HTTP runs off the reactor. +pub(super) async fn invoke( + catalogue: AgentCatalogue, + command: &str, + args: Value, +) -> Result { + invoke_with(catalogue, command, args, Policy::PROD).await +} + +async fn invoke_with( + catalogue: AgentCatalogue, + command: &str, + args: Value, + policy: Policy, +) -> Result { + let command = command.to_string(); + tokio::task::spawn_blocking(move || { + // Validate the inputs before touching the credential, so a malformed + // node is reported as such and never costs a token refresh or a request. + let op = Op::parse(&command, &args)?; + let (token, core) = auth_and_base(&catalogue)?; + let tc = Client { + agent: client_agent(policy.timeout), + token, + core, + }; + run(&tc, op, policy) + }) + .await + .map_err(|e| AwareError::Internal(format!("trimble-connect task join: {e}")))? +} + +/// No redirects (a 3xx is an error, never followed) and a whole-request +/// deadline on top of the connect / read / write inactivity bounds. +fn client_agent(total: Duration) -> ureq::Agent { + ureq::AgentBuilder::new() + .redirects(0) + .timeout_connect(Duration::from_secs(15)) + .timeout_read(Duration::from_secs(60)) + .timeout_write(Duration::from_secs(60)) + .timeout(total) + .build() +} + +// ── validated commands ─────────────────────────────────────────────────────── + +/// A validated command. Parsing is pure, so every refusal of bad input is +/// tested without a server and happens before any credential is read. +#[derive(Debug, PartialEq)] +enum Op { + /// A paged BCF list under the project: `rel` is `topics` or + /// `topics/{id}/{comments|viewpoints|document_references}`. + BcfList { + project: String, + rel: String, + top: Option, + skiptoken: Option, + }, + GetTopic { + project: String, + topic: String, + }, + /// A BCF `POST` under the project; the response's `guid` is reported under + /// `id_key` and the whole object under `object_key`. + BcfCreate { + project: String, + rel: String, + body: Map, + id_key: &'static str, + object_key: &'static str, + }, + UpdateTopic { + project: String, + topic: String, + /// BCF wire key → new value; `Null` clears. + changes: Map, + }, + DeleteTopic { + project: String, + topic: String, + }, + Snapshot { + project: String, + topic: String, + viewpoint: String, + }, + ListTodos { + project: String, + file: Option, + version: Option, + range: Option, + }, + CreateTodo { + body: Map, + }, + UpdateTodo { + todo: String, + changes: Map, + }, + DeleteTodo { + todo: String, + }, +} + +/// The fields of BCF's `PUT topic` body (Trimble's UpdateTopic schema), in +/// order. `update-topic` re-sends every one the topic has, so a field the +/// caller did not touch survives the whole-topic replace. +const TOPIC_PUT_KEYS: &[&str] = &[ + "topic_type", + "topic_status", + "reference_links", + "title", + "priority", + "index", + "labels", + "assigned_to", + "assigned_to_uuid", + "assignees", + "stage", + "description", + "due_date", + "marker", + "bim_snippet", +]; + +/// A topic's plain-string fields: kebab input → BCF wire key. Topic types, +/// statuses, priorities and stages are project extensions in BCF, so they are +/// passed through rather than checked against a fixed list. +const TOPIC_STRING_FIELDS: &[(&str, &str)] = &[ + ("title", "title"), + ("description", "description"), + ("topic-type", "topic_type"), + ("topic-status", "topic_status"), + ("priority", "priority"), + ("assigned-to", "assigned_to"), + ("due-date", "due_date"), + ("stage", "stage"), +]; + +/// The topic fields `update-topic` may clear with an explicit `null`. +const TOPIC_CLEARABLE: &[&str] = &["description", "assigned-to", "due-date", "stage", "labels"]; + +/// ToDo status / priority values — fixed enums in the Core definition. (ToDo +/// *types* are per project — `GET /todos/types` — so they pass through.) +const TODO_STATUSES: &[&str] = &["NEW", "IN_PROGRESS", "RESOLVED", "BLOCKED", "CLOSED"]; +const TODO_PRIORITIES: &[&str] = &["LOW", "NORMAL", "HIGH", "CRITICAL"]; + +/// A ToDo's plain-string fields: kebab input → Core wire key. +const TODO_STRING_FIELDS: &[(&str, &str)] = &[ + ("title", "title"), + ("description", "description"), + ("type", "type"), + ("label", "label"), + ("due-date", "dueDate"), +]; + +/// The ToDo fields `update-todo` may clear with an explicit `null`. +const TODO_CLEARABLE: &[&str] = &["label", "due-date", "assignees"]; + +impl Op { + fn parse(command: &str, args: &Value) -> Result { + let project = || req_str(args, "project-id"); + let topic = || req_str(args, "topic-id"); + let list = |rel: String| -> Result { + Ok(Op::BcfList { + project: project()?, + rel, + top: opt_count(args, "top", 1, u64::MAX)?, + skiptoken: opt_str(args, "skiptoken")?.map(str::to_string), + }) + }; + Ok(match command { + "list-topics" => list("topics".into())?, + "list-comments" => list(format!("topics/{}/comments", enc(&topic()?)))?, + "list-viewpoints" => list(format!("topics/{}/viewpoints", enc(&topic()?)))?, + "list-document-references" => { + list(format!("topics/{}/document_references", enc(&topic()?)))? + } + "get-topic" => Op::GetTopic { + project: project()?, + topic: topic()?, + }, + "delete-topic" => Op::DeleteTopic { + project: project()?, + topic: topic()?, + }, + "create-topic" => { + let mut body = Map::new(); + if let Some(guid) = opt_str(args, "guid")? { + body.insert("guid".into(), json!(guid)); + } + for (input, wire) in TOPIC_STRING_FIELDS { + if let Some(v) = opt_str(args, input)? { + body.insert((*wire).into(), json!(v)); + } + } + if let Some(labels) = opt_labels(args)? { + body.insert("labels".into(), labels); + } + match body.get("title").and_then(Value::as_str) { + Some(t) if !t.trim().is_empty() => {} + Some(_) => return Err(invalid("`title` is blank".into())), + None => return Err(missing("title")), + } + Op::BcfCreate { + project: project()?, + rel: "topics".into(), + body, + id_key: "topic-id", + object_key: "topic", + } + } + "update-topic" => { + if present(args, "guid") { + return Err(invalid( + "update-topic cannot change a topic's `guid` (it is its identity)".into(), + )); + } + let mut changes = Map::new(); + for (input, wire) in TOPIC_STRING_FIELDS { + match tri_str(args, input)? { + Tri::Absent => {} + Tri::Null if TOPIC_CLEARABLE.contains(input) => { + changes.insert((*wire).into(), Value::Null); + } + Tri::Null => { + return Err(invalid(format!("`{input}` cannot be cleared"))); + } + Tri::Value(v) => { + if *input == "title" && v.trim().is_empty() { + return Err(invalid("`title` is blank".into())); + } + changes.insert((*wire).into(), json!(v)); + } + } + } + if args.get("labels").is_some_and(Value::is_null) { + changes.insert("labels".into(), json!([])); + } else if let Some(labels) = opt_labels(args)? { + changes.insert("labels".into(), labels); + } + if changes.is_empty() { + return Err(invalid( + "update-topic needs at least one field to change (title, description, \ + topic-type, topic-status, priority, labels, assigned-to, due-date, stage)" + .into(), + )); + } + Op::UpdateTopic { + project: project()?, + topic: topic()?, + changes, + } + } + "create-comment" => { + let comment = req_str(args, "comment")?; + let mut body = Map::new(); + body.insert("comment".into(), json!(comment)); + if let Some(v) = opt_str(args, "viewpoint-id")? { + body.insert("viewpoint_guid".into(), json!(v)); + } + if let Some(v) = opt_str(args, "reply-to-comment-id")? { + body.insert("reply_to_comment_guid".into(), json!(v)); + } + Op::BcfCreate { + project: project()?, + rel: format!("topics/{}/comments", enc(&topic()?)), + body, + id_key: "comment-id", + object_key: "comment", + } + } + "create-viewpoint" => Op::BcfCreate { + project: project()?, + rel: format!("topics/{}/viewpoints", enc(&topic()?)), + body: viewpoint_body(args)?, + id_key: "viewpoint-id", + object_key: "viewpoint", + }, + "get-viewpoint-snapshot" => Op::Snapshot { + project: project()?, + topic: topic()?, + viewpoint: req_str(args, "viewpoint-id")?, + }, + "create-document-reference" => { + let mut body = Map::new(); + match (opt_str(args, "document-id")?, opt_str(args, "url")?) { + (Some(d), None) => { + body.insert("document_guid".into(), json!(d)); + } + (None, Some(u)) => { + body.insert("url".into(), json!(u)); + } + _ => { + return Err(invalid( + "create-document-reference needs exactly one of `document-id` \ + (a project BCF document) or `url`" + .into(), + )); + } + } + if let Some(d) = opt_str(args, "description")? { + body.insert("description".into(), json!(d)); + } + Op::BcfCreate { + project: project()?, + rel: format!("topics/{}/document_references", enc(&topic()?)), + body, + id_key: "document-reference-id", + object_key: "document-reference", + } + } + "list-todos" => { + let file = opt_str(args, "file-id")?.map(str::to_string); + let version = opt_str(args, "version-id")?.map(str::to_string); + if version.is_some() && file.is_none() { + return Err(invalid("`version-id` needs `file-id`".into())); + } + Op::ListTodos { + project: project()?, + file, + version, + range: crate::runtime::trimble_ops::item_range(args)?.map(str::to_string), + } + } + "create-todo" => { + let mut body = todo_fields(args, false)?; + match body.get("description") { + Some(Value::String(d)) if !d.trim().is_empty() => {} + Some(_) => return Err(invalid("`description` is blank".into())), + None => return Err(missing("description")), + } + body.insert("projectId".into(), json!(project()?)); + Op::CreateTodo { body } + } + "update-todo" => { + if present(args, "project-id") { + return Err(invalid( + "update-todo cannot move a ToDo to another project".into(), + )); + } + let changes = todo_fields(args, true)?; + if changes.is_empty() { + return Err(invalid( + "update-todo needs at least one field to change (title, description, \ + type, label, due-date, percent-complete, status, priority, assignees)" + .into(), + )); + } + Op::UpdateTodo { + todo: req_str(args, "todo-id")?, + changes, + } + } + "delete-todo" => Op::DeleteTodo { + todo: req_str(args, "todo-id")?, + }, + other => { + return Err(AwareError::Validation(format!( + "trimble-connect: {other:?} is not a BCF / ToDo command" + ))); + } + }) + } +} + +/// The ToDo body fields from kebab inputs. On update, an explicit `null` +/// clears a [`TODO_CLEARABLE`] field (`assignees` → `[]`) and is refused on +/// any other. +fn todo_fields(args: &Value, update: bool) -> Result, AwareError> { + let mut body = Map::new(); + let null_ok = |input: &str| -> Result<(), AwareError> { + if update && TODO_CLEARABLE.contains(&input) { + Ok(()) + } else { + Err(invalid(format!("`{input}` cannot be cleared"))) + } + }; + for (input, wire) in TODO_STRING_FIELDS { + match tri_str(args, input)? { + Tri::Absent => {} + Tri::Null if !update => {} + Tri::Null => { + null_ok(input)?; + body.insert((*wire).into(), Value::Null); + } + Tri::Value(v) => { + if matches!(*input, "title" | "description") && v.trim().is_empty() { + return Err(invalid(format!("`{input}` is blank"))); + } + body.insert((*wire).into(), json!(v)); + } + } + } + for (input, allowed) in [("status", TODO_STATUSES), ("priority", TODO_PRIORITIES)] { + if let Some(v) = opt_str(args, input)? { + let upper = v.to_ascii_uppercase(); + if !allowed.contains(&upper.as_str()) { + return Err(invalid(format!( + "`{input}` must be one of {}, got {v:?}", + allowed.join(", ") + ))); + } + body.insert(input.into(), json!(upper)); + } + } + if let Some(p) = opt_count(args, "percent-complete", 0, 100)? { + body.insert("percentComplete".into(), json!(p)); + } + match args.get("assignees") { + None => {} + Some(Value::String(s)) if s.is_empty() => {} + Some(Value::Null) if !update => {} + Some(Value::Null) => { + null_ok("assignees")?; + body.insert("assignees".into(), json!([])); + } + Some(v) => { + body.insert("assignees".into(), assignees(v)?); + } + } + Ok(body) +} + +/// `assignees`: a list whose items are a user id (→ `{id, type: USER}`) or an +/// `{id, type: USER|GROUP}` object. A JSON-encoded list (what `{{ }}` renders a +/// list to) is accepted too. +fn assignees(v: &Value) -> Result { + let v = decoded(v, "assignees")?; + let Value::Array(items) = &v else { + return Err(invalid("`assignees` must be a list".into())); + }; + items + .iter() + .map(|item| match item { + Value::String(id) if !id.trim().is_empty() => Ok(json!({"id": id, "type": "USER"})), + Value::Object(o) => { + let id = o + .get("id") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| invalid("each assignee needs an `id`".into()))?; + let ty = match o.get("type").and_then(Value::as_str) { + None => "USER".to_string(), + Some(t) if t.eq_ignore_ascii_case("user") => "USER".to_string(), + Some(t) if t.eq_ignore_ascii_case("group") => "GROUP".to_string(), + Some(t) => { + return Err(invalid(format!( + "assignee `type` must be USER or GROUP, got {t:?}" + ))); + } + }; + Ok(json!({"id": id, "type": ty})) + } + other => Err(invalid(format!( + "each assignee must be a user id or an {{id, type}} object, got {other}" + ))), + }) + .collect::, _>>() + .map(Value::Array) +} + +/// `labels`: a list of non-blank strings (or its JSON encoding). +fn opt_labels(args: &Value) -> Result, AwareError> { + let Some(raw) = args.get("labels") else { + return Ok(None); + }; + if raw.is_null() || raw.as_str() == Some("") { + return Ok(None); + } + let v = decoded(raw, "labels")?; + let ok = v.as_array().is_some_and(|a| { + a.iter() + .all(|l| l.as_str().is_some_and(|s| !s.trim().is_empty())) + }); + if !ok { + return Err(invalid( + "`labels` must be a list of non-blank strings".into(), + )); + } + Ok(Some(v)) +} + +// ── viewpoint validation ───────────────────────────────────────────────────── + +/// The `create-viewpoint` body. Cameras, lines, clipping planes and components +/// are given in BCF's own wire shape (snake_case, `{x, y, z}` points) and +/// checked structurally here, so a malformed viewpoint is refused before any +/// request. A camera or a snapshot is required. +fn viewpoint_body(args: &Value) -> Result, AwareError> { + let mut body = Map::new(); + let perspective = opt_object(args, "perspective-camera")?; + let orthogonal = opt_object(args, "orthogonal-camera")?; + if perspective.is_some() && orthogonal.is_some() { + return Err(invalid( + "give `perspective-camera` or `orthogonal-camera`, not both".into(), + )); + } + if let Some(cam) = perspective { + camera(&cam, "perspective-camera", "field_of_view", |f| { + f > 0.0 && f < 180.0 + })?; + body.insert("perspective_camera".into(), cam); + } + if let Some(cam) = orthogonal { + camera(&cam, "orthogonal-camera", "view_to_world_scale", |s| { + s > 0.0 + })?; + body.insert("orthogonal_camera".into(), cam); + } + if let Some(lines) = opt_list(args, "lines")? { + for (i, l) in lines.as_array().into_iter().flatten().enumerate() { + let what = format!("lines[{i}]"); + point(l.get("start_point"), &format!("{what}.start_point"), false)?; + point(l.get("end_point"), &format!("{what}.end_point"), false)?; + } + body.insert("lines".into(), lines); + } + if let Some(planes) = opt_list(args, "clipping-planes")? { + for (i, p) in planes.as_array().into_iter().flatten().enumerate() { + let what = format!("clipping-planes[{i}]"); + point(p.get("location"), &format!("{what}.location"), false)?; + point(p.get("direction"), &format!("{what}.direction"), true)?; + } + body.insert("clipping_planes".into(), planes); + } + if let Some(components) = opt_object(args, "components")? { + for key in ["selection", "coloring"] { + if let Some(v) = components.get(key) + && !v.as_array().is_some_and(|a| a.iter().all(Value::is_object)) + { + return Err(invalid(format!( + "`components.{key}` must be a list of objects" + ))); + } + } + if let Some(v) = components.get("visibility") + && !v.is_object() + { + return Err(invalid("`components.visibility` must be an object".into())); + } + body.insert("components".into(), components); + } + if let Some(snapshot) = snapshot_input(args)? { + body.insert("snapshot".into(), snapshot); + } + if !body.contains_key("perspective_camera") + && !body.contains_key("orthogonal_camera") + && !body.contains_key("snapshot") + { + return Err(invalid( + "create-viewpoint needs a camera (`perspective-camera` / `orthogonal-camera`) \ + or a `snapshot`" + .into(), + )); + } + Ok(body) +} + +/// A BCF camera: view point, non-zero direction and up vectors, and the +/// camera-specific scalar (`field_of_view` / `view_to_world_scale`). +fn camera(cam: &Value, what: &str, scalar: &str, ok: fn(f64) -> bool) -> Result<(), AwareError> { + point( + cam.get("camera_view_point"), + &format!("{what}.camera_view_point"), + false, + )?; + point( + cam.get("camera_direction"), + &format!("{what}.camera_direction"), + true, + )?; + point( + cam.get("camera_up_vector"), + &format!("{what}.camera_up_vector"), + true, + )?; + match cam.get(scalar).and_then(Value::as_f64) { + Some(v) if v.is_finite() && ok(v) => Ok(()), + _ => Err(invalid(format!( + "`{what}.{scalar}` is missing or out of range" + ))), + } +} + +/// An `{x, y, z}` of finite numbers; a direction must not be the zero vector. +fn point(v: Option<&Value>, what: &str, direction: bool) -> Result<(), AwareError> { + let coords: Option> = v.and_then(|p| { + ["x", "y", "z"] + .iter() + .map(|k| p.get(*k).and_then(Value::as_f64).filter(|n| n.is_finite())) + .collect() + }); + let Some(coords) = coords else { + return Err(invalid(format!( + "`{what}` must be an object with numeric x, y and z" + ))); + }; + if direction && coords.iter().all(|c| *c == 0.0) { + return Err(invalid(format!("`{what}` is the zero vector"))); + } + Ok(()) +} + +/// The optional `snapshot` (base64 PNG or JPEG) → BCF's `{snapshot_type, +/// snapshot_data}`. The size is checked on the encoded string before decoding, +/// the image's structure is checked, and a declared `snapshot-type` must match +/// what the bytes are. +fn snapshot_input(args: &Value) -> Result, AwareError> { + let Some(encoded) = opt_str(args, "snapshot")? else { + if opt_str(args, "snapshot-type")?.is_some() { + return Err(invalid("`snapshot-type` given without a `snapshot`".into())); + } + return Ok(None); + }; + // Measured on the encoded text, before anything is copied or decoded. + let encoded_len = encoded.bytes().filter(|b| !b.is_ascii_whitespace()).count(); + if encoded_len > MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 { + return Err(invalid(format!( + "`snapshot` is larger than {} MiB", + MAX_SNAPSHOT_BYTES / 1024 / 1024 + ))); + } + let cleaned: String = encoded + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .map_err(|_| invalid("`snapshot` must be a base64 string".into()))?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(invalid(format!( + "`snapshot` is larger than {} MiB", + MAX_SNAPSHOT_BYTES / 1024 / 1024 + ))); + } + let kind = image_kind(&bytes) + .ok_or_else(|| invalid("`snapshot` is not a complete PNG or JPEG image".into()))?; + if let Some(declared) = opt_str(args, "snapshot-type")? { + let declared = match declared.to_ascii_lowercase().as_str() { + "png" => "png", + "jpg" | "jpeg" => "jpg", + other => { + return Err(invalid(format!( + "`snapshot-type` must be png or jpg, got {other:?}" + ))); + } + }; + if declared != kind { + return Err(invalid(format!( + "`snapshot-type` is {declared} but the snapshot is a {kind}" + ))); + } + } + Ok(Some(json!({ + "snapshot_type": kind, + "snapshot_data": base64::engine::general_purpose::STANDARD.encode(&bytes), + }))) +} + +/// `png` for a PNG signature followed by its IHDR chunk, `jpg` for a JPEG that +/// starts with SOI and ends with EOI; anything else (truncated included) is +/// `None`. +fn image_kind(b: &[u8]) -> Option<&'static str> { + const PNG: &[u8] = b"\x89PNG\r\n\x1a\n"; + if b.len() >= 33 && b.starts_with(PNG) && &b[12..16] == b"IHDR" { + return Some("png"); + } + if b.len() >= 4 && b.starts_with(&[0xFF, 0xD8, 0xFF]) && b.ends_with(&[0xFF, 0xD9]) { + return Some("jpg"); + } + None +} + +// ── the client ─────────────────────────────────────────────────────────────── + +/// The authenticated client for one command: `core` is the agent's Core API +/// base (`…/tc/api/2.0`). +struct Client { + agent: ureq::Agent, + token: String, + core: String, +} + +impl Client { + fn authed(&self, method: &str, url: &str) -> ureq::Request { + self.agent + .request(method, url) + .set("Authorization", &format!("Bearer {}", self.token)) + } +} + +/// Send `req` (with a JSON body when given) and insist on a 2xx: redirects are +/// not followed, so a 3xx is a failure here rather than a silent success. +fn send( + req: ureq::Request, + body: Option<&Value>, + what: &str, +) -> Result { + let res = match body { + Some(b) => req + .set("Content-Type", "application/json") + .send_string(&b.to_string()), + None => req.call(), + }; + let resp = ok_response(res, what)?; + if !(200..300).contains(&resp.status()) { + return Err(AwareError::Network(format!( + "{what}: HTTP {} — redirects are not followed (location: {})", + resp.status(), + resp.header("Location").unwrap_or("none") + ))); + } + Ok(resp) +} + +/// The JSON body of a 2xx; `None` for 204 No Content. +fn read_json(resp: ureq::Response, what: &str) -> Result, AwareError> { + if resp.status() == 204 { + return Ok(None); + } + let text = resp + .into_string() + .map_err(|e| AwareError::Network(format!("{what}: read body: {e}")))?; + serde_json::from_str(&text) + .map(Some) + .map_err(|e| AwareError::Network(format!("{what}: bad JSON: {e}"))) +} + +fn json_of(req: ureq::Request, body: Option<&Value>, what: &str) -> Result { + read_json(send(req, body, what)?, what)? + .ok_or_else(|| AwareError::Network(format!("{what}: TC answered with no content"))) +} + +/// The project's BCF 2.1 base, `{topic-api}bcf/2.1/projects/{id}`: the +/// project's `location` (Core `GET projects/{id}`) mapped through the public +/// `GET regions` to that region's `topic-api`, which must pass `policy` before +/// any request carrying the token is made to it. +fn bcf_base(tc: &Client, project: &str, policy: Policy) -> Result { + let what = "BCF region"; + let info = json_of( + tc.authed("GET", &format!("{}/projects/{}", tc.core, enc(project))), + None, + &format!("{what}: project"), + )?; + let location = info + .get("location") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AwareError::Network(format!("{what}: project {project} has no `location`")) + })?; + // `regions` is public: it is asked without the token. + let regions = json_of( + tc.agent.get(&format!("{}/regions", tc.core)), + None, + &format!("{what}: regions"), + )?; + let topic_api = regions + .as_array() + .into_iter() + .flatten() + .find(|r| r.get("location").and_then(Value::as_str) == Some(location)) + .and_then(|r| r.get("topic-api").and_then(Value::as_str)) + .ok_or_else(|| { + AwareError::Network(format!( + "{what}: TC lists no Topics API for the project's location {location:?}" + )) + })?; + let parsed = url::Url::parse(topic_api) + .map_err(|e| AwareError::Network(format!("{what}: Topics API URL {topic_api:?}: {e}")))?; + if !(policy.allow)(&parsed) { + return Err(AwareError::Network(format!( + "{what}: TC named {topic_api:?} as the Topics API for {location:?}; that is not a \ + known Trimble Connect Topics host, so the token is not sent there" + ))); + } + Ok(format!( + "{}/bcf/2.1/projects/{}", + topic_api.trim_end_matches('/'), + enc(project) + )) +} + +// ── commands ───────────────────────────────────────────────────────────────── + +fn run(tc: &Client, op: Op, policy: Policy) -> Result { + match op { + Op::BcfList { + project, + rel, + top, + skiptoken, + } => { + let base = bcf_base(tc, &project, policy)?; + bcf_list(tc, &base, &rel, top, skiptoken.as_deref()) + } + Op::GetTopic { project, topic } => { + let base = bcf_base(tc, &project, policy)?; + let t = get_topic(tc, &base, &topic)?; + Ok(json!({ "topic-id": guid_of(&t, "get topic")?, "topic": t })) + } + Op::BcfCreate { + project, + rel, + body, + id_key, + object_key, + } => { + let base = bcf_base(tc, &project, policy)?; + let what = format!("create {}", object_key.replace('-', " ")); + let created = json_of( + tc.authed("POST", &format!("{base}/{rel}")), + Some(&Value::Object(body)), + &what, + )?; + Ok(json!({ id_key: guid_of(&created, &what)?, object_key: created })) + } + Op::UpdateTopic { + project, + topic, + changes, + } => { + let base = bcf_base(tc, &project, policy)?; + update_topic(tc, &base, &topic, changes) + } + Op::DeleteTopic { project, topic } => { + let base = bcf_base(tc, &project, policy)?; + send( + tc.authed("DELETE", &format!("{base}/topics/{}", enc(&topic))), + None, + "delete topic", + )?; + Ok(json!({ "topic-id": topic, "deleted": true })) + } + Op::Snapshot { + project, + topic, + viewpoint, + } => { + let base = bcf_base(tc, &project, policy)?; + snapshot(tc, &base, &topic, &viewpoint) + } + Op::ListTodos { + project, + file, + version, + range, + } => list_todos( + tc, + &project, + file.as_deref(), + version.as_deref(), + range.as_deref(), + ), + Op::CreateTodo { body } => { + let todo = json_of( + tc.authed("POST", &format!("{}/todos", tc.core)), + Some(&Value::Object(body)), + "create todo", + )?; + Ok(json!({ "todo-id": id_of(&todo, "create todo")?, "todo": todo })) + } + Op::UpdateTodo { todo, changes } => { + let updated = json_of( + tc.authed("PATCH", &format!("{}/todos/{}", tc.core, enc(&todo))), + Some(&Value::Object(changes)), + "update todo", + )?; + Ok(json!({ "todo-id": id_of(&updated, "update todo")?, "todo": updated })) + } + Op::DeleteTodo { todo } => { + send( + tc.authed("DELETE", &format!("{}/todos/{}", tc.core, enc(&todo))), + None, + "delete todo", + )?; + Ok(json!({ "todo-id": todo, "deleted": true })) + } + } +} + +/// One page of a BCF list. BCF 2.1 pages with `top` + `skiptoken`; the +/// response's `next` header is the next page's URL, from which the opaque +/// `skiptoken` is handed back as `next-skiptoken` (null on the last page). +/// A 204 is an empty list. +fn bcf_list( + tc: &Client, + base: &str, + rel: &str, + top: Option, + skiptoken: Option<&str>, +) -> Result { + let what = format!( + "list {}", + rel.rsplit('/').next().unwrap_or(rel).replace('_', " ") + ); + let mut url = format!("{base}/{rel}"); + let mut sep = '?'; + if let Some(t) = top { + url.push_str(&format!("{sep}top={t}")); + sep = '&'; + } + if let Some(s) = skiptoken { + url.push_str(&format!("{sep}skiptoken={}", enc(s))); + } + let resp = send(tc.authed("GET", &url), None, &what)?; + let next = resp.header("next").map(str::to_string); + let items = match read_json(resp, &what)? { + None => json!([]), + Some(v @ Value::Array(_)) => v, + Some(_) => { + return Err(AwareError::Network(format!( + "{what}: TC did not return a list" + ))); + } + }; + let next_skiptoken = match next.as_deref().map(str::trim).filter(|n| !n.is_empty()) { + None => None, + Some(n) => Some(skiptoken_of(n).ok_or_else(|| { + AwareError::Network(format!( + "{what}: TC's `next` page link {n:?} carries no skiptoken" + )) + })?), + }; + Ok(json!({ "items": items, "next-skiptoken": next_skiptoken })) +} + +/// The `skiptoken` query value of a (possibly relative) next-page URL. +fn skiptoken_of(next: &str) -> Option { + let base = url::Url::parse("https://next.invalid/").ok()?; + let url = base.join(next).ok()?; + url.query_pairs() + .find(|(k, _)| k.eq_ignore_ascii_case("skiptoken")) + .map(|(_, v)| v.into_owned()) + .filter(|v| !v.is_empty()) +} + +fn get_topic(tc: &Client, base: &str, topic: &str) -> Result { + json_of( + tc.authed("GET", &format!("{base}/topics/{}", enc(topic))), + None, + "get topic", + ) +} + +/// `update-topic`. BCF's `PUT topic` replaces the whole topic, so the current +/// topic is read first and every field of the PUT schema it carries is sent +/// back with the caller's changes laid over it — an untouched field survives. +/// When the changes leave the topic as it is, nothing is written +/// (`changed: false`). BCF 2.1 on TC has no `If-Match`, so an edit another +/// client makes between the read and the write is overwritten. +fn update_topic( + tc: &Client, + base: &str, + topic: &str, + changes: Map, +) -> Result { + let current = get_topic(tc, base, topic)?; + let mut body = Map::new(); + for key in TOPIC_PUT_KEYS { + if let Some(v) = current.get(*key) { + body.insert((*key).into(), v.clone()); + } + } + let before = body.clone(); + for (k, v) in changes { + body.insert(k, v); + } + if body == before { + return Ok(json!({ + "topic-id": guid_of(¤t, "update topic")?, + "topic": current, + "changed": false, + })); + } + let updated = json_of( + tc.authed("PUT", &format!("{base}/topics/{}", enc(topic))), + Some(&Value::Object(body)), + "update topic", + )?; + Ok(json!({ + "topic-id": guid_of(&updated, "update topic")?, + "topic": updated, + "changed": true, + })) +} + +/// `get-viewpoint-snapshot` — the image bytes, base64, bounded to +/// [`MAX_SNAPSHOT_BYTES`]. +fn snapshot(tc: &Client, base: &str, topic: &str, viewpoint: &str) -> Result { + let what = "get viewpoint snapshot"; + let resp = send( + tc.authed( + "GET", + &format!( + "{base}/topics/{}/viewpoints/{}/snapshot", + enc(topic), + enc(viewpoint) + ), + ), + None, + what, + )?; + let content_type = resp.header("Content-Type").map(str::to_string); + let mut bytes = Vec::new(); + resp.into_reader() + .take(MAX_SNAPSHOT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| AwareError::Network(format!("{what}: read: {e}")))?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(AwareError::Network(format!( + "{what}: the snapshot is larger than {} MiB", + MAX_SNAPSHOT_BYTES / 1024 / 1024 + ))); + } + if bytes.is_empty() { + return Err(AwareError::Network(format!("{what}: TC returned no image"))); + } + Ok(json!({ + "bytes": base64::engine::general_purpose::STANDARD.encode(&bytes), + "encoding": "base64", + "content-type": content_type, + "size-kb": (bytes.len() as f64) / 1024.0, + })) +} + +/// `list-todos` — `GET todos?projectId=…[&fileId=…[&versionId=…]]`. TC pages +/// the list like its other Core lists (a 206 with `Content-Range`), so the +/// result carries `partial` and `content-range`, and `range` asks for a page. +fn list_todos( + tc: &Client, + project: &str, + file: Option<&str>, + version: Option<&str>, + range: Option<&str>, +) -> Result { + let what = "list todos"; + let mut url = format!("{}/todos?projectId={}", tc.core, enc(project)); + if let Some(f) = file { + url.push_str(&format!("&fileId={}", enc(f))); + } + if let Some(v) = version { + url.push_str(&format!("&versionId={}", enc(v))); + } + let mut req = tc.authed("GET", &url); + if let Some(r) = range { + req = req.set("Range", r); + } + let resp = send(req, None, what)?; + let status = resp.status(); + let content_range = resp.header("Content-Range").map(str::to_string); + let items = match read_json(resp, what)? { + None => json!([]), + Some(v @ Value::Array(_)) => v, + Some(_) => { + return Err(AwareError::Network(format!( + "{what}: TC did not return a list" + ))); + } + }; + Ok(json!({ + "items": items, + "partial": status == 206, + "content-range": content_range, + })) +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn enc(s: &str) -> String { + percent_encode_path(s) +} + +/// The BCF object's `guid` — what a downstream node passes on, so a 2xx +/// without it is a failure. +fn guid_of(v: &Value, what: &str) -> Result { + required_key(v, "guid", what) +} + +fn id_of(v: &Value, what: &str) -> Result { + required_key(v, "id", what) +} + +fn required_key(v: &Value, key: &str, what: &str) -> Result { + v.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| AwareError::Network(format!("{what}: TC response has no {key}"))) +} + +/// Absent / given-as-null / a value — `update-*` needs all three apart, since +/// `null` clears a field and absence leaves it alone. `""` (what `{{ }}` renders +/// an unset input to) counts as absent. +enum Tri<'a> { + Absent, + Null, + Value(&'a str), +} + +fn tri_str<'a>(args: &'a Value, key: &str) -> Result, AwareError> { + match args.get(key) { + None => Ok(Tri::Absent), + Some(Value::Null) => Ok(Tri::Null), + Some(Value::String(s)) if s.is_empty() => Ok(Tri::Absent), + Some(Value::String(s)) => Ok(Tri::Value(s)), + Some(other) => Err(invalid(format!("`{key}` must be a string, got {other}"))), + } +} + +/// Whether `key` was given a real value (not absent, null or `""`). +fn present(args: &Value, key: &str) -> bool { + match args.get(key) { + None | Some(Value::Null) => false, + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +fn req_str(args: &Value, key: &str) -> Result { + opt_str(args, key)? + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| missing(key)) +} + +/// An optional string input: absent, `null` and `""` all mean "not given". +fn opt_str<'a>(args: &'a Value, key: &str) -> Result, AwareError> { + match tri_str(args, key)? { + Tri::Value(s) => Ok(Some(s)), + Tri::Absent | Tri::Null => Ok(None), + } +} + +/// An optional whole number in `min..=max`, given as a number or a decimal +/// string (what `{{ }}` renders a number to). +fn opt_count(args: &Value, key: &str, min: u64, max: u64) -> Result, AwareError> { + let n = match args.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(s)) if s.is_empty() => return Ok(None), + Some(Value::String(s)) => s.trim().parse::().ok(), + Some(Value::Number(n)) => n.as_u64(), + Some(_) => None, + }; + match n { + Some(n) if (min..=max).contains(&n) => Ok(Some(n)), + _ => Err(invalid(format!( + "`{key}` must be a whole number from {min}{}", + if max == u64::MAX { + " up".to_string() + } else { + format!(" to {max}") + } + ))), + } +} + +/// An optional JSON input: the value itself, or its JSON encoding as a string. +fn decoded(v: &Value, key: &str) -> Result { + match v { + Value::String(s) => serde_json::from_str(s) + .map_err(|_| invalid(format!("`{key}` is a string that is not JSON"))), + other => Ok(other.clone()), + } +} + +fn opt_json(args: &Value, key: &str) -> Result, AwareError> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if s.is_empty() => Ok(None), + Some(v) => decoded(v, key).map(Some), + } +} + +fn opt_object(args: &Value, key: &str) -> Result, AwareError> { + match opt_json(args, key)? { + Some(v @ Value::Object(_)) => Ok(Some(v)), + Some(_) => Err(invalid(format!("`{key}` must be an object"))), + None => Ok(None), + } +} + +fn opt_list(args: &Value, key: &str) -> Result, AwareError> { + match opt_json(args, key)? { + Some(v @ Value::Array(_)) => Ok(Some(v)), + Some(_) => Err(invalid(format!("`{key}` must be a list"))), + None => Ok(None), + } +} + +fn missing(key: &str) -> AwareError { + AwareError::Validation(format!("trimble-connect: missing required input `{key}`")) +} + +fn invalid(msg: String) -> AwareError { + AwareError::Validation(format!("trimble-connect: {msg}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::trimble_files::test_support::{ + agents_with, dead_base, drain, mock_agents, next_request, + }; + use crate::runtime::trimble_ops::tests::assert_declared; + use std::sync::mpsc; + + /// The policy the wire mock needs: plain http on loopback. Production never + /// sees it — [`invoke`] always passes [`Policy::PROD`]. + const LOOPBACK: Policy = Policy { + allow: |u| u.scheme() == "http" && u.host_str() == Some("127.0.0.1"), + timeout: Duration::from_secs(20), + }; + + /// A route: request-line substring, status, extra headers, body bytes. + type Route = (&'static str, u16, Vec<(&'static str, String)>, Vec); + + /// Like `mock_routed`, but a route can set response headers (`next`, + /// `Content-Range`, `Location`) and a binary body. + fn mock(count: usize, routes_fn: F) -> (String, mpsc::Receiver) + where + F: FnOnce(&str) -> Vec + Send + 'static, + { + use std::io::{Read as _, Write as _}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let for_thread = base.clone(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let routes = routes_fn(&for_thread); + for _ in 0..count { + let Ok((mut stream, _)) = listener.accept() else { + break; + }; + stream + .set_read_timeout(Some(Duration::from_millis(250))) + .ok(); + let mut data = Vec::new(); + let mut tmp = [0u8; 4096]; + while let Ok(n) = stream.read(&mut tmp) { + if n == 0 { + break; + } + data.extend_from_slice(&tmp[..n]); + } + let req = String::from_utf8_lossy(&data).to_string(); + let line = req.lines().next().unwrap_or("").to_string(); + let (status, headers, body) = routes + .iter() + .find(|(pat, ..)| line.contains(pat)) + .map(|(_, s, h, b)| (*s, h.clone(), b.clone())) + .unwrap_or((404, vec![], b"{}".to_vec())); + let mut head = format!("HTTP/1.1 {status} X\r\n"); + if !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")) + { + head.push_str("Content-Type: application/json\r\n"); + } + for (k, v) in &headers { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )); + stream.write_all(head.as_bytes()).ok(); + stream.write_all(&body).ok(); + tx.send(req).ok(); + } + }); + (base, rx) + } + + fn json_route(pat: &'static str, status: u16, body: &str) -> Route { + (pat, status, vec![], body.as_bytes().to_vec()) + } + + /// Project P is in North America; the regions list maps that to the mock. + fn discovery(base: &str) -> Vec { + vec![ + json_route( + "GET /projects/P ", + 200, + r#"{"id":"P","name":"Take 5","location":"northAmerica"}"#, + ), + json_route( + "GET /regions", + 200, + &format!( + r#"[{{"location":"europe","topic-api":"https://open21.connect.trimble.com/"}}, + {{"location":"northAmerica","topic-api":"{base}/"}}]"# + ), + ), + ] + } + + async fn run(base: &str, command: &str, args: Value) -> Result { + let agents = mock_agents(base); + invoke_with( + AgentCatalogue::from(agents.path().join("agents")), + command, + args, + LOOPBACK, + ) + .await + } + + fn line(req: &str) -> &str { + req.lines().next().unwrap_or_default() + } + + fn body_of(req: &str) -> Value { + let body = req.split("\r\n\r\n").nth(1).unwrap_or_default(); + serde_json::from_str(body).unwrap_or_else(|e| panic!("body not JSON ({e}): {req}")) + } + + /// A 1×1 PNG header: signature + IHDR chunk (enough for `image_kind`). + fn png() -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + b.extend_from_slice(&[ + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, 0x89, + ]); + b + } + + fn b64(b: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(b) + } + + fn camera() -> Value { + json!({"camera_view_point": {"x": 0, "y": 0, "z": 0}, + "camera_direction": {"x": 1, "y": 0, "z": 0}, + "camera_up_vector": {"x": 0, "y": 0, "z": 1}, + "field_of_view": 60}) + } + + const TOPIC: &str = r#"{"guid":"T1","title":"Clash at B4","topic_status":"Open","version":3, + "creation_date":"2026-10-11T00:00:00Z","creation_author":"a@b.c","creation_author_uuid":"U"}"#; + + #[test] + fn inputs_are_validated_before_any_request() { + let cam = camera(); + let mut zero_dir = cam.clone(); + zero_dir["camera_direction"] = json!({"x": 0, "y": 0, "z": 0}); + let mut wide = cam.clone(); + wide["field_of_view"] = json!(180); + let truncated = png()[..20].to_vec(); + let pt = |extra: Value| { + let mut a = json!({"project-id": "P", "topic-id": "T"}); + a.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + a + }; + let cases: Vec<(&str, Value, &str)> = vec![ + ("list-topics", json!({}), "project-id"), + ( + "list-topics", + json!({"project-id": "P", "top": 0}), + "`top` must be", + ), + ( + "list-topics", + json!({"project-id": "P", "top": "ten"}), + "`top` must be", + ), + ("list-comments", json!({"project-id": "P"}), "topic-id"), + ("get-topic", pt(json!({"topic-id": " "})), "topic-id"), + ("create-topic", json!({"project-id": "P"}), "`title`"), + ( + "create-topic", + json!({"project-id": "P", "title": " "}), + "blank", + ), + ( + "create-topic", + json!({"project-id": "P", "title": "t", "labels": "x"}), + "not JSON", + ), + ( + "create-topic", + json!({"project-id": "P", "title": "t", "labels": [""]}), + "`labels` must be", + ), + ("update-topic", pt(json!({})), "at least one field"), + ( + "update-topic", + pt(json!({"description": ""})), + "at least one field", + ), + ( + "update-topic", + pt(json!({"title": null})), + "`title` cannot be cleared", + ), + ( + "update-topic", + pt(json!({"topic-status": null})), + "cannot be cleared", + ), + ( + "update-topic", + pt(json!({"guid": "G", "title": "x"})), + "`guid`", + ), + ("create-comment", pt(json!({})), "`comment`"), + ("create-comment", pt(json!({"comment": " "})), "`comment`"), + ("create-viewpoint", pt(json!({})), "needs a camera"), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "orthogonal-camera": cam})), + "not both", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": {}})), + "camera_view_point", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": zero_dir})), + "zero vector", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": wide})), + "field_of_view", + ), + ( + "create-viewpoint", + pt(json!({"orthogonal-camera": cam})), + "view_to_world_scale", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "lines": [{"start_point": {"x": 0}}]})), + "lines[0].start_point", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "clipping-planes": [{ + "location": {"x": 0, "y": 0, "z": 0}, + "direction": {"x": 0, "y": 0, "z": 0}}]})), + "zero vector", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "components": {"selection": {}}})), + "components.selection", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "components": {"visibility": []}})), + "components.visibility", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": "{not json"})), + "not JSON", + ), + ("create-viewpoint", pt(json!({"snapshot": "%%%"})), "base64"), + ( + "create-viewpoint", + pt(json!({"snapshot": b64(&truncated)})), + "not a complete PNG", + ), + ( + "create-viewpoint", + pt(json!({"snapshot": b64(&png()), "snapshot-type": "jpg"})), + "but the snapshot is a png", + ), + ( + "create-viewpoint", + pt(json!({"perspective-camera": cam, "snapshot-type": "png"})), + "without a `snapshot`", + ), + ("get-viewpoint-snapshot", pt(json!({})), "viewpoint-id"), + ("create-document-reference", pt(json!({})), "exactly one"), + ( + "create-document-reference", + pt(json!({"url": "u", "document-id": "d"})), + "exactly one", + ), + ("list-todos", json!({}), "project-id"), + ( + "list-todos", + json!({"project-id": "P", "version-id": "V"}), + "needs `file-id`", + ), + ( + "list-todos", + json!({"project-id": "P", "range": "0-9"}), + "`range` must look like", + ), + ("create-todo", json!({"project-id": "P"}), "`description`"), + ("create-todo", json!({"description": "d"}), "project-id"), + ( + "create-todo", + json!({"project-id": "P", "description": "d", "status": "DONE"}), + "`status` must be one of", + ), + ( + "create-todo", + json!({"project-id": "P", "description": "d", "priority": "URGENT"}), + "`priority` must be one of", + ), + ( + "create-todo", + json!({"project-id": "P", "description": "d", "percent-complete": 101}), + "`percent-complete`", + ), + ( + "create-todo", + json!({"project-id": "P", "description": "d", + "assignees": [{"id": "u", "type": "ROBOT"}]}), + "USER or GROUP", + ), + ( + "create-todo", + json!({"project-id": "P", "description": "d", "assignees": [3]}), + "each assignee", + ), + ("update-todo", json!({"todo-id": "D"}), "at least one field"), + ( + "update-todo", + json!({"todo-id": "D", "description": null}), + "cannot be cleared", + ), + ( + "update-todo", + json!({"todo-id": "D", "project-id": "P2", "title": "x"}), + "another project", + ), + ("update-todo", json!({"title": "x"}), "todo-id"), + ("delete-todo", json!({}), "todo-id"), + ]; + for (cmd, args, want) in &cases { + let err = Op::parse(cmd, args).unwrap_err(); + assert!( + matches!(err, AwareError::Validation(_)), + "{cmd} {args}: {err}" + ); + assert!( + err.to_string().contains(want), + "{cmd} {args}: want {want:?}, got {err}" + ); + } + } + + #[test] + fn an_oversized_snapshot_is_refused_before_it_is_decoded() { + // Not valid base64 at all — the length check must fire first. + let huge = "!".repeat(MAX_SNAPSHOT_BYTES / 3 * 4 + 64); + let err = Op::parse( + "create-viewpoint", + &json!({"project-id": "P", "topic-id": "T", "snapshot": huge}), + ) + .unwrap_err(); + assert!(err.to_string().contains("larger than 20 MiB"), "{err}"); + } + + #[test] + fn only_the_five_published_topics_hosts_may_receive_the_token() { + for ok in TOPICS_HOSTS { + let url = url::Url::parse(&format!("https://{ok}/")).unwrap(); + assert!(prod_topics_host(&url), "{ok}"); + } + for bad in [ + "http://open11.connect.trimble.com/", + "https://open11.connect.trimble.com:8443/", + "https://evil.connect.trimble.com/", + "https://open11.connect.trimble.com.evil.example/", + "https://user:pw@open11.connect.trimble.com/", + "https://app.connect.trimble.com/", + "http://127.0.0.1/", + ] { + assert!( + !prod_topics_host(&url::Url::parse(bad).unwrap()), + "{bad} must not be allowed" + ); + } + } + + #[tokio::test] + async fn bad_input_is_refused_before_the_credential_is_needed() { + let agents = agents_with( + &format!( + "agent: trimble-connect\nversion: 0.1.0\ndescription: x\nstateful: false\n\ + license: MIT\ntransport:\n rest:\n base: {}/\nauth:\n scheme: oauth2\n \ + secret: mock-tc-tok\ncommands: {{}}\n", + dead_base() + ), + None, + ); + let err = invoke( + AgentCatalogue::from(agents.path().join("agents")), + "create-topic", + json!({"project-id": "P"}), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("`title`"), "{err}"); + } + + #[tokio::test] + async fn a_list_resolves_the_region_then_pages_with_top_and_skiptoken() { + let (base, rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(( + "GET /bcf/2.1/projects/P/topics", + 200, + vec![( + "next", + format!("{b}/bcf/2.1/projects/P/topics?top=2&skiptoken=q1%2Bby"), + )], + format!("[{TOPIC}]").into_bytes(), + )); + r + }); + let out = run( + &base, + "list-topics", + json!({"project-id": "P", "top": "2", "skiptoken": "a b"}), + ) + .await + .unwrap(); + assert_eq!(out["items"][0]["guid"], "T1"); + assert_eq!(out["next-skiptoken"], "q1+by"); + let project = next_request(&rx); + assert!(line(&project).starts_with("GET /projects/P "), "{project}"); + assert!(project.contains("Bearer TESTTOKEN"), "{project}"); + let regions = next_request(&rx); + assert!(line(®ions).starts_with("GET /regions"), "{regions}"); + assert!( + !regions.contains("Authorization"), + "regions is public — no token: {regions}" + ); + let list = next_request(&rx); + assert!( + line(&list).contains("/bcf/2.1/projects/P/topics?top=2&skiptoken=a%20b"), + "{list}" + ); + assert!(list.contains("Bearer TESTTOKEN"), "{list}"); + } + + #[tokio::test] + async fn an_empty_list_and_the_last_page_have_no_next_skiptoken() { + let (base, _rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(( + "GET /bcf/2.1/projects/P/topics/T/comments", + 204, + vec![], + vec![], + )); + r + }); + let out = run( + &base, + "list-comments", + json!({"project-id": "P", "topic-id": "T"}), + ) + .await + .unwrap(); + assert_eq!(out, json!({"items": [], "next-skiptoken": null})); + } + + #[tokio::test] + async fn a_next_link_without_a_skiptoken_is_an_error_not_a_last_page() { + let (base, _rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(( + "GET /bcf/2.1/projects/P/topics/T/viewpoints", + 200, + vec![( + "next", + "/bcf/2.1/projects/P/topics/T/viewpoints?page=2".into(), + )], + b"[]".to_vec(), + )); + r + }); + let err = run( + &base, + "list-viewpoints", + json!({"project-id": "P", "topic-id": "T"}), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("carries no skiptoken"), "{err}"); + } + + #[tokio::test] + async fn the_token_is_never_sent_to_a_host_outside_the_policy() { + // The production policy: the regions list names the mock (http, + // loopback), which is not a published Topics host — refused before any + // BCF request. + let (base, rx) = mock(4, discovery); + let agents = mock_agents(&base); + let err = invoke( + AgentCatalogue::from(agents.path().join("agents")), + "list-topics", + json!({"project-id": "P"}), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("not a known"), "{err}"); + let seen = drain(&rx); + assert_eq!(seen.len(), 2, "project + regions only: {seen:#?}"); + + // A project whose region is missing from the list is an error too. + let (base, _rx) = mock(4, |b| { + let mut r = discovery(b); + r[0] = json_route("GET /projects/P ", 200, r#"{"id":"P","location":"mars"}"#); + r + }); + let err = run(&base, "list-topics", json!({"project-id": "P"})) + .await + .unwrap_err(); + assert!(err.to_string().contains("no Topics API"), "{err}"); + } + + #[tokio::test] + async fn a_redirect_is_never_followed() { + let (base, rx) = mock(5, |b| { + let mut r = discovery(b); + r.push(( + "GET /bcf/2.1/projects/P/topics/T ", + 302, + vec![("Location", format!("{b}/elsewhere"))], + vec![], + )); + r.push(json_route("GET /elsewhere", 200, TOPIC)); + r + }); + let err = run( + &base, + "get-topic", + json!({"project-id": "P", "topic-id": "T"}), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("redirects are not followed"), + "{err}" + ); + let seen = drain(&rx); + assert_eq!( + seen.len(), + 3, + "no request to the redirect target: {seen:#?}" + ); + + // Discovery itself does not follow one either. + let (base, rx) = mock(4, |b| { + vec![( + "GET /projects/P ", + 301, + vec![("Location", format!("{b}/projects/Q"))], + vec![], + )] + }); + let err = run(&base, "list-topics", json!({"project-id": "P"})) + .await + .unwrap_err(); + assert!( + err.to_string().contains("redirects are not followed"), + "{err}" + ); + assert_eq!(drain(&rx).len(), 1); + } + + #[tokio::test] + async fn create_topic_posts_snake_case_and_reports_the_guid() { + let (base, rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(json_route("POST /bcf/2.1/projects/P/topics ", 201, TOPIC)); + r + }); + let out = run( + &base, + "create-topic", + json!({"project-id": "P", "title": "Clash at B4", "topic-type": "Clash", + "labels": "[\"steel\"]", "assigned-to": "a@b.c", "due-date": "", + "guid": "T1"}), + ) + .await + .unwrap(); + assert_eq!(out["topic-id"], "T1"); + let seen = drain(&rx); + assert_eq!( + body_of(&seen[2]), + json!({"guid": "T1", "title": "Clash at B4", "topic_type": "Clash", + "assigned_to": "a@b.c", "labels": ["steel"]}) + ); + } + + #[tokio::test] + async fn update_topic_resends_every_writable_field_with_the_changes_laid_over() { + let full = r#"{"guid":"T1","version":3,"creation_date":"d","creation_author":"a", + "creation_author_uuid":"u","modified_date":"m","viewpoint":{"guid":"V"}, + "files":[],"related_topics":[], + "topic_type":"Clash","topic_status":"Open","reference_links":["r"],"title":"Old", + "priority":"High","index":7,"labels":["a"],"assigned_to":"x@y.z", + "assigned_to_uuid":"xu","assignees":[{"id":"x"}],"stage":"S1","description":"D", + "due_date":"2026-12-01","marker":{"m":1},"bim_snippet":{"s":1}}"#; + let (base, rx) = mock(5, move |b| { + let mut r = discovery(b); + r.push(json_route("GET /bcf/2.1/projects/P/topics/T1 ", 200, full)); + r.push(json_route("PUT /bcf/2.1/projects/P/topics/T1 ", 200, TOPIC)); + r + }); + let out = run( + &base, + "update-topic", + json!({"project-id": "P", "topic-id": "T1", "title": "New", + "due-date": null, "labels": null, "stage": ""}), + ) + .await + .unwrap(); + assert_eq!(out["changed"], json!(true)); + let seen = drain(&rx); + assert_eq!(seen.len(), 4, "{seen:#?}"); + assert!(line(&seen[3]).starts_with("PUT "), "{}", seen[3]); + // Every PUT-schema field the topic had, untouched ones included; none of + // the server-owned fields (guid, version, creation_*, viewpoint, …). + assert_eq!( + body_of(&seen[3]), + json!({"topic_type": "Clash", "topic_status": "Open", "reference_links": ["r"], + "title": "New", "priority": "High", "index": 7, "labels": [], + "assigned_to": "x@y.z", "assigned_to_uuid": "xu", + "assignees": [{"id": "x"}], "stage": "S1", "description": "D", + "due_date": null, "marker": {"m": 1}, "bim_snippet": {"s": 1}}) + ); + } + + #[tokio::test] + async fn update_topic_writes_nothing_when_nothing_changes() { + let (base, rx) = mock(5, |b| { + let mut r = discovery(b); + r.push(json_route("GET /bcf/2.1/projects/P/topics/T1 ", 200, TOPIC)); + r + }); + let out = run( + &base, + "update-topic", + json!({"project-id": "P", "topic-id": "T1", "topic-status": "Open"}), + ) + .await + .unwrap(); + assert_eq!(out["changed"], json!(false)); + assert_eq!(out["topic-id"], "T1"); + assert_eq!(drain(&rx).len(), 3, "no PUT"); + } + + #[tokio::test] + async fn a_viewpoint_carries_its_snapshot_and_the_snapshot_comes_back_as_base64() { + let image = png(); + let cam = camera(); + let (base, rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(json_route( + "POST /bcf/2.1/projects/P/topics/T/viewpoints ", + 201, + r#"{"guid":"V1"}"#, + )); + r + }); + let out = run( + &base, + "create-viewpoint", + json!({"project-id": "P", "topic-id": "T", + "perspective-camera": cam.to_string(), "snapshot": b64(&image)}), + ) + .await + .unwrap(); + assert_eq!(out["viewpoint-id"], "V1"); + let seen = drain(&rx); + assert_eq!( + body_of(&seen[2]), + json!({"perspective_camera": cam, + "snapshot": {"snapshot_type": "png", "snapshot_data": b64(&image)}}) + ); + + let served = image.clone(); + let (base, _rx) = mock(4, move |b| { + let mut r = discovery(b); + r.push(( + "GET /bcf/2.1/projects/P/topics/T/viewpoints/V1/snapshot", + 200, + vec![("Content-Type", "image/png".into())], + served, + )); + r + }); + let out = run( + &base, + "get-viewpoint-snapshot", + json!({"project-id": "P", "topic-id": "T", "viewpoint-id": "V1"}), + ) + .await + .unwrap(); + assert_eq!(out["bytes"], b64(&image)); + assert_eq!(out["content-type"], "image/png"); + } + + #[tokio::test] + async fn todos_speak_camel_case_on_the_core_base() { + let todo = r#"{"id":"D1","title":"t","description":"d","status":"NEW"}"#; + let (base, rx) = mock(2, move |_b| vec![json_route("POST /todos ", 201, todo)]); + let out = run( + &base, + "create-todo", + json!({"project-id": "P", "description": "d", "title": "t", "status": "new", + "priority": "high", "due-date": "2026-12-01T00:00:00Z", + "percent-complete": "40", + "assignees": ["u1", {"id": "g1", "type": "group"}]}), + ) + .await + .unwrap(); + assert_eq!(out["todo-id"], "D1"); + let req = next_request(&rx); + assert!(req.contains("Bearer TESTTOKEN"), "{req}"); + assert_eq!( + body_of(&req), + json!({"title": "t", "description": "d", "dueDate": "2026-12-01T00:00:00Z", + "status": "NEW", "priority": "HIGH", "percentComplete": 40, + "assignees": [{"id": "u1", "type": "USER"}, {"id": "g1", "type": "GROUP"}], + "projectId": "P"}) + ); + + let (base, rx) = mock(2, move |_b| vec![json_route("PATCH /todos/D1 ", 200, todo)]); + run( + &base, + "update-todo", + json!({"todo-id": "D1", "status": "RESOLVED", "label": null, "assignees": null}), + ) + .await + .unwrap(); + assert_eq!( + body_of(&next_request(&rx)), + json!({"label": null, "status": "RESOLVED", "assignees": []}) + ); + + let (base, rx) = mock(2, |_b| vec![json_route("DELETE /todos/D1 ", 204, "")]); + let out = run(&base, "delete-todo", json!({"todo-id": "D1"})) + .await + .unwrap(); + assert_eq!(out, json!({"todo-id": "D1", "deleted": true})); + assert!(next_request(&rx).contains("Bearer TESTTOKEN")); + } + + #[tokio::test] + async fn list_todos_filters_by_file_and_pages_with_range() { + let (base, rx) = mock(2, |_b| { + vec![( + "GET /todos?", + 206, + vec![("Content-Range", "items 0-1/5".into())], + br#"[{"id":"a"},{"id":"b"}]"#.to_vec(), + )] + }); + let out = run( + &base, + "list-todos", + json!({"project-id": "P 1", "file-id": "F", "version-id": "V", + "range": "items=0-1"}), + ) + .await + .unwrap(); + assert_eq!(out["partial"], json!(true)); + assert_eq!(out["content-range"], "items 0-1/5"); + let req = next_request(&rx); + assert!( + line(&req).contains("/todos?projectId=P%201&fileId=F&versionId=V"), + "{req}" + ); + assert!(req.contains("Range: items=0-1"), "{req}"); + } + + #[tokio::test] + async fn tc_refusals_name_the_step_and_status() { + let (base, _rx) = mock(4, |b| { + let mut r = discovery(b); + r.push(json_route( + "POST /bcf/2.1/projects/P/topics/T/comments", + 403, + r#"{"message":"no access"}"#, + )); + r + }); + let err = run( + &base, + "create-comment", + json!({"project-id": "P", "topic-id": "T", "comment": "hi"}), + ) + .await + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("create comment") && text.contains("403") && text.contains("no access"), + "{text}" + ); + // A 2xx without the identifier a downstream node needs is a failure. + let (base, _rx) = mock(2, |_b| { + vec![json_route("POST /todos ", 201, r#"{"title":"t"}"#)] + }); + let err = run( + &base, + "create-todo", + json!({"project-id": "P", "description": "d"}), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("no id"), "{err}"); + } + + /// Every command this module handles, run once against the wire mock, emits + /// only keys its manifest output schema declares — and none is skipped. + #[tokio::test] + async fn every_key_a_handler_emits_is_declared_in_the_manifest() { + let mut ortho = camera(); + ortho.as_object_mut().unwrap().remove("field_of_view"); + ortho["view_to_world_scale"] = json!(2); + let pt = |extra: Value| { + let mut a = json!({"project-id": "P", "topic-id": "T"}); + a.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + a + }; + let cases: Vec<(&str, Value)> = vec![ + ("list-topics", json!({"project-id": "P"})), + ("get-topic", pt(json!({}))), + ("create-topic", json!({"project-id": "P", "title": "t"})), + ("update-topic", pt(json!({"title": "changed"}))), + ("delete-topic", pt(json!({}))), + ("list-comments", pt(json!({}))), + ("create-comment", pt(json!({"comment": "c"}))), + ("list-viewpoints", pt(json!({}))), + ("create-viewpoint", pt(json!({"orthogonal-camera": ortho}))), + ("get-viewpoint-snapshot", pt(json!({"viewpoint-id": "V"}))), + ("list-document-references", pt(json!({}))), + ("create-document-reference", pt(json!({"url": "https://x"}))), + ("list-todos", json!({"project-id": "P"})), + ( + "create-todo", + json!({"project-id": "P", "description": "d"}), + ), + ("update-todo", json!({"todo-id": "D", "title": "t"})), + ("delete-todo", json!({"todo-id": "D"})), + ]; + let exercised: std::collections::BTreeSet<&str> = cases.iter().map(|(c, _)| *c).collect(); + let owned: std::collections::BTreeSet<&str> = COMMANDS.iter().copied().collect(); + assert_eq!(exercised, owned, "every handled command is exercised here"); + for (cmd, args) in cases { + let image = png(); + let (base, _rx) = mock(6, move |b| { + let mut r = discovery(b); + r.push(( + "/snapshot", + 200, + vec![("Content-Type", "image/png".into())], + image, + )); + r.push(json_route("GET /bcf/2.1/projects/P/topics/T ", 200, TOPIC)); + r.push(json_route("PUT /bcf/2.1/projects/P/topics/T ", 200, TOPIC)); + r.push(json_route("DELETE /bcf/", 204, "")); + r.push(json_route("GET /bcf/", 200, "[]")); + r.push(json_route("POST /bcf/", 201, r#"{"guid":"G"}"#)); + r.push(json_route("GET /todos?", 200, "[]")); + r.push(json_route("DELETE /todos/", 204, "")); + r.push(json_route("/todos", 200, r#"{"id":"D"}"#)); + r + }); + let out = run(&base, cmd, args) + .await + .unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert_declared(cmd, &out); + } + } +} diff --git a/cli/src/runtime/trimble_ops.rs b/cli/src/runtime/trimble_ops.rs index 4031a843b..530e03013 100644 --- a/cli/src/runtime/trimble_ops.rs +++ b/cli/src/runtime/trimble_ops.rs @@ -35,9 +35,11 @@ const COMMANDS: &[&str] = &[ "copy-file", ]; -/// Whether `command` is a trimble-connect command handled here. +/// Whether `command` is a trimble-connect command handled here — including the +/// BCF topic and ToDo commands of [`super::trimble_bcf`], which this module +/// dispatches to. pub fn handles(command: &str) -> bool { - COMMANDS.contains(&command) + COMMANDS.contains(&command) || super::trimble_bcf::handles(command) } /// Run one of [`COMMANDS`]. Blocking HTTP runs off the reactor. @@ -47,6 +49,9 @@ pub async fn invoke( args: Value, ) -> Result { let catalogue = catalogue.into(); + if super::trimble_bcf::handles(command) { + return super::trimble_bcf::invoke(catalogue, command, args).await; + } let command = command.to_string(); tokio::task::spawn_blocking(move || invoke_blocking(&catalogue, &command, &args)) .await @@ -455,7 +460,7 @@ fn list_folder_by_path( /// The optional `range` input: TC's `items=-` (zero-based, /// inclusive). Anything else is refused rather than sent for TC to ignore. -fn item_range(args: &Value) -> Result, AwareError> { +pub(super) fn item_range(args: &Value) -> Result, AwareError> { let Some(r) = opt_str(args, "range")? else { return Ok(None); }; @@ -644,7 +649,7 @@ fn invalid(msg: String) -> AwareError { } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use crate::runtime::trimble_files::test_support::*; @@ -667,7 +672,7 @@ mod tests { #[test] fn handles_exactly_the_management_commands() { - for c in COMMANDS { + for c in COMMANDS.iter().chain(super::super::trimble_bcf::COMMANDS) { assert!(handles(c), "{c}"); } // upload/download stay with trimble_files; the declarative reads stay REST. @@ -677,6 +682,7 @@ mod tests { "list-projects", "list-folders", "get-folder", + "get-todo", ] { assert!(!handles(c), "{c}"); } @@ -698,6 +704,7 @@ mod tests { .collect(); let undeclared = COMMANDS .iter() + .chain(super::super::trimble_bcf::COMMANDS) .filter(|c| { manifest .commands @@ -742,6 +749,13 @@ mod tests { manifest_handler_drift(&undeclared).1, vec!["delete-file".to_string()] ); + // A BCF / ToDo handler command the manifest drops is caught the same way. + let mut undeclared_bcf = load(); + undeclared_bcf.commands.remove("update-topic"); + assert_eq!( + manifest_handler_drift(&undeclared_bcf).1, + vec!["update-topic".to_string()] + ); let mut given_a_method = load(); given_a_method.commands.get_mut("copy-file").unwrap().method = Some("POST".into()); assert_eq!( @@ -1028,7 +1042,7 @@ mod tests { /// Every key in `out` is one the manifest declares for `command` — the /// lockfile compiler checks `{{ node.key }}` references and `--simulate` /// builds outputs from that schema, so an undeclared key is unusable. - fn assert_declared(command: &str, out: &Value) { + pub(in crate::runtime) fn assert_declared(command: &str, out: &Value) { let declared = declared_output_keys(command); let undeclared: Vec<&String> = out .as_object() From eb4b4602bd5e828701ee739dfd7e286481214ccb Mon Sep 17 00:00:00 2001 From: Pawel Date: Sun, 11 Oct 2026 08:26:50 +0200 Subject: [PATCH 02/20] fix(trimble-connect): clear rules + ToDo dates + snapshot type as verified live on Take 5 --- .../trimble-connect/commands/create-todo.md | 2 +- .../trimble-connect/commands/update-todo.md | 9 +- .../trimble-connect/commands/update-topic.md | 15 +-- .../trimble-connect/manifest.yaml | 20 ++-- cli/src/runtime/trimble_bcf.rs | 110 +++++++++++++++--- 5 files changed, 118 insertions(+), 38 deletions(-) diff --git a/20-agents/aeco/construction/trimble-connect/commands/create-todo.md b/20-agents/aeco/construction/trimble-connect/commands/create-todo.md index 8174e2c10..4fc04fdda 100644 --- a/20-agents/aeco/construction/trimble-connect/commands/create-todo.md +++ b/20-agents/aeco/construction/trimble-connect/commands/create-todo.md @@ -17,7 +17,7 @@ user ids (sent as `{id, type: USER}`) or `{id, type: USER|GROUP}` objects. | `title` | string (optional) | Short title. | | `type` | string (optional) | A ToDo type of the project (e.g. Request, Issue). | | `label` | string (optional) | Label, unique within the project. | -| `due-date` | string (optional) | ISO 8601 date-time. | +| `due-date` | string (optional) | ISO 8601 date or date-time (TC keeps the date). `Z` is sent as `+0000`. | | `percent-complete` | int (optional) | Progress, 0-100. | | `status` | `NEW` \| `IN_PROGRESS` \| `RESOLVED` \| `BLOCKED` \| `CLOSED` (optional) | Status. | | `priority` | `LOW` \| `NORMAL` \| `HIGH` \| `CRITICAL` (optional) | Priority. | diff --git a/20-agents/aeco/construction/trimble-connect/commands/update-todo.md b/20-agents/aeco/construction/trimble-connect/commands/update-todo.md index ed792b082..0a6b6ab2c 100644 --- a/20-agents/aeco/construction/trimble-connect/commands/update-todo.md +++ b/20-agents/aeco/construction/trimble-connect/commands/update-todo.md @@ -1,8 +1,9 @@ # `trimble-connect.update-todo` — change some fields of a ToDo Write command. TC's `PATCH` changes only the fields sent. An input left out or `""` is -not a change; an explicit `null` clears `label` or `due-date`, and empties `assignees` -(`title` and `description` cannot be cleared). The Core definition declares no +not a change; an explicit `null` clears `due-date` and empties `assignees` (`title`, +`description` and `label` cannot be cleared — TC keeps a label once set). A due date ending +in `Z` is sent as `+0000`, which is the form TC accepts. The Core definition declares no `If-Match` for ToDos. ## Lifecycle @@ -17,8 +18,8 @@ not a change; an explicit `null` clears `label` or `due-date`, and empties `assi | `description` | string (optional) | New description. | | `title` | string (optional) | New title. | | `type` | string (optional) | New type. | -| `label` | string (optional) | New label; null clears. | -| `due-date` | string (optional) | New due date; null clears. | +| `label` | string (optional) | New label (TC keeps a label once set; it cannot be cleared). | +| `due-date` | string (optional) | New due date (ISO 8601; TC keeps the date); null clears. | | `percent-complete` | int (optional) | Progress, 0-100. | | `status` | `NEW` \| `IN_PROGRESS` \| `RESOLVED` \| `BLOCKED` \| `CLOSED` (optional) | New status. | | `priority` | `LOW` \| `NORMAL` \| `HIGH` \| `CRITICAL` (optional) | New priority. | diff --git a/20-agents/aeco/construction/trimble-connect/commands/update-topic.md b/20-agents/aeco/construction/trimble-connect/commands/update-topic.md index 8906b1476..a601ba18b 100644 --- a/20-agents/aeco/construction/trimble-connect/commands/update-topic.md +++ b/20-agents/aeco/construction/trimble-connect/commands/update-topic.md @@ -1,12 +1,13 @@ # `trimble-connect.update-topic` — change some fields of a BCF topic -Write command. TC's `PUT topic` replaces the **whole** topic, so the topic is read first and -every field of the PUT schema it carries (`topic_type`, `topic_status`, `reference_links`, +Write command. BCF's `PUT topic` is specified to replace the **whole** topic, so the topic +is read first and every field of the PUT schema it carries (`topic_type`, `topic_status`, `reference_links`, `title`, `priority`, `index`, `labels`, `assigned_to`, `assigned_to_uuid`, `assignees`, `stage`, `description`, `due_date`, `marker`, `bim_snippet`) is sent back with your changes laid over it — a field you do not name survives. An input left out or `""` -is not a change; an explicit `null` clears `description`, `assigned-to`, `due-date`, -`stage` or `labels` (`title` cannot be cleared). When the result equals the topic as +is not a change; an explicit `null` clears `description` (sent as `""`) or `labels` (sent +as `[]`). Verified live: Trimble Connect ignores `null` in a topic PUT and offers no way to +clear `due-date`, `assigned-to` or `stage`, so a `null` there (or on `title`) is refused. When the result equals the topic as read, nothing is written and `changed` is `false`. **Concurrency:** BCF 2.1 on Trimble Connect offers no `If-Match` / ETag, so an edit @@ -28,9 +29,9 @@ someone else makes between this command's read and its write is overwritten. | `topic-status` | string (optional) | New status. | | `priority` | string (optional) | New priority. | | `labels` | list (optional) | Replacement labels; null clears. | -| `assigned-to` | string (optional) | New assignee email; null clears. | -| `due-date` | string (optional) | New due date; null clears. | -| `stage` | string (optional) | New stage; null clears. | +| `assigned-to` | string (optional) | New assignee email (cannot be cleared). | +| `due-date` | string (optional) | New due date (cannot be cleared). | +| `stage` | string (optional) | New stage (cannot be cleared). | The agent authenticates with the single `trimble-connect` credential from `aware connect trimble-connect` (the token is refreshed automatically, #198). diff --git a/20-agents/aeco/construction/trimble-connect/manifest.yaml b/20-agents/aeco/construction/trimble-connect/manifest.yaml index 394482703..6c7f7908e 100644 --- a/20-agents/aeco/construction/trimble-connect/manifest.yaml +++ b/20-agents/aeco/construction/trimble-connect/manifest.yaml @@ -642,9 +642,9 @@ commands: lifecycle: single mode: write description: | - Change some fields of a BCF topic. TC's PUT replaces the whole topic, so the - topic is read first and every field you don't name is sent back unchanged. - `null` clears description, assigned-to, due-date, stage or labels. When + Change some fields of a BCF topic. The topic is read first and every field + you don't name is sent back unchanged. `null` clears description or labels + (Trimble Connect offers no way to clear due-date, assigned-to or stage). When nothing would change, nothing is written (`changed: false`). BCF 2.1 on TC has no If-Match: an edit made elsewhere between the read and the write is overwritten. @@ -682,15 +682,15 @@ commands: assigned-to: type: string required: false - description: New assignee email; null clears. + description: New assignee email (cannot be cleared). due-date: type: string required: false - description: New due date; null clears. + description: New due date (cannot be cleared). stage: type: string required: false - description: New stage; null clears. + description: New stage (cannot be cleared). outputs: type: single schema: @@ -1029,7 +1029,7 @@ commands: due-date: type: string required: false - description: ISO 8601 date-time. + description: ISO 8601 date or date-time (TC keeps the date). `Z` is sent as `+0000`. percent-complete: type: int required: false @@ -1057,7 +1057,7 @@ commands: update-todo: lifecycle: single mode: write - description: Change some fields of a ToDo. `null` clears label, due-date or assignees. + description: Change some fields of a ToDo. `null` clears due-date or assignees. inputs: todo-id: type: string @@ -1077,11 +1077,11 @@ commands: label: type: string required: false - description: New label; null clears. + description: New label (TC keeps a label once set; it cannot be cleared). due-date: type: string required: false - description: New due date; null clears. + description: New due date (ISO 8601; TC keeps the date); null clears. percent-complete: type: int required: false diff --git a/cli/src/runtime/trimble_bcf.rs b/cli/src/runtime/trimble_bcf.rs index 371cb67e1..1ae2a0122 100644 --- a/cli/src/runtime/trimble_bcf.rs +++ b/cli/src/runtime/trimble_bcf.rs @@ -241,8 +241,13 @@ const TOPIC_STRING_FIELDS: &[(&str, &str)] = &[ ("stage", "stage"), ]; -/// The topic fields `update-topic` may clear with an explicit `null`. -const TOPIC_CLEARABLE: &[&str] = &["description", "assigned-to", "due-date", "stage", "labels"]; +/// The topic fields `update-topic` may clear with an explicit `null`. Verified +/// live against Trimble Connect: its `PUT topic` ignores a `null` (and an +/// omitted key) rather than clearing, `description: ""` clears, `labels: []` +/// empties, and there is no way to clear `due_date` (`""` is ignored) or +/// `assigned_to` (`""` is refused) — so only these two are clearable, and a +/// `null` is sent as the value that actually clears. +const TOPIC_CLEARABLE: &[&str] = &["description", "labels"]; /// ToDo status / priority values — fixed enums in the Core definition. (ToDo /// *types* are per project — `GET /todos/types` — so they pass through.) @@ -259,7 +264,9 @@ const TODO_STRING_FIELDS: &[(&str, &str)] = &[ ]; /// The ToDo fields `update-todo` may clear with an explicit `null`. -const TODO_CLEARABLE: &[&str] = &["label", "due-date", "assignees"]; +/// (`label` is not: TC answers a `null` label by keeping the auto-assigned one — +/// verified live.) +const TODO_CLEARABLE: &[&str] = &["due-date", "assignees"]; impl Op { fn parse(command: &str, args: &Value) -> Result { @@ -325,7 +332,7 @@ impl Op { match tri_str(args, input)? { Tri::Absent => {} Tri::Null if TOPIC_CLEARABLE.contains(input) => { - changes.insert((*wire).into(), Value::Null); + changes.insert((*wire).into(), json!("")); } Tri::Null => { return Err(invalid(format!("`{input}` cannot be cleared"))); @@ -492,7 +499,11 @@ fn todo_fields(args: &Value, update: bool) -> Result, AwareEr if matches!(*input, "title" | "description") && v.trim().is_empty() { return Err(invalid(format!("`{input}` is blank"))); } - body.insert((*wire).into(), json!(v)); + if *input == "due-date" { + body.insert((*wire).into(), json!(tc_datetime(v))); + } else { + body.insert((*wire).into(), json!(v)); + } } } } @@ -526,6 +537,27 @@ fn todo_fields(args: &Value, update: bool) -> Result, AwareEr Ok(body) } +/// An ISO 8601 date-time as the Core API accepts it. TC refuses a `Z` suffix +/// and fractional seconds ("Invalid date format") but takes `+0000` and +/// `±hh:mm` offsets and a bare date — verified live — so `Z` becomes `+0000` +/// and a fraction is dropped. Anything else passes through for TC to judge. +fn tc_datetime(v: &str) -> String { + let mut t = v.trim().to_string(); + if t.ends_with(['Z', 'z']) { + t.pop(); + t.push_str("+0000"); + } + if let Some(dot) = t.find('.') { + let digits = t[dot + 1..] + .find(|c: char| !c.is_ascii_digit()) + .map_or(t.len(), |i| dot + 1 + i); + if digits > dot + 1 { + t.replace_range(dot..digits, ""); + } + } + t +} + /// `assignees`: a list whose items are a user id (→ `{id, type: USER}`) or an /// `{id, type: USER|GROUP}` object. A JSON-encoded list (what `{{ }}` renders a /// list to) is accepted too. @@ -1048,9 +1080,10 @@ fn get_topic(tc: &Client, base: &str, topic: &str) -> Result ) } -/// `update-topic`. BCF's `PUT topic` replaces the whole topic, so the current -/// topic is read first and every field of the PUT schema it carries is sent -/// back with the caller's changes laid over it — an untouched field survives. +/// `update-topic`. BCF's `PUT topic` is specified to replace the whole topic +/// (TC's in practice keeps a field the body leaves out), so the current topic is +/// read first and every field of the PUT schema it carries is sent back with the +/// caller's changes laid over it — an untouched field survives either way. /// When the changes leave the topic as it is, nothing is written /// (`changed: false`). BCF 2.1 on TC has no `If-Match`, so an edit another /// client makes between the read and the write is overwritten. @@ -1106,7 +1139,7 @@ fn snapshot(tc: &Client, base: &str, topic: &str, viewpoint: &str) -> Result Result Some("image/png".to_string()), + Some(_) => Some("image/jpeg".to_string()), + None => header_type, + }; Ok(json!({ "bytes": base64::engine::general_purpose::STANDARD.encode(&bytes), "encoding": "base64", @@ -1504,6 +1544,16 @@ mod tests { pt(json!({"topic-status": null})), "cannot be cleared", ), + ( + "update-topic", + pt(json!({"due-date": null})), + "`due-date` cannot be cleared", + ), + ( + "update-topic", + pt(json!({"assigned-to": null})), + "`assigned-to` cannot be cleared", + ), ( "update-topic", pt(json!({"guid": "G", "title": "x"})), @@ -1632,6 +1682,11 @@ mod tests { json!({"todo-id": "D", "description": null}), "cannot be cleared", ), + ( + "update-todo", + json!({"todo-id": "D", "label": null}), + "`label` cannot be cleared", + ), ( "update-todo", json!({"todo-id": "D", "project-id": "P2", "title": "x"}), @@ -1920,7 +1975,7 @@ mod tests { &base, "update-topic", json!({"project-id": "P", "topic-id": "T1", "title": "New", - "due-date": null, "labels": null, "stage": ""}), + "description": null, "labels": null, "stage": ""}), ) .await .unwrap(); @@ -1935,8 +1990,8 @@ mod tests { json!({"topic_type": "Clash", "topic_status": "Open", "reference_links": ["r"], "title": "New", "priority": "High", "index": 7, "labels": [], "assigned_to": "x@y.z", "assigned_to_uuid": "xu", - "assignees": [{"id": "x"}], "stage": "S1", "description": "D", - "due_date": null, "marker": {"m": 1}, "bim_snippet": {"s": 1}}) + "assignees": [{"id": "x"}], "stage": "S1", "description": "", + "due_date": "2026-12-01", "marker": {"m": 1}, "bim_snippet": {"s": 1}}) ); } @@ -1994,7 +2049,8 @@ mod tests { r.push(( "GET /bcf/2.1/projects/P/topics/T/viewpoints/V1/snapshot", 200, - vec![("Content-Type", "image/png".into())], + // Mislabelled, as TC was seen doing: the bytes decide. + vec![("Content-Type", "image/jpeg".into())], served, )); r @@ -2029,7 +2085,7 @@ mod tests { assert!(req.contains("Bearer TESTTOKEN"), "{req}"); assert_eq!( body_of(&req), - json!({"title": "t", "description": "d", "dueDate": "2026-12-01T00:00:00Z", + json!({"title": "t", "description": "d", "dueDate": "2026-12-01T00:00:00+0000", "status": "NEW", "priority": "HIGH", "percentComplete": 40, "assignees": [{"id": "u1", "type": "USER"}, {"id": "g1", "type": "GROUP"}], "projectId": "P"}) @@ -2039,13 +2095,13 @@ mod tests { run( &base, "update-todo", - json!({"todo-id": "D1", "status": "RESOLVED", "label": null, "assignees": null}), + json!({"todo-id": "D1", "status": "RESOLVED", "due-date": null, "assignees": null}), ) .await .unwrap(); assert_eq!( body_of(&next_request(&rx)), - json!({"label": null, "status": "RESOLVED", "assignees": []}) + json!({"dueDate": null, "status": "RESOLVED", "assignees": []}) ); let (base, rx) = mock(2, |_b| vec![json_route("DELETE /todos/D1 ", 204, "")]); @@ -2056,6 +2112,28 @@ mod tests { assert!(next_request(&rx).contains("Bearer TESTTOKEN")); } + #[test] + fn todo_dates_are_sent_in_the_form_tc_accepts() { + for (given, sent) in [ + ("2026-12-31T00:00:00Z", "2026-12-31T00:00:00+0000"), + ("2026-12-31T00:00:00.000Z", "2026-12-31T00:00:00+0000"), + ("2026-12-31T10:00:00.5+01:00", "2026-12-31T10:00:00+01:00"), + ("2026-12-31T00:00:00+0000", "2026-12-31T00:00:00+0000"), + ("2026-12-31", "2026-12-31"), + ] { + assert_eq!(tc_datetime(given), sent, "{given}"); + } + let op = Op::parse( + "update-todo", + &json!({"todo-id": "D", "due-date": "2026-12-31T00:00:00.000Z"}), + ) + .unwrap(); + let Op::UpdateTodo { changes, .. } = op else { + panic!("{op:?}") + }; + assert_eq!(changes["dueDate"], "2026-12-31T00:00:00+0000"); + } + #[tokio::test] async fn list_todos_filters_by_file_and_pages_with_range() { let (base, rx) = mock(2, |_b| { From 09845277bb29ad8a911b413cbc96358ea245db45 Mon Sep 17 00:00:00 2001 From: Pawel Date: Sun, 11 Oct 2026 08:30:17 +0200 Subject: [PATCH 03/20] docs(trimble-connect): correct BCF/ToDo skills against the published definitions + live behaviour --- .../trimble-connect/skills/files.md | 7 +- .../trimble-connect/skills/projects.md | 124 +++++++----------- .../trimble-connect/skills/topics.md | 69 ++++++++-- .../trimble-connect/skills/viewpoints.md | 25 +++- 40-diagrams/substrate-playground.html | 2 +- 5 files changed, 140 insertions(+), 87 deletions(-) diff --git a/20-agents/aeco/construction/trimble-connect/skills/files.md b/20-agents/aeco/construction/trimble-connect/skills/files.md index f636f2d68..62901e069 100644 --- a/20-agents/aeco/construction/trimble-connect/skills/files.md +++ b/20-agents/aeco/construction/trimble-connect/skills/files.md @@ -485,9 +485,12 @@ foreach (var item in doc.RootElement.EnumerateArray()) // 1. Upload file using the 3-step package upload (see above) // ... get fileId from upload completion ... -// 2. Create document reference on the topic (BCF API uses a different base URL) +// 2. Create document reference on the topic (BCF API uses a different, regional base URL +// — see topics.md). `document_guid` names a *BCF project document* +// (POST {bcfBaseUrl}/projects/{projectId}/documents), not a Files API id; to point at +// a Connect file, reference it by `url`. AWARE: `create-document-reference`. var refUrl = $"{bcfBaseUrl}/projects/{projectId}/topics/{topicId}/document_references"; -var refPayload = new { document_guid = fileId, description = "Clash detection report" }; +var refPayload = new { url = fileWebUrl, description = "Clash detection report" }; var refContent = new StringContent( JsonSerializer.Serialize(refPayload), System.Text.Encoding.UTF8, "application/json"); diff --git a/20-agents/aeco/construction/trimble-connect/skills/projects.md b/20-agents/aeco/construction/trimble-connect/skills/projects.md index e51e00e38..ebb060589 100644 --- a/20-agents/aeco/construction/trimble-connect/skills/projects.md +++ b/20-agents/aeco/construction/trimble-connect/skills/projects.md @@ -194,103 +194,77 @@ GETfiles/{fileId} ## ToDos (Task Management) -### List ToDos - -``` -GETprojects/{projectId}/todos -``` +ToDos are **Core API 2.0** resources at the top level — `/todos`, filtered by a `projectId` +query parameter — not nested under `/projects/{id}`. (Checked against Trimble's published +definition `api.swaggerhub.com/apis/Trimble-Connect/tcps/2.0` and live, 2026-10-11.) + +| Operation | Endpoint | AWARE command | +|---|---|---| +| List | `GET /todos?projectId={id}[&fileId=…[&versionId=…]]` — 206 + `Content-Range`; send `Range: items=a-b` to page | `list-todos` | +| Get | `GET /todos/{todoId}` | `get-todo` | +| Create | `POST /todos` | `create-todo` | +| Update | `PATCH /todos/{todoId}` (only the fields sent change) | `update-todo` | +| Delete | `DELETE /todos/{todoId}` → 204 | `delete-todo` | +| Types | `GET /todos/types?projectId={id}` — the project's ToDo types | — | ### Create ToDo ``` -POSTprojects/{projectId}/todos +POST /todos Content-Type: application/json ``` -**Request Body:** ```json { - "label": "Review structural drawings", + "projectId": "Za04_rB1jV8", + "title": "Review structural drawings", "description": "Check beam connections at grid A-5", - "priority": 1, - "assignedTo": "engineer@company.com", - "dueDate": "2025-06-15T00:00:00Z", - "status": 0 + "priority": "HIGH", + "status": "NEW", + "dueDate": "2025-06-15T00:00:00+0000", + "percentComplete": 0, + "assignees": [{ "id": "", "type": "USER" }] } ``` -### ToDo Status Values - -| Value | Meaning | -|-------|---------| -| `0` | Active / Open | -| `1` | Completed | -| `2` | Deleted | - -### ToDo Priority Values - -| Value | Meaning | -|-------|---------| -| `0` | None | -| `1` | Low | -| `2` | Normal | -| `3` | High | -| `4` | Urgent | - -### Update ToDo - -``` -PUTprojects/{projectId}/todos/{todoId} -Content-Type: application/json -``` - -```csharp -var url = $"{apiBaseUrl}/projects/{projectId}/todos/{todoId}"; -var payload = new Dictionary -{ - ["label"] = "Updated task label", - ["status"] = 1, // Mark as completed - ["priority"] = 3 // Change to High -}; - -var jsonContent = JsonSerializer.Serialize(payload); -var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); - -var request = new HttpRequestMessage(HttpMethod.Put, url) { Content = content }; -var response = await client.SendAsync(request).ConfigureAwait(false); -``` - -### Delete ToDo +`projectId` and `description` are required. `label` is unique within the project; TC +assigns one (e.g. `TAKE-1`) when none is given. -``` -DELETEprojects/{projectId}/todos/{todoId} -``` +### Values -```csharp -var url = $"{apiBaseUrl}/projects/{projectId}/todos/{todoId}"; -var response = await client.DeleteAsync(url).ConfigureAwait(false); -``` +| Field | Values | +|---|---| +| `status` | `NEW`, `IN_PROGRESS`, `RESOLVED`, `BLOCKED`, `CLOSED` | +| `priority` | `LOW`, `NORMAL`, `HIGH`, `CRITICAL` | +| `type` | per project — read `GET /todos/types?projectId=…` | +| `assignees[].type` | `USER`, `GROUP` | -### Get Single ToDo +### Gotchas (verified live) -``` -GETprojects/{projectId}/todos/{todoId} -``` +- **Dates:** `dueDate` takes `2025-06-15`, `2025-06-15T00:00:00+0000` or a `±hh:mm` offset — + but a `Z` suffix or fractional seconds answer `400 Invalid date format`. TC keeps the + date only. (`create-todo` / `update-todo` send `Z` as `+0000`.) +- **Clearing:** `PATCH {"dueDate": null}` clears the due date and `"assignees": []` empties + the assignees, but `"label": null` leaves the label as it was. +- No `If-Match` is declared for ToDos. ### ToDo Object Schema | Property | Type | Description | |----------|------|-------------| -| `id` | string | Unique ToDo GUID | -| `label` | string | ToDo title/label | -| `description` | string | Detailed description | -| `status` | int | Status: 0=Open, 1=Completed, 2=Deleted | -| `priority` | int | Priority: 0=None, 1=Low, 2=Normal, 3=High, 4=Urgent | -| `assignedTo` | string | Assignee email address | -| `dueDate` | string (ISO 8601) | Due date | -| `createdOn` | string (ISO 8601) | Creation timestamp (read-only) | -| `modifiedOn` | string (ISO 8601) | Last modified timestamp (read-only) | -| `createdBy` | string | Creator user ID (read-only) | +| `id` | string | ToDo id | +| `label` | string | Unique label within the project | +| `title` | string | Title | +| `description` | string | Description | +| `type` | string | One of the project's ToDo types | +| `status` | string | See values above | +| `priority` | string | See values above | +| `percentComplete` | int | 0–100 | +| `dueDate` | string | `yyyy-MM-ddTHH:mm:ss+0000` | +| `projectId` | string | Project id | +| `assignees` | array | `{id, tiduuid, type, firstName, lastName, name, email}` | +| `createdOn` / `modifiedOn` | string | Timestamps (read-only) | +| `createdBy` / `modifiedBy` | object | User details (read-only) | --- diff --git a/20-agents/aeco/construction/trimble-connect/skills/topics.md b/20-agents/aeco/construction/trimble-connect/skills/topics.md index 576c0e145..3d758b968 100644 --- a/20-agents/aeco/construction/trimble-connect/skills/topics.md +++ b/20-agents/aeco/construction/trimble-connect/skills/topics.md @@ -8,6 +8,46 @@ description: Trimble Connect BCF Topics API — create, query, filter, update, d All BCF Topic Service endpoints use the regional base URL (see trimble-connect-auth skill). Example base: `https://open11.connect.trimble.com/bcf/2.1` +Checked against Trimble's published Topics API definition +(`api.swaggerhub.com/apis/Trimble-Connect/topic/v2`) and live against Trimble Connect +(2026-10-11). + +## Finding the base URL + +The Topics API is **not** under the Core base (`app.connect.trimble.com/tc/api/2.0`); each +region has its own host. Resolve it per project: + +1. `GET {core}/projects/{projectId}` → `location` (e.g. `northAmerica`). +2. `GET {core}/regions` (public, no token) → the entry with that `location` → `topic-api` + (`https://open11.connect.trimble.com/` for North America, `open21` Europe, `open22` UK, + `open31` Asia, `open32` Australia). +3. `bcfBaseUrl = {topic-api}bcf/2.1`. + +The same Trimble Identity bearer token works there. + +## AWARE commands + +| Operation | Command | +|---|---| +| List / get / create / update / delete topics | `list-topics`, `get-topic`, `create-topic`, `update-topic`, `delete-topic` | +| List / create comments | `list-comments`, `create-comment` | +| List / create viewpoints, fetch a snapshot | `list-viewpoints`, `create-viewpoint`, `get-viewpoint-snapshot` (see viewpoints.md) | +| List / create document references | `list-document-references`, `create-document-reference` | + +The commands resolve the region as above and send the token only to the five published +Topics hosts. + +## Things the definition and live behaviour settle + +- **Paging** is `top` + `skiptoken` query parameters; the response's `next` header is the + next page's URL (carrying the `skiptoken`). An empty list may come back as `204`. There is + no `Range` header and no `If-Match` / ETag on any BCF endpoint. +- **Extension values:** `topic_type`, `topic_status`, `priority`, `stage` **and `labels`** + must be values defined in the project's extensions (`GET …/extensions`); anything else is + `400 "… is not defined in the project extension"`. +- **Unsupported on TC** (per the definition): viewpoint bitmaps, BIM snippets, topic and + comment events, OData sorting/filtering, OData paging on 2.1. + --- ## Topics @@ -103,12 +143,18 @@ PUT /bcf/2.1/projects/{projectId}/topics/{topicId} Content-Type: application/json ``` -**Request Body:** Full or partial Topic object. Only include fields you want to change. +**Request Body:** a Topic object; `title` is **required**. BCF specifies a whole-topic +replace; TC in practice keeps a field the body leaves out, and also ignores a `null`. So to +*clear* a field send its empty value: `"description": ""` clears the description and +`"labels": []` empties the labels. TC offers no way to clear `due_date` (`""` is ignored) or +`assigned_to` (`""` is refused). Safest is to read the topic first and send its fields back +with your changes — what `update-topic` does. ```csharp var url = $"{bcfBaseUrl}/projects/{projectId}/topics/{topicId}"; var payload = new Dictionary { + ["title"] = currentTitle, // required on every PUT ["topic_status"] = "Closed", ["priority"] = "Low" }; @@ -394,21 +440,22 @@ var response = await client.SendAsync(request).ConfigureAwait(false); GET /bcf/2.1/projects/{projectId}/topics/{topicId}/related_topics ``` -### Add Related Topic +### Set Related Topics ``` -POST /bcf/2.1/projects/{projectId}/topics/{topicId}/related_topics +PUT /bcf/2.1/projects/{projectId}/topics/{topicId}/related_topics Content-Type: application/json ``` -**Request Body:** +**Request Body:** the **whole** list — it replaces the topic's related topics (there is no +`POST` on this path in Trimble's definition). Read the list first to add one. ```json -{ - "related_topic_guid": "guid-of-related-topic" -} +[ + { "related_topic_guid": "guid-of-related-topic" } +] ``` -**Note:** Related topics support may vary by SDK version. The AWARE SDK wrapper currently has limited support for this endpoint. +**Note:** No AWARE command wraps this endpoint yet. --- @@ -514,6 +561,12 @@ foreach (var topic in doc.RootElement.EnumerateArray()) ## BCF Events (Change Tracking) +> **Not supported on Trimble Connect.** Trimble's Topics API definition lists topic events +> and comment events among the BCF features it does not implement, and declares neither +> path below. Use `GET /bcf/2.1/projects/{projectId}/changes` (in the definition) or poll +> topics by `modified_date` instead. The section is kept for BCF servers that do implement +> events. + ### Topic Events ``` diff --git a/20-agents/aeco/construction/trimble-connect/skills/viewpoints.md b/20-agents/aeco/construction/trimble-connect/skills/viewpoints.md index 11211d10b..d8bf98675 100644 --- a/20-agents/aeco/construction/trimble-connect/skills/viewpoints.md +++ b/20-agents/aeco/construction/trimble-connect/skills/viewpoints.md @@ -10,6 +10,28 @@ Example base: `https://open11.connect.trimble.com/bcf/2.1` All viewpoints belong to a topic: `/bcf/2.1/projects/{projectId}/topics/{topicId}/viewpoints` +The regional base is found per project (`location` from `GET {core}/projects/{id}`, then +that region's `topic-api` from the public `GET {core}/regions`) — see topics.md. Checked +against Trimble's Topics API definition (`api.swaggerhub.com/apis/Trimble-Connect/topic/v2`) +and live (2026-10-11). + +## AWARE commands + +| Operation | Command | +|---|---| +| One page of a topic's viewpoints (`top` + `skiptoken`) | `list-viewpoints` | +| Create a viewpoint (camera, lines, clipping planes, components, snapshot) | `create-viewpoint` | +| A viewpoint's snapshot, base64 | `get-viewpoint-snapshot` | + +`create-viewpoint` takes cameras, lines, clipping planes and components in the BCF wire +shape shown below and checks them before sending (one camera at most, finite `x/y/z`, +non-zero direction and up vectors, `field_of_view` in (0, 180), `view_to_world_scale` > 0). + +**Live notes:** a viewpoint may be created with only a snapshot. The snapshot endpoint was +seen answering a PNG with `Content-Type: image/jpeg`, and the list endpoint returns a JPEG +thumbnail as `snapshot_data` while still saying `snapshot_type: "png"` — tell the format by +the bytes, not the label (`get-viewpoint-snapshot` does). + --- ## Viewpoints @@ -195,7 +217,8 @@ if (response.IsSuccessStatusCode) GET /bcf/2.1/projects/{projectId}/topics/{topicId}/viewpoints/{viewpointId}/bitmap ``` -**Response:** Bitmap overlay image bytes. Same pattern as snapshot retrieval. +**Not supported on Trimble Connect:** its Topics API definition lists viewpoint bitmaps as +unsupported and declares no `/bitmap` path. Kept for BCF servers that implement it. --- diff --git a/40-diagrams/substrate-playground.html b/40-diagrams/substrate-playground.html index 9c68d42b9..b86932189 100644 --- a/40-diagrams/substrate-playground.html +++ b/40-diagrams/substrate-playground.html @@ -90,7 +90,7 @@

Prompt — copy back to Claude