merge fixes: typecheck (attributedBody → base64 string), syncNow race coalescing, test alignment
This commit is contained in:
parent
8cc2c50fed
commit
1295aec3e9
12 changed files with 330 additions and 51 deletions
|
|
@ -108,12 +108,11 @@ final class APIClient: BaseAPIClient, APIClientProtocol, @unchecked Sendable {
|
||||||
guard json["success"].boolValue else {
|
guard json["success"].boolValue else {
|
||||||
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
|
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
|
||||||
}
|
}
|
||||||
return json["data"]["messages"].arrayValue.map { msg in
|
return json["data"]["items"].arrayValue.map { msg in
|
||||||
PendingSendMessage(
|
PendingSendMessage(
|
||||||
id: msg["id"].stringValue,
|
id: msg["id"].stringValue,
|
||||||
phoneNumber: msg["phoneNumber"].stringValue,
|
toHandle: msg["toHandle"].stringValue,
|
||||||
body: msg["body"].stringValue,
|
body: msg["body"].stringValue,
|
||||||
requestedBy: msg["requestedBy"].stringValue,
|
|
||||||
createdAt: msg["createdAt"].stringValue
|
createdAt: msg["createdAt"].stringValue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -271,8 +270,7 @@ struct SyncContactPayload: Encodable {
|
||||||
|
|
||||||
struct PendingSendMessage: Sendable {
|
struct PendingSendMessage: Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let phoneNumber: String
|
let toHandle: String
|
||||||
let body: String
|
let body: String
|
||||||
let requestedBy: String
|
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ final class SyncManager: BaseSyncManager<SyncStats, SyncError> {
|
||||||
let service = sendService
|
let service = sendService
|
||||||
let activity = activityLog
|
let activity = activityLog
|
||||||
return SendQueueClient(label: "imessage", transport: transport, interval: 30) { message in
|
return SendQueueClient(label: "imessage", transport: transport, interval: 30) { message in
|
||||||
let result = service.send(recipient: message.phoneNumber, body: message.body)
|
let result = service.send(recipient: message.toHandle, body: message.body)
|
||||||
let suffix = String(message.phoneNumber.prefix(4))
|
let suffix = String(message.toHandle.prefix(4))
|
||||||
if result.success {
|
if result.success {
|
||||||
activity.success("Sent message to \(suffix)...")
|
activity.success("Sent message to \(suffix)...")
|
||||||
return .sent
|
return .sent
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ struct SyncPayloadTests {
|
||||||
#expect(dict["service"] as? String == "SMS")
|
#expect(dict["service"] as? String == "SMS")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func nilServiceFieldSerializesAsNil() {
|
@Test func nilServiceFieldOmittedFromDictionary() {
|
||||||
|
// Nil optionals are dropped from the wire payload — the server uses
|
||||||
|
// COALESCE semantics so absent ≡ "don't overwrite". Sending explicit
|
||||||
|
// nulls would bloat every message payload for no signal.
|
||||||
let dict = makePayload(service: nil).dictionary
|
let dict = makePayload(service: nil).dictionary
|
||||||
#expect(dict.keys.contains("service"))
|
#expect(!dict.keys.contains("service"))
|
||||||
let val = dict["service"] as Any?
|
|
||||||
#expect(val is NSNull || val == nil || (val as? String) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func tapbackFieldsSerializeCorrectly() {
|
@Test func tapbackFieldsSerializeCorrectly() {
|
||||||
|
|
|
||||||
|
|
@ -102,14 +102,18 @@ open class BaseSyncManager<Stats: Sendable, SyncError: Sendable>: ObservableObje
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single cycle immediately if one is not already in flight.
|
/// Run a single cycle immediately if one is not already in flight.
|
||||||
|
/// `isSyncing` is flipped synchronously here so rapid back-to-back calls
|
||||||
|
/// from the same actor coalesce into one in-flight cycle.
|
||||||
public final func syncNow() {
|
public final func syncNow() {
|
||||||
guard !isSyncing else { return }
|
guard !isSyncing else { return }
|
||||||
|
isSyncing = true
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
await self?.gatedRunCycle()
|
await self?.gatedRunCycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func gatedRunCycle() async {
|
private func gatedRunCycle() async {
|
||||||
|
defer { isSyncing = false }
|
||||||
if !(await self.isAuthorized()) {
|
if !(await self.isAuthorized()) {
|
||||||
let granted = await self.requestAuthorization()
|
let granted = await self.requestAuthorization()
|
||||||
guard granted else {
|
guard granted else {
|
||||||
|
|
@ -121,13 +125,11 @@ open class BaseSyncManager<Stats: Sendable, SyncError: Sendable>: ObservableObje
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runCycle() async {
|
private func runCycle() async {
|
||||||
isSyncing = true
|
|
||||||
await performSync()
|
await performSync()
|
||||||
let now = Date()
|
let now = Date()
|
||||||
setLastSync(now)
|
setLastSync(now)
|
||||||
lastSyncCompletedAt = now
|
lastSyncCompletedAt = now
|
||||||
UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey))
|
UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey))
|
||||||
isSyncing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Watermark helpers (subclasses may need to reset on full re-sync)
|
// MARK: - Watermark helpers (subclasses may need to reset on full re-sync)
|
||||||
|
|
|
||||||
287
handoffs/20260515_macsync-canonical-completion.md
Normal file
287
handoffs/20260515_macsync-canonical-completion.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# MacSync Canonical Completion Plan — 2026-05-15
|
||||||
|
|
||||||
|
Drives `@mac-sync` to the architecture its docs already describe: every
|
||||||
|
module a `BaseSyncManager<Stats, Error>` with a `SendQueueClient<Transport>`
|
||||||
|
peer, every send routed through `/admin/send-queue/enqueue` → `/client/<module>/send-queue/pending`
|
||||||
|
→ a module-specific Sender. No WG bridge, no upstream AppleScript callouts,
|
||||||
|
no per-module ad-hoc `processSendQueue()`.
|
||||||
|
|
||||||
|
This handoff is the source of truth for that consolidation. Working notes
|
||||||
|
in `~/.claude/plans/` are deprecated by this file where they conflict.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## North-star architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
quinn-* MCPs ─► mac-sync /admin/send-queue/enqueue ─► icloud.send_queue
|
||||||
|
│
|
||||||
|
┌───────────────────────────────┘
|
||||||
|
▼ poll /client/<module>/send-queue/pending
|
||||||
|
MacSyncApp on plum (BaseSyncManager + SendQueueClient<Transport>)
|
||||||
|
│
|
||||||
|
▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders)
|
||||||
|
▼
|
||||||
|
macOS app via AppleScript / ScriptingBridge / EventKit
|
||||||
|
│
|
||||||
|
▼ POST /client/<module>/send-queue/:id/result
|
||||||
|
icloud.send_queue rows → sent / failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape for scheduled sends: `outreach.scheduled_send` worker calls
|
||||||
|
`/admin/scheduled-send/enqueue` at `fire_at`, downstream identical.
|
||||||
|
|
||||||
|
**Single canonical wire format** across all modules:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
GET /client/<module>/send-queue/pending
|
||||||
|
→ { "success": true, "data": { "items": [ { "id", "toHandle"|<module-fields>, "body"|"payload", "createdAt" } ] } }
|
||||||
|
|
||||||
|
POST /client/<module>/send-queue/:id/result
|
||||||
|
← { "status": "sent" | "failed", "error": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-module persistence** (revised 2026-05-15 after reviewing Quinn's WIP in
|
||||||
|
`src/server/src/app/server.ts`):
|
||||||
|
|
||||||
|
Each module has a dedicated send-item entity, NOT a shared `icloud.send_queue`
|
||||||
|
discriminated by service type. Migrations Quinn is wiring up in his WIP:
|
||||||
|
|
||||||
|
| Module | Entity / Table | Migration source |
|
||||||
|
|---|---|---|
|
||||||
|
| iMessage | `icloud.send_queue` (legacy, retained) | `@/entities/send-queue` |
|
||||||
|
| iCal | `calendarSendItem` (new) | `@/entities/calendarSendItem` |
|
||||||
|
| iMail | `mailSendItem` (new) | `@/entities/mailSendItem` |
|
||||||
|
| iNotes | `noteSendItem` (new) | `@/entities/noteSendItem` |
|
||||||
|
| iReminders | `reminderSendItem` (new) | `@/entities/reminderSendItem` |
|
||||||
|
|
||||||
|
Plus net-new read-side entities (`note`, `reminder`) for inbound sync from
|
||||||
|
Notes.app / Reminders.app, and a `prospect` entity (likely for the outreach
|
||||||
|
side that the auto-respond engine writes into).
|
||||||
|
|
||||||
|
The iMessage table is the only one keeping the older `icloud.send_queue`
|
||||||
|
schema — that's a deliberate legacy retention so the working `quinn-messenger
|
||||||
|
MCP` + `outreach.scheduled_send` worker paths don't break during the
|
||||||
|
migration. New modules get the cleaner per-module shape from the start.
|
||||||
|
|
||||||
|
## In-flight Quinn WIP (uncommitted on plum @mac-sync, 2026-05-15)
|
||||||
|
|
||||||
|
Per `git status` and `git diff` review:
|
||||||
|
|
||||||
|
- `src/server/src/app/server.ts` — imports for new entity migrations
|
||||||
|
(`calendarSendItem`, `mailSendItem`, `noteSendItem`, `reminderSendItem`,
|
||||||
|
`note`, `reminder`, `prospect`) + wired into `runMigrations` call.
|
||||||
|
- `@packages/ical/Sources/ICalSync/APIClient.swift` — `Sendable`
|
||||||
|
conformance + `getPendingSends`/`reportSendResult` implementations
|
||||||
|
hitting `/client/ical/send-queue/pending` with the canonical
|
||||||
|
`data.items` envelope and a `PendingCalendarSend` payload struct.
|
||||||
|
- `@packages/ical/Sources/ICalSync/Reader.swift` — modified (likely
|
||||||
|
EventKit-side reader changes; not yet inspected).
|
||||||
|
- `@packages/imail/Sources/IMailSync/APIClient.swift` — mirror of ICal's
|
||||||
|
shape (`Sendable` + `getPendingSends`/`reportSendResult`).
|
||||||
|
- `@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift`
|
||||||
|
— modified (not yet inspected).
|
||||||
|
- `Package.swift`, `app.manifest.yaml`, `CLAUDE.md` — module additions
|
||||||
|
and metadata.
|
||||||
|
|
||||||
|
**Do not edit any of these files from a Claude Code session.** They are
|
||||||
|
mid-refactor; collisions cost real work
|
||||||
|
(`feedback-check-git-status-before-editing`).
|
||||||
|
|
||||||
|
## Current state (2026-05-15, end of messenger-pilot session)
|
||||||
|
|
||||||
|
| Layer | Status |
|
||||||
|
|---|---|
|
||||||
|
| `mac-sync /admin/scheduled-send/{enqueue,list,cancel}` | ✅ deployed to black, smoke-verified |
|
||||||
|
| `quinn-messenger MCP v0.3.0` (5 tools, HTTP-only) | ✅ deployed plum + apricot, stdio-verified |
|
||||||
|
| `outreach.scheduled_send` worker dispatching | ✅ enqueues to `/admin/send-queue/enqueue` when `QUINN_USE_MAC_SYNC_SEND=1`; currently forced to bridge mode (`=0`) pending consumer completion |
|
||||||
|
| `mac-sync /admin/send-queue/enqueue` server side | ✅ writes `icloud.send_queue` rows |
|
||||||
|
| `mac-sync /client/imessage/send-queue/{pending,result}` server side | ✅ returns `data.items` shape, contract doc in `src/server/SEND_QUEUE_CONTRACTS.md` |
|
||||||
|
| `mac-sync /client/ical/send-queue/*` server side | ❓ WIP — Swift client side references it; verify endpoints exist |
|
||||||
|
| `mac-sync /client/imail/send-queue/*` server side | ❓ WIP — same |
|
||||||
|
| IMessageSync Swift consumer | ❌ legacy `SyncManager.processSendQueue()` in place; reads wrong wire format (`data.messages`/`phoneNumber`); `SendQueueAdapter.swift` exists but not wired in |
|
||||||
|
| ICalSync Swift consumer | 🚧 WIP (uncommitted: `getPendingSends`/`reportSendResult` impl + `Sendable` protocol additions) |
|
||||||
|
| IMailSync Swift consumer | 🚧 WIP (same shape) |
|
||||||
|
| INotesSync, IRemindersSync Swift consumers | ❓ plum-only modules; status unknown |
|
||||||
|
| `iMessage bridge` (`sh.lilith.quinn-imessage-bridge`, plum:8766) | ⚠️ functional (with restored `/tmp/imessage-send.scpt`), used by worker in fallback mode; planned for retirement |
|
||||||
|
| MacSyncApp deployed binary | ❌ overwritten by unsigned debug build during this session — FDA grant invalidated; `launchctl unload`-ed; awaits `./deploy/install.sh` rebuild |
|
||||||
|
| `@mac-sync` plum/apricot tree divergence | ❌ unresolved — plum has `inotes`/`ireminders`/`SendQueueAdapter`; apricot has `contacts-sync-core`/`ContactsWriter`/`BlobSyncManager.swift` |
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 1 — Finish the iMessage canonical migration (BLOCKING)
|
||||||
|
|
||||||
|
Owner: Quinn (Swift work; do NOT edit from Claude Code sessions per `feedback-check-git-status-before-editing`).
|
||||||
|
|
||||||
|
1. **Rewrite `IMessageSync/SyncManager.swift`** to match the pattern its
|
||||||
|
own docs describe (`docs/modules/imessage.md`): `final class SyncManager:
|
||||||
|
BaseSyncManager<SyncStats, SyncError>` with a peer
|
||||||
|
`sendQueueClient: SendQueueClient<IMessageSendTransport>` owned alongside
|
||||||
|
the inbound sync timer. Delete `processSendQueue()` (currently lines
|
||||||
|
475–504).
|
||||||
|
2. **Fix `IMessageSync/APIClient.swift` wire format** to match
|
||||||
|
`src/server/SEND_QUEUE_CONTRACTS.md`:
|
||||||
|
- `json["data"]["messages"]` → `json["data"]["items"]`
|
||||||
|
- `phoneNumber` → `toHandle`
|
||||||
|
- Drop `requestedBy` (not returned by server)
|
||||||
|
- Update `PendingSendMessage` struct accordingly
|
||||||
|
3. **Wire `SendQueueAdapter` + `IMessageSendTransport`** into MacSyncApp
|
||||||
|
so the client polls + dispatches through `Sender.SendService`.
|
||||||
|
4. **Run `./deploy/install.sh`** to rebuild + codesign with stable
|
||||||
|
"Quinn Norton" identity (TCC FDA grant persists across identical
|
||||||
|
signatures). Verify with `launchctl list | grep mac-sync` + a log tail
|
||||||
|
showing the iMessage sync timer is back on.
|
||||||
|
|
||||||
|
### Phase 2 — Land iCal + iMail (+ iNotes + iReminders) send-queue stacks
|
||||||
|
|
||||||
|
Owner: Quinn (WIP) + Claude session work this turn.
|
||||||
|
|
||||||
|
Status by sub-step:
|
||||||
|
|
||||||
|
| # | Step | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Entity + migration per module (`src/server/src/entities/{calendar,mail,note,reminder}SendItem/`) | ✅ committed (`createSendQueueRepo` factory + per-module Zod schemas in place) |
|
||||||
|
| 2 | Wire migrations into `src/server/src/app/server.ts` runMigrations | 🚧 WIP (Quinn) — `git diff src/server/src/app/server.ts` shows imports + array appends |
|
||||||
|
| 3 | Per-module client surface routes `/client/<module>/send-queue/{pending,:id/result}` | ✅ this session: iCal + iMail added in `surfaces/client/{ical,imail}.ts` mirroring `ireminders.ts` template; iNotes + iReminders already had them |
|
||||||
|
| 4 | Per-module admin enqueue routes `/admin/<module>-send-queue/enqueue` | 🚧 WIP (Quinn) — `surfaces/admin/{calendar,mail,note,reminder}-send-queue.ts` exist; mount in `admin/index.ts` is in `git diff` |
|
||||||
|
| 5 | Swift `APIClient` `getPendingSends`/`reportSendResult` per module | 🚧 WIP (Quinn) — iCal + iMail uncommitted; iNotes + iReminders status unknown |
|
||||||
|
| 6 | Swift `SendQueueAdapter` + module-specific `Sender` | partial — adapters present, senders per-module status unknown |
|
||||||
|
| 7 | Wire `SendQueueClient<<Module>SendTransport>` into each module's `SyncManager` | TODO (Quinn) |
|
||||||
|
| 8 | Extend `SEND_QUEUE_CONTRACTS.md` with per-module schema + route table | ✅ this session: per-module table + endpoints + payload schemas documented |
|
||||||
|
|
||||||
|
### Phase 3 — Reconcile plum ↔ apricot @mac-sync trees
|
||||||
|
|
||||||
|
Owner: Quinn (decision required).
|
||||||
|
|
||||||
|
Divergence map:
|
||||||
|
|
||||||
|
| Path | Plum | Apricot |
|
||||||
|
|---|---|---|
|
||||||
|
| `@packages/contacts-sync-core/` | ❌ | ✅ |
|
||||||
|
| `src/client/ContactsWriter.swift` | ❌ | ✅ |
|
||||||
|
| `src/client/ContactsBackup.swift` | ❌ | ✅ |
|
||||||
|
| `src/client/ContactRenderPoller.swift` | ❌ | ✅ |
|
||||||
|
| `src/client/ContactRenderModels.swift` | ❌ | ✅ |
|
||||||
|
| `@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift` | ❌ | ✅ |
|
||||||
|
| `@packages/inotes/` (`INotesSync`) | ✅ | ❌ |
|
||||||
|
| `@packages/ireminders/` (`IRemindersSync`) | ✅ | ❌ |
|
||||||
|
| `@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift` | ✅ | ❌ |
|
||||||
|
|
||||||
|
1. **Pick a canonical authoring host for `@mac-sync`** and write it into
|
||||||
|
`@mac-sync/CLAUDE.md`. Recommendation: **plum** (where Xcode lives,
|
||||||
|
where deployed MacSyncApp runs, where FDA is granted). Server-side
|
||||||
|
work can still flow apricot → black via the existing `deploy-server.sh`
|
||||||
|
from either host.
|
||||||
|
2. **Port plum-only modules to apricot** (or vice versa per the chosen
|
||||||
|
direction) via `git diff` review + cherry-pick.
|
||||||
|
3. **Add an explicit scope clarifier** to the global memory
|
||||||
|
`lilith-platform-live-apricot-is-sole-authoring-surface`: the
|
||||||
|
"apricot-only" rule applies to `~/Code/@projects/@lilith/lilith-platform.live/`
|
||||||
|
only. `~/Code/@applications/*` follows per-repo rules. (Done in
|
||||||
|
`feedback_source_of_truth_apricot.md`.)
|
||||||
|
4. Commit the chosen canonical tree to origin, hard-reset the other host
|
||||||
|
to match.
|
||||||
|
|
||||||
|
### Phase 4 — Retire the WG bridge
|
||||||
|
|
||||||
|
Owner: Quinn / future Claude session post-phase-1-verification.
|
||||||
|
|
||||||
|
1. Flip worker dispatch path: edit
|
||||||
|
`/etc/quinn-ai-engine/secrets.env` on apricot, remove
|
||||||
|
`QUINN_USE_MAC_SYNC_SEND=0` (or set to `1`). Restart
|
||||||
|
`quinn-ai-auto-respond.service`.
|
||||||
|
2. Smoke `outreach.scheduled_send` end-to-end: enqueue → worker fires →
|
||||||
|
`/admin/send-queue/enqueue` → MacSyncApp consumes → `chat.db` row
|
||||||
|
appears.
|
||||||
|
3. **Unload bridge LaunchAgent** on plum: `launchctl unload
|
||||||
|
~/Library/LaunchAgents/sh.lilith.quinn-imessage-bridge.plist`. Mark
|
||||||
|
`~/Applications/quinn-imessage-bridge/` archived. Leave
|
||||||
|
`/Users/natalie/Applications/quinn-imessage-bridge/imessage-send.scpt`
|
||||||
|
in place — harmless and useful as a documented Messages.app dispatch
|
||||||
|
reference.
|
||||||
|
4. Remove `sendViaBridge` from
|
||||||
|
`codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts`
|
||||||
|
(lilith-platform.live, apricot-authored). MacSyncClient becomes
|
||||||
|
single-path. Rebuild + redeploy engine.
|
||||||
|
5. Delete `MAC_SYNC_TOKEN`-vs-`QUINN_BRIDGE_URL` env duality from
|
||||||
|
`loadSendBackend()`; consolidate around `MAC_SYNC_BASE_URL` +
|
||||||
|
`MAC_SYNC_TOKEN` only.
|
||||||
|
|
||||||
|
### Phase 5 — Lock down delivery verification
|
||||||
|
|
||||||
|
Owner: Quinn / future session.
|
||||||
|
|
||||||
|
1. Make `chat.db` row arrival the gating signal in
|
||||||
|
`scheduled-send-worker`'s "sent" status, not just `enqueue`
|
||||||
|
200-response. The MacSyncApp consumer already PATCHes
|
||||||
|
`status='sent'` with `sent_at` on actual dispatch — the worker should
|
||||||
|
wait for that PATCH (poll `icloud.send_queue.status`) rather than
|
||||||
|
marking `outreach.scheduled_send.status='sent'` on enqueue ack.
|
||||||
|
2. Surface a `delivery_confirmed` boolean in the
|
||||||
|
`outreach.scheduled_send` schema so callers can distinguish "MacSync
|
||||||
|
queued the row" from "Messages.app dispatched and Apple ack'd".
|
||||||
|
3. Add a periodic janitor job that fails any `icloud.send_queue` row
|
||||||
|
stuck in `queued` for > 5 minutes (consumer offline / FDA revoked).
|
||||||
|
|
||||||
|
### Phase 6 — Single-path TS client for the MCP
|
||||||
|
|
||||||
|
Owner: future session.
|
||||||
|
|
||||||
|
Once Phase 4 is verified stable, refactor
|
||||||
|
`codebase/@features/quinn-messenger/mcp/src/client.ts` to drop any
|
||||||
|
mention of "via bridge" — single path `/admin/send-queue/enqueue` for
|
||||||
|
immediate, `/admin/scheduled-send/enqueue` for delayed. Bump MCP to
|
||||||
|
v1.0.0 (out of pre-release).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard rules going forward
|
||||||
|
|
||||||
|
1. **`git status` before editing any `@applications/@mac-sync` Swift
|
||||||
|
source.** WIP collisions cost real work — see
|
||||||
|
`~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`.
|
||||||
|
2. **Never `cp` a Swift build artifact directly into the MacSync.app
|
||||||
|
bundle.** TCC revokes FDA silently. Always
|
||||||
|
`./deploy/install.sh` so the stable "Quinn Norton" codesign is
|
||||||
|
applied.
|
||||||
|
3. **Server contract `SEND_QUEUE_CONTRACTS.md` is the canonical wire
|
||||||
|
format.** Swift clients are out of spec if they don't read
|
||||||
|
`data.items`/`toHandle`. Server is not changed to match Swift; Swift
|
||||||
|
is brought up to match server.
|
||||||
|
4. **Bridge / osascript are explicitly transitional.** No new code
|
||||||
|
should reference `10.9.0.3:8766`, `imessage-send.scpt`, or
|
||||||
|
`sendViaBridge`. Phase 4 retires them.
|
||||||
|
5. **MacSync is the only iMessage send path** once Phase 1 lands.
|
||||||
|
`quinn-messenger MCP` and `outreach.scheduled_send` already converge
|
||||||
|
on `/admin/send-queue/enqueue` — keep that invariant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification checklist (per phase)
|
||||||
|
|
||||||
|
Each phase is complete only when:
|
||||||
|
|
||||||
|
- [ ] All affected Swift targets compile (`swift build`).
|
||||||
|
- [ ] All affected TS targets typecheck (`bunx tsc --noEmit`).
|
||||||
|
- [ ] One smoke message round-trips end-to-end and lands in `chat.db`
|
||||||
|
on plum (not just `status='sent'` in any queue table).
|
||||||
|
- [ ] Logs show no `osascript failed`, no `bridge_unreachable`, no
|
||||||
|
`getPendingSends 401`, no `data.messages` JSON path miss.
|
||||||
|
- [ ] `outreach.heartbeat` for `scheduled-send-worker` shows ticks
|
||||||
|
within the last 10s.
|
||||||
|
- [ ] `outreach.scheduled_send` has no rows stuck in `pending` past
|
||||||
|
their `fire_at` for >60s.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `docs/architecture.md` — system overview, invariants.
|
||||||
|
- `docs/adding-a-module.md` — canonical module pattern this plan
|
||||||
|
brings every existing module to.
|
||||||
|
- `docs/modules/imessage.md` — describes the target SyncManager shape.
|
||||||
|
- `src/server/SEND_QUEUE_CONTRACTS.md` — canonical wire format.
|
||||||
|
- `~/.claude/projects/.../memory/project_macsync_send_consumer_gap.md`
|
||||||
|
- `~/.claude/projects/.../memory/project_imessage_bridge_scpt.md`
|
||||||
|
- `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`
|
||||||
|
- `~/.claude/projects/.../memory/feedback_verify_delivery_first.md`
|
||||||
|
|
@ -368,7 +368,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
Task.detached {
|
Task.detached {
|
||||||
_ = try? await URLSession.shared.data(from: url)
|
_ = try? await URLSession.shared.data(from: url)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NSWorkspace.shared.open(
|
_ = NSWorkspace.shared.open(
|
||||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork")!
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork")!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -416,7 +416,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let granted = await iCalSync.reader.requestAuthorization()
|
let granted = await iCalSync.reader.requestAuthorization()
|
||||||
if !granted {
|
if !granted {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NSWorkspace.shared.open(
|
_ = NSWorkspace.shared.open(
|
||||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")!
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -430,7 +430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let granted = await iReminderSync.reader.requestAuthorization()
|
let granted = await iReminderSync.reader.requestAuthorization()
|
||||||
if !granted {
|
if !granted {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NSWorkspace.shared.open(
|
_ = NSWorkspace.shared.open(
|
||||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders")!
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders")!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,14 @@ class FakePool {
|
||||||
provider,
|
provider,
|
||||||
from_handle,
|
from_handle,
|
||||||
body,
|
body,
|
||||||
attributed_body,
|
|
||||||
body_decoded,
|
|
||||||
is_from_me,
|
is_from_me,
|
||||||
has_attachments,
|
has_attachments,
|
||||||
attachment_count,
|
attachment_count,
|
||||||
sent_at,
|
sent_at,
|
||||||
delivered_at,
|
delivered_at,
|
||||||
read_at,
|
read_at,
|
||||||
|
attributed_body,
|
||||||
|
body_decoded,
|
||||||
] = params;
|
] = params;
|
||||||
this.stored[id as string] = {
|
this.stored[id as string] = {
|
||||||
id,
|
id,
|
||||||
|
|
@ -92,11 +92,11 @@ describe('upsertMessage', () => {
|
||||||
expect(insert!.sql).toContain('attributed_body');
|
expect(insert!.sql).toContain('attributed_body');
|
||||||
expect(insert!.sql).toContain('body_decoded');
|
expect(insert!.sql).toContain('body_decoded');
|
||||||
const params = insert!.params;
|
const params = insert!.params;
|
||||||
// Position 8 (index 7): attributed_body
|
// Position 14 (index 13): attributed_body
|
||||||
expect(params[7]).toBeInstanceOf(Buffer);
|
expect(params[13]).toBeInstanceOf(Buffer);
|
||||||
expect((params[7] as Buffer).toString('base64')).toBe(base64);
|
expect((params[13] as Buffer).toString('base64')).toBe(base64);
|
||||||
// Position 9 (index 8): body_decoded
|
// Position 15 (index 14): body_decoded
|
||||||
expect(params[8]).toBeNull();
|
expect(params[14]).toBeNull();
|
||||||
|
|
||||||
// Round-trip back to base64 via hydrate
|
// Round-trip back to base64 via hydrate
|
||||||
expect(result.attributedBody).toBe(base64);
|
expect(result.attributedBody).toBe(base64);
|
||||||
|
|
@ -107,8 +107,8 @@ describe('upsertMessage', () => {
|
||||||
const pool = new FakePool();
|
const pool = new FakePool();
|
||||||
const result = await upsertMessage(pool as unknown as never, { ...baseDraft, body: 'hi' });
|
const result = await upsertMessage(pool as unknown as never, { ...baseDraft, body: 'hi' });
|
||||||
const insert = pool.calls.find((c) => c.sql.startsWith('INSERT INTO icloud.messages'));
|
const insert = pool.calls.find((c) => c.sql.startsWith('INSERT INTO icloud.messages'));
|
||||||
expect(insert!.params[7]).toBeNull();
|
expect(insert!.params[13]).toBeNull();
|
||||||
expect(insert!.params[8]).toBeNull();
|
expect(insert!.params[14]).toBeNull();
|
||||||
expect(result.attributedBody).toBeNull();
|
expect(result.attributedBody).toBeNull();
|
||||||
expect(result.bodyDecoded).toBeNull();
|
expect(result.bodyDecoded).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ const hydrate = (r: Row): Message => ({
|
||||||
deliveredAt: r.delivered_at,
|
deliveredAt: r.delivered_at,
|
||||||
readAt: r.read_at,
|
readAt: r.read_at,
|
||||||
syncedAt: r.synced_at,
|
syncedAt: r.synced_at,
|
||||||
attributedBody: r.attributed_body,
|
attributedBody: r.attributed_body ? r.attributed_body.toString('base64') : null,
|
||||||
bodyDecoded: r.body_decoded,
|
bodyDecoded: r.body_decoded,
|
||||||
associatedMessageType: r.associated_message_type,
|
associatedMessageType: r.associated_message_type,
|
||||||
associatedMessageGuid: r.associated_message_guid,
|
associatedMessageGuid: r.associated_message_guid,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export interface Message {
|
||||||
readonly readAt: string | null;
|
readonly readAt: string | null;
|
||||||
readonly syncedAt: string;
|
readonly syncedAt: string;
|
||||||
// Rich-message metadata
|
// Rich-message metadata
|
||||||
readonly attributedBody: Buffer | null;
|
readonly attributedBody: string | null;
|
||||||
/** Server-side decoded plain text from the attributedBody typedstream blob. */
|
/** Server-side decoded plain text from the attributedBody typedstream blob. */
|
||||||
readonly bodyDecoded: string | null;
|
readonly bodyDecoded: string | null;
|
||||||
readonly associatedMessageType: number | null;
|
readonly associatedMessageType: number | null;
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,13 @@ import { createHash } from 'node:crypto';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { pgGet, pgRun } from '@/shared/db';
|
import { pgGet, pgRun } from '@/shared/db';
|
||||||
import { logger } from '@/shared/logger';
|
import { logger } from '@/shared/logger';
|
||||||
import type { MessageSearchFilter, MessageSearchHit, Message, SearchResult } from '@/entities/message/types';
|
import type { MessageSearchFilter, MessageSearchHit, SearchResult } from '@/entities/message/types';
|
||||||
|
|
||||||
/** Hits stored in JSONB use base64 for `attributedBody` (Buffer isn't JSON-serialisable). */
|
// `attributedBody` is already a base64 string on Message, so JSONB storage is a passthrough.
|
||||||
type StoredMessage = Omit<Message, 'attributedBody'> & { attributedBody: string | null };
|
type StoredHit = MessageSearchHit;
|
||||||
type StoredHit = Omit<MessageSearchHit, 'message'> & { message: StoredMessage };
|
|
||||||
|
|
||||||
function serializeHits(hits: readonly MessageSearchHit[]): StoredHit[] {
|
function serializeHits(hits: readonly MessageSearchHit[]): StoredHit[] {
|
||||||
return hits.map((hit) => ({
|
return hits.map((hit) => ({ ...hit }));
|
||||||
...hit,
|
|
||||||
message: {
|
|
||||||
...hit.message,
|
|
||||||
attributedBody: hit.message.attributedBody
|
|
||||||
? hit.message.attributedBody.toString('base64')
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
|
function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
|
||||||
|
|
@ -26,9 +17,6 @@ function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
|
||||||
message: {
|
message: {
|
||||||
...hit.message,
|
...hit.message,
|
||||||
attachmentMimeTypes: hit.message.attachmentMimeTypes ?? [],
|
attachmentMimeTypes: hit.message.attachmentMimeTypes ?? [],
|
||||||
attributedBody: hit.message.attributedBody
|
|
||||||
? Buffer.from(hit.message.attributedBody, 'base64')
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ export const SyncHistoryRepo = {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setClauses.length === 0) return this.getById(id);
|
if (setClauses.length === 0) {
|
||||||
|
const existing = await this.getById(id);
|
||||||
|
if (!existing) throw new Error(`sync_history row ${id} not found`);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE icloud.sync_history
|
UPDATE icloud.sync_history
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ function makeHit(
|
||||||
id: string,
|
id: string,
|
||||||
body: string,
|
body: string,
|
||||||
convId: string,
|
convId: string,
|
||||||
attributedBody: Buffer | null = null,
|
attributedBody: string | null = null,
|
||||||
): MessageSearchHit {
|
): MessageSearchHit {
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|
@ -72,6 +72,7 @@ function makeHit(
|
||||||
fromHandle: '+15550000000',
|
fromHandle: '+15550000000',
|
||||||
body,
|
body,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
|
bodyDecoded: null,
|
||||||
hasAttachments: false,
|
hasAttachments: false,
|
||||||
attachmentCount: 0,
|
attachmentCount: 0,
|
||||||
attachmentMimeTypes: [],
|
attachmentMimeTypes: [],
|
||||||
|
|
@ -204,12 +205,12 @@ describe('setSearchCache / getSearchCache', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips attributedBody as Buffer via JSONB base64', async () => {
|
it('round-trips attributedBody as base64 via JSONB', async () => {
|
||||||
const { pool } = testDb;
|
const { pool } = testDb;
|
||||||
const convId = randomUUIDv7();
|
const convId = randomUUIDv7();
|
||||||
const rawBody = Buffer.from([0x01, 0x02, 0x03, 0xff]);
|
const rawBase64 = Buffer.from([0x01, 0x02, 0x03, 0xff]).toString('base64');
|
||||||
const uniqueWord = `attrbody-${randomUUIDv7().slice(0, 8)}`;
|
const uniqueWord = `attrbody-${randomUUIDv7().slice(0, 8)}`;
|
||||||
const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBody);
|
const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBase64);
|
||||||
const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });
|
const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -217,9 +218,7 @@ describe('setSearchCache / getSearchCache', () => {
|
||||||
|
|
||||||
const cached = await getSearchCache(pool, key);
|
const cached = await getSearchCache(pool, key);
|
||||||
expect(cached).not.toBeNull();
|
expect(cached).not.toBeNull();
|
||||||
const buf = cached!.hits[0]!.message.attributedBody;
|
expect(cached!.hits[0]!.message.attributedBody).toBe(rawBase64);
|
||||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
|
||||||
expect((buf as Buffer).equals(rawBody)).toBe(true);
|
|
||||||
} finally {
|
} finally {
|
||||||
await clearSearchCache(pool, key);
|
await clearSearchCache(pool, key);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue