Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions .claude/docs/MILESTONE-19-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Milestone v1.0.0-beta.4 — Handoff

Context for continuing milestone 19 work locally. Written at the end of a Claude Code
web session that shipped 4 of the 14 issues.

- **Branch:** `claude/parallel-agents-work-trees-pbumvn`
- **PR:** [#424](https://github.com/brightdigit/MistKit/pull/424) (draft) → base `v1.0.0-beta.4` (**not** `main`)
- **Head at handoff:** `fb84e52`
- **Milestone:** https://github.com/brightdigit/MistKit/milestone/19

```bash
git fetch origin claude/parallel-agents-work-trees-pbumvn
git checkout claude/parallel-agents-work-trees-pbumvn
```

---

## 1. What shipped in #424

| Issue | State | Notes |
|---|---|---|
| #421 Remove deprecated public API | Done | 4 declarations removed. **Breaking.** |
| #378 FieldValue ↔ Components refactor | Done | Exhaustive dispatch; 5 `default:` branches removed |
| #358 CloudKitError per-serverErrorCode | Done | 14 codes + `.unknownServerError` fallback |
| #295 Cloud toolchain setup | Done | Two-tier swiftly setup + `.claude/settings.json` |
| #419 MistDemoApp initializers | **Already fixed** | Not a code change — see below |

### #419 is closable without code
Both initializers the issue proposes (`NoteEditView.init(mode:onSaved:)`,
`RecordDetailView.init(note:onChange:)`) shipped in `5a58120` (v1.0.0 beta.3), eight days
after the issue was filed. Verified they exist in the tree and that `5a58120` is an
ancestor of HEAD.

**To close it**, confirm on macOS with Swift 6.3.2:
```bash
cd Examples/MistDemo && swift build --target MistDemoApp
```
Linux cannot run this — the views are inside `#if canImport(SwiftUI)`.

---

## 2. Remaining milestone work (9 issues)

**Serialize these three — they all mutate `openapi.yaml`** and will collide if run in
parallel worktrees:

| Issue | Title |
|---|---|
| #41 | Fetching Record Information (`records/resolve`) |
| #42 | Accepting Share Records (`records/accept`) |
| #47 | Fetching Record Zone Changes (`changes/zone`) |
| #386 | Zone schemas only model `{ zoneID }` — enrich with sync/atomic metadata |
| #401 | Clarify change-tracking endpoint coverage (`changes/*` vs `records/changes`) |

**Safe to parallelize — independent, no spec changes:**

| Issue | Title |
|---|---|
| #399 | Fix Over-Extension Use With Protocol Extension Implementation Pattern |
| #407 | Create MistKitConfiguration package for shared CloudKit config glue |
| #146 | Add custom CloudKit zone support for queries |
| #398 | MistDemo web: add phone-number support to `/users/discover` |

---

## 3. Environment & tooling — hard-won details

### Swift versions differ across the repo
| Package | tools-version |
|---|---|
| root `Package.swift` | 6.1 |
| `Examples/MistDemo`, `BushelCloud`, `CelestraCloud` | **6.2** |
| `.swift-version` (new) | **6.3.2** |

Installing 6.1 makes every example package unbuildable
(`error: package is using Swift tools version 6.2.0 but the installed version is 6.1.0`).

### mise vs. cloud sessions
`mise.toml` pins swift-format, SwiftLint, periphery and swift-openapi-generator via
`spm:`/`aqua:` backends, which resolve through **`api.github.com`** — unreachable from
Claude Code web sessions. Locally mise works normally; **run the full lint locally**,
since web sessions skip SwiftLint and periphery:

```bash
./Scripts/lint.sh # full pipeline — do this before merging #424
mise exec -- swiftlint
```

`Scripts/lint.sh` now gates SwiftLint/periphery on `CLAUDE_CODE_REMOTE`. That gating is
for web sessions only and should not affect local runs.

### Regenerating OpenAPI code
`Scripts/generate-openapi.sh` prefers the mise binary and falls back to
`Scripts/OpenAPITools/` — a standalone manifest that resolves the generator over plain
git. Either path produces byte-identical output (verified).

> **Keep the version in `Scripts/OpenAPITools/Package.swift` in sync with `mise.toml`'s
> `spm:apple/swift-openapi-generator` pin (currently `1.10.3`).** Nothing enforces this.

```bash
./Scripts/generate-openapi.sh
```

---

## 4. Lessons from the parallel-worktree run

Four agents ran on isolated worktrees. Worth repeating, and worth knowing the failure mode:

**Git merged cleanly and both branches were individually green — yet the merge was
broken.** #358's new tests called a query overload that #421 deleted. The branches
touched disjoint files, so there was no conflict, and neither agent could have seen it.
It surfaced only under `--build-tests`, because a plain `swift build` does not compile
test code.

```bash
# After merging any two parallel branches:
swift build --build-tests # NOT just `swift build`
swift test
```

Also: don't pipe test output through `tail` when diagnosing — it reduced a real
compile error to a context-free `error: fatalError` that looked like a passing run.

Agents could not build the example packages (toolchain mismatch, above), so call-site
edits in `Examples/` were made by reading signatures. Those need a compile before trust.

---

## 5. Open follow-ups

### 5.1 Unverified 32-bit/WASI narrowing (#378)
The agent introduced, then caught and fixed, an `Int64` → `Int` conversion that would
**trap on wasm32** for large timestamps. There is no 32-bit test coverage. A green wasm
CI lane proves it compiles, not that the path is safe. **Wants a human review.**

### 5.2 `openapi.yaml` declares `serverErrorCode` as a closed enum
Two places:
- `OperationFailureServerErrorCode` (~line 1716)
- `ErrorResponse.serverErrorCode` (~line 1803)

The generator emits closed Swift enums, so an unrecognized code fails to decode as
`.decodingError` **before** MistKit's mapping runs — meaning `.unknownServerError` can
never fire from real traffic today.

Two concrete gaps found:
- The spec's own prose (~line 1795) documents `RECORD_NOT_FOUND` and `PARTIAL_FAILURE`,
neither of which is in the `enum:` below it.
- Apple's CloudKit JS reference names `SERVICE_UNAVAILABLE`, `UNIQUE_FIELD_ERROR`,
`INVALID_ARGUMENTS`, `UNKNOWN_ERROR` — all rejected by the enum. *Caveat: some
CloudKit JS codes are client-side only and may never cross the REST wire. Unverified.*

Deliberately deferred — the design question (open the enum to plain `string`, vs. keep a
closed enum, vs. restructure `CloudKitError` around a nested `ServerErrorCode` enum with
`.unknown(String)`) was left open. `CloudKitError` is a 30-case public enum with library
evolution **disabled**, so adding cases post-1.0 is source-breaking for exhaustive
switches. Pre-1.0 is the free moment to decide.

### 5.3 `.claude/docs/webservices.md` is missing
`CLAUDE.md` documents it as present at 289 KB and calls it the primary REST API
reference. It is not in the repo. It is the doc you'd want for §5.2.

### 5.4 #421's acceptance-criteria grep is vacuous
The issue's verification grep cannot match multi-line `@available(...)` attributes, so it
returns clean whether or not the work was done. Worth fixing in the issue template.
(Confirmed: zero `@available(*, deprecated)` declarations remain in `Sources/MistKit`.)

---

## 6. Verification status of #424

Run on Linux x86-64 / Swift 6.2 against the fully merged tree:

| Check | Result |
|---|---|
| MistKit core | 552 tests / 176 suites passing |
| MistDemo | 970 tests / 289 suites passing |
| MistDemo, CelestraCloud, BushelCloud | all build against modified MistKit |
| swift-format lint + header + `--build-tests` | clean; formatting produced no changes |
| SwiftLint, periphery | **not run** — needs local mise |
| wasm32, Windows, Android, Apple platforms | **not run locally** — CI only |

Before merging: run `./Scripts/lint.sh` locally and check CI on #424.
127 changes: 127 additions & 0 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/bin/bash
set -euo pipefail

# SessionStart hook: install a Swift toolchain for Claude Code on the web
# (Linux). Only runs in remote sessions; local sessions are untouched. Runs
# async so the session starts immediately: progress lands in
# ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end.
#
# The toolchain is all this installs. Lint tooling is deliberately left out to
# keep cold start short: swift-format ships inside the toolchain, and both
# SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits
# them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise
# provides the pinned versions, to get full coverage.
#
# This hook is the second tier of a two-tier setup. The first tier is
# Scripts/cloud-setup.sh, pasted into the cloud environment's "Setup script"
# field: it runs once, then the filesystem is snapshotted and later sessions
# reuse it, so the ~1 GB toolchain download happens once per environment
# instead of once per container. When that snapshot exists, the `command -v
# swift` check below short-circuits and this hook finishes in about a second.
#
# The hook is still required on every session for two reasons: a snapshot
# restores files but not environment variables, so PATH has to be re-exported
# into CLAUDE_ENV_FILE each time; and an environment with no setup script
# configured (a fresh clone, another contributor) still needs the toolchain
# installed from here.
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
exit 0
fi

echo '{"async": true, "asyncTimeout": 2400000}'

SETUP_LOG="$HOME/.claude-session-setup.log"
SETUP_DONE="$HOME/.claude-session-setup.done"
rm -f "$SETUP_DONE"
exec >> "$SETUP_LOG" 2>&1

SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh"
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"

# Make swift reachable for the session up front; an entry pointing at a
# not-yet-populated directory is harmless.
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
{
echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\""
echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\""
echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\""
} >> "$CLAUDE_ENV_FILE"
fi

install_swift() {
# System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux
# instructions), plus curl for fetching swiftly. Most are already in the
# base image, so only reach for apt when something is genuinely missing --
# `apt-get update` alone costs ~10s.
local packages missing pkg
packages=(
binutils
curl
git
gnupg2
libc6-dev
libcurl4-openssl-dev
libedit2
libgcc-13-dev
libncurses-dev
libpython3-dev
libsqlite3-0
libstdc++-13-dev
libxml2-dev
libz3-dev
pkg-config
tzdata
zlib1g-dev
)
missing=()
for pkg in "${packages[@]}"; do
if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then
missing+=("$pkg")
fi
done

if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing system packages: ${missing[*]}"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends "${missing[@]}"
else
echo "All system packages already present; skipping apt."
fi

# Install swiftly non-interactively, then the toolchain pinned by the
# repo's .swift-version (falling back to latest if no pin resolves).
local workdir
workdir="$(mktemp -d)"
pushd "$workdir" > /dev/null
curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz"
tar zxf "swiftly-$(uname -m).tar.gz"
./swiftly init -y --skip-install
popd > /dev/null
rm -rf "$workdir"

# shellcheck disable=SC1090
. "$SWIFTLY_ENV"

cd "$PROJECT_DIR"
if ! swiftly install -y; then
echo "Pinned toolchain install failed; falling back to latest." >&2
swiftly install -y latest
swiftly use -y latest
fi
}

# Pick up a swiftly install from a previous (cached) hook run.
if [ -f "$SWIFTLY_ENV" ]; then
# shellcheck disable=SC1090
. "$SWIFTLY_ENV"
fi

if command -v swift > /dev/null 2>&1; then
echo "Swift already installed: $(swift --version 2>&1 | head -1)"
else
install_swift
fi

swift --version
touch "$SETUP_DONE"
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\""
}
]
}
]
}
}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,10 @@ dev-debug.log
# tasks/
.claude/scheduled_tasks.lock
build

# Git worktrees created for parallel agent runs
.claude/worktrees/

# Standalone openapi generator tools package build products
Scripts/OpenAPITools/.build/
Scripts/OpenAPITools/.swiftpm/
1 change: 1 addition & 0 deletions .swift-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6.3.2
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev
- `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING`
- `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64`

Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in `makeScalarRequest` (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional.
Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional.

**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`).

Expand Down Expand Up @@ -192,7 +192,7 @@ MistKit/
| `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` |
| `CloudKitService+ModifyZones.swift` | `modifyZones(_:database:)` |
| `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` |
| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(no-arg address-book form — unavailable, pending #28; distinct from the available `discoverAllUserIdentities(lookupInfos:batchSize:)` chunking overload below)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) |
| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(no-arg address-book form — unavailable, pending #28; distinct from the available `discoverAllUserIdentities(lookupInfos:batchSize:)` chunking overload below)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)` |
| `CloudKitService+LookupAllRecords.swift` | `lookupAllRecords(recordNames:desiredKeys:database:batchSize:)` — auto-chunking convenience over `lookupRecords` |
| `CloudKitService+UserIdentityChunking.swift` | `discoverAllUserIdentities(lookupInfos:batchSize:)` — auto-chunking convenience over `discoverUserIdentities` |
| `CloudKitService+BatchChunking.swift` | internal `chunkedBatches` helper backing the auto-chunking conveniences |
Expand All @@ -210,7 +210,7 @@ MistKit/
- `discoverUserIdentities(lookupInfos:)` → POST `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]`

**User-Identity Operations (public DB + web-auth required):**
- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces deprecated `fetchCurrentUser()` / `users/current`. Only valid against the public database with web-auth credentials.
- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces Apple's deprecated `users/current` endpoint (the `fetchCurrentUser()` wrapper was removed in #421). Only valid against the public database with web-auth credentials.
- `discoverAllUserIdentities()` → GET `/users/discover` — returns `[UserIdentity]` for every discoverable user in the caller's address book.
- `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`.
- `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`.
Expand Down
2 changes: 1 addition & 1 deletion Examples/BushelCloud/.claude/s2s-auth-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ fields["swiftVersion"] = .reference(

**1. Test authentication:**
```swift
let records = try await service.queryRecords(recordType: "RestoreImage", limit: 1)
let records = try await service.queryRecords(Query(recordType: "RestoreImage"), limit: 1)
print("✓ Authentication successful, found \(records.count) records")
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ internal enum StatusCommand {
private static func fetchAllMetadata(cloudKitService: BushelCloudKitService) async throws
-> [DataSourceMetadata]
{
let records = try await cloudKitService.queryRecords(recordType: "DataSourceMetadata")
let records = try await cloudKitService.queryAllRecords(recordType: "DataSourceMetadata")

var metadataList: [DataSourceMetadata] = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol
// MARK: - RecordManaging Protocol Requirements

/// Query all records of a given type, automatically paginating
public func queryRecords(recordType: String) async throws -> [RecordInfo] {
public func queryAllRecords(recordType: String) async throws -> [RecordInfo] {
try await service.queryAllRecords(
recordType: recordType,
database: .public(.prefers(.serverToServer))
Expand Down
Loading
Loading