Collab status: PTAsk and PTLanes
0.4.25 (23:45 UTC): live updates for the public app are on split/collab (merged from dev 1a50c81, no CI run yet):
LiveUpdatesin PTSync (kept feed items, refresh at launch and foreground with a 5-minute floor, one background hook, ephemeral URLSession, promptugal.news only), PTCoreLiveContent(stories and agenda from the kept files) and a small change in PTAgendaReaderStoreto use them before the app's seed. A test pins every feed host to promptugal.news. Needs from apps: the glue file and Info.plist/project/apps.json lines under "Live updates wiring (0.4.25)" at the end. Ask is not touched (the Release build no longer ships it).
Branch: split/collab · Last CI run: none for this round (conductor: no runs) · Updated: 23:45 UTC, 8 Nov (collab.B)
Rulings from the conductor (7 Nov, 23:47 UTC)
CollabSeams.swiftgoes in PTCore, one file, owned by collab: protocols, value types and the formatter only.CollabVaultAccessthere uses plain types.VaultCollabAccesslives in PTVaultUI (agreed with vault through the conductor). Add the file only after platform's phase 2 is on dev.AskContextProvider,PublicAskProvider,TeamAskProviderapproved. Consent and context switches behave as today in the Team app.- The 4 team AITests may move to the Team test target.
- Order: PTLanes once PTVaultUI is on dev; PTAsk last, after PTAgenda, PTNews and PTVaultUI. The conductor pings at each step.
Design's map (7 Nov, 23:48 UTC)
Two TeamFeatures.shared uses must be covered: AIContext.swift:320 (teamData, now TeamAskProvider.part(for:)) and AskView.swift:196 (AskMentions schedule, now isPicked / togglePick on the provider). Added to the protocol above.
Correction (7 Nov, 23:48 UTC)
PublicAskProvider uses ScheduleStore for myPickIds, isPicked and togglePick, not the reader's old saved sessions. Both providers share the same pick list. They differ only in the team parts.
Done
- Read the three-apps README, contracts and the overnight rules.
- Mapped what
AI/(9 files) andLanes/(9 files) use from outside. About 4,000 lines. - Designed the provider split (below).
In progress
- Merged origin/dev (PTVaultUI e5328ff) at 00:41 UTC.
CollabSeams.swiftwritten for PTCore (committed locally, not pushed: PTCore phase 2 is not on dev yet). It holdsCollabVaultAccess,CollabHooks(vault nil by default,me,currentPage,HookError),PageContext,CollabIdentity,CollabErrorText,collabJS. Protocol text sent to the conductor for vault.- PTLanes move prepared locally, not pushed: 9 files
git mv'd,import PTCore,publiconly where outside code needs it (LanesView,LaneEventRow,LaneEvent,LanesLog,LanesStore.shared/log/laneCount/hasEnumKey,LaneIdentity.exists),LanesEnvironment.linkedVaultsreplacesCoordinationStorein team notes,LanesTestsmoved to the package. Team side:TeamLanesWiring.install()(Coordination/), called fromCoordinationStore.shared. - Risk:
LanesViewusesDebugMode(Debug/). If it is not in PTCore after phase 2, I need it moved or injected.
Next
- Push the prepared commits when the conductor says PTCore is on dev; read CI and fix. Original
Coordination/CollabHooks.swiftandCollabErrorText.swiftlose the moved parts then (Team keeps agenda/myPickIds/dayPlan as aCollabHooksextension). - When PTCore, PTAgenda, PTNews, PTVaultUI are on dev: merge
origin/dev, then step 1 to 5 below.
Blocked / needs from others
- Conductor / platform: a ruling on where the shared "collab seams" live (see Q1). Recommendation: PTCore.
- vault:
VaultSessionpublic API in PTVaultUI, enough forVaultCollabAccess(readFile, update, importKey, savePicks, files, append, vaults, readerVaultId, isLocked). - team:
ScheduleStoreandPlannerStorepublic in PTAgenda;FeedClientandEventpublic in PTNews/PTAgenda;TeamFeaturesstays in the Team app. - design:
Destinations.swift,DebugHomeView,SystemPanelsreferenceAskView,LanesView,CollabDebugPanel; they needimport PTAsk/import PTLanes(Exports.swift gives that for free) and the types to bepublic.WebPage.swiftcallsCollabHooks.noteCurrentPage, so that call must stay reachable from PTWeb's owner (see Q1).
1. What AI/ and Lanes/ use from outside
| Used | By | Where it goes |
|---|---|---|
HTTPClient, HTTPLog, Lisbon, Design | AI, Lanes | PTCore (already allowed) |
Event, FeedClient, PickId, ScheduleStore (AskMentions) | AI | PTNews / PTAgenda |
VaultSession via VaultCollabAccess (CollabVaultGlue.swift) | AI key copy, Lanes, team notes | PTVaultUI (glue), protocol lower (Q1) |
CollabHooks (vault, agenda, myPickIds, me, dayPlan, currentPage) | AI, Lanes | split: see below |
CollabErrorText, CollabIdentity, PageContext, ISO8601DateFormatter.collabJS, HookError | AI, Lanes | Q1 |
TeamFeatures.shared (.team, .schedule, .planner) | AIContext, AskView, CollabHooks defaults | Team app only. Replaced by a provider |
TeamPerson, CommandCentre (Team models) | AIContextAssembler.teamPart/agentsPart | Team app, with the provider |
CoordinationStore.shared | AIContext (teammates), AITools (where_is_everyone), Lanes TeamNotes | Team app only. Provider / injected closure |
CollabTeamGlue.planText (DayPlanner.Plan) | CollabHooks.dayPlan default | Team app (provider) |
Used by the app from outside: Destinations.swift (AskView, LanesView, TeamPlansView), DebugHomeView and SystemPanels (CollabDebugPanel), WebPage.swift (noteCurrentPage). Tests: AITests, LanesTests, CoordinationTests, plus DesignReviewTests (references).
2. Provider split
Principle: PTAsk and PTLanes never name TeamFeatures, CoordinationStore, TeamPerson or CommandCentre. Each app supplies a provider at launch.
PTAsk
@MainActor public protocol AskContextProvider: AnyObject {
/// The kinds this app offers. Public app: [.agenda, .picks, .page]. Team app: all seven.
var supportedKinds: Set<AIContextKind> { get }
var personName: String? { get } // used in the system prompt; nil in the public app
func agenda() async -> [Event]
func myPickIds() -> [String]
func isPicked(_ event: Event) -> Bool // AskMentions (AskView.swift:196) used TeamFeatures.shared.schedule
func togglePick(_ event: Event) // both providers use ScheduleStore (PTAgenda), one pick list synced with promptugal.news
func part(for kind: AIContextKind, now: Date) async -> AIContextPart? // .team .agents .teammates .plan; public app returns nil
func runTool(_ name: String, args: [String: String], now: Date) async -> (text: String, summary: String)? // team_status, my_agents, where_is_everyone
var extraStarters: [AIStarter] { get } // "My agents" only in Team
}
public enum AskEnvironment { @MainActor public static var provider: any AskContextProvider = PublicAskProvider() }
PublicAskProvider(in PTAsk): agenda from the reader cache thenFeedClient, picks throughScheduleStore(PTAgenda, the one synced schedule), no team, no person name. It is the default, so the public app works with no wiring.AIContextKindenum is unchanged (move, don't rewrite). The consent sheet, toggles, tool list and gatherer filter bysupportedKinds. A kind the app does not support is not shown, not gathered, and its tools are not offered.AITools.definitions(for:)keeps the 6 definitions;team_status,my_agents,where_is_everyoneare dropped when their kind is unsupported (same filter that exists today).- Consent flow,
AIKeyStore, OpenRouter client and the Keychain code move untouched. Consent tests stay as they are. - The "copy key to vault" action keeps using
CollabHooks.vault(PTVaultUI is in both apps).
Team app (stays in Coordination/, later Apps/Team/)
TeamAskProvider: AskContextProviderreadsTeamFeatures.shared(schedule, planner, team),CoordinationStore.sharedandCollabTeamGlue. It carries the code that now sits inAIContextGatherer.teamPart/agentsPart/teamDataand the.teammates/.planbranches.AIContextAssembler.teamPart/agentsPart(pure text builders overTeamPerson/CommandCentre) move to the Team app with the provider. The 4 tests that call them (AITests lines ~181–211) move to the Team test target. All other AITests stay with PTAsk.- Installed once at launch:
AskEnvironment.provider = TeamAskProvider(). The Team app keeps every current feature.
PTLanes
- Depends only on PTCore and PTVaultUI. It uses the vault (
CollabHooks.vault),me,currentPageandCollabErrorText, which are not team-specific (Q1). - The one team link is
TeamNotesStore.load(coordination: CoordinationStore), which readslinkedVaults. Replace the argument withlinkedVaults: @MainActor () -> [String: String]set by the Team app (LanesEnvironment.linkedVaults). Default: empty. - PTLanes is only in the Team app, so it needs no public-app variant.
Debug: CollabDebugPanel mixes AIKeyStore and LanesStore, so it cannot live in either package. It stays in the Team app (with CollabDebugPanel.keyRows used by SystemPanels). Both stores need public on the members it reads.
3. Open questions for the conductor
- Q1. Where do the shared seams live?
AI/andLanes/both needCollabHooks(vault, me, currentPage),CollabVaultAccess,HookError,CollabIdentity,PageContext,CollabErrorTextand thecollabJSdate formatter. PTAsk cannot depend on PTLanes and the reverse, so they go below both. All are pure Foundation. Recommendation: one new fileCollabSeams.swiftin PTCore (collab adds it, platform's files untouched), withVaultCollabAccess(needsVaultSession) in PTVaultUI, andCollabHooks.vaultdefaulting to nil in PTCore, set by PTVaultUI's owner (or the app) at launch.PickIdgoes withScheduleStorein PTAgenda (team).WebPage.swift(PTWeb) callsnoteCurrentPage, which is then in PTCore, so no upward edge. - Q2. OK to add that one file to PTCore, or should platform do it?
4. Steps when the dependencies are on dev
- Merge
origin/dev. AddCollabSeams.swift(Q1) andVaultCollabAccess. git mvLanes/*(minus nothing; all 9 files) toPackages/PTLanes/Sources/PTLanes/,public,import PTCore/PTVaultUI. InjectlinkedVaults. MoveLanesTests.swifttoPackages/PTLanes/Tests/. Push, read CI.- Add
AskContextProvider,PublicAskProvider;git mvAI/*(minusCollabDebugPanel.swift) toPackages/PTAsk/Sources/PTAsk/. AddTeamAskProviderinCoordination/. MoveAITests(except the 4 team-part tests) toPackages/PTAsk/Tests/. Push, read CI. - Check consent, own-vault and lanes tests are all still present and green. Report to
@parentwith branch, commit and run number.
Handoff (8 Nov, about 01:45 UTC, to conductor.B / account B)
Collab session stopped on the conductor's handoff. No scheduled tasks of mine remain and I dispatch no more CI.
Heads and last runs
| Branch | Head | Last run |
|---|---|---|
split/collab-seams | 11acc2e (from dev 116a350 era, only adds Packages/PTCore/Sources/PTCore/CollabSeams.swift) | ios.yml run 291 (id 42433846852), dispatched 01:20 UTC, still queued at 01:42 UTC, no result |
split/collab | a32e67a (last merge of origin/dev, 00:45 UTC; contains the prepared PTLanes move and the Coordination edits) | none. It cannot compile until CollabSeams.swift is on dev. Red is expected. |
Agreed order
CollabSeams.swift(branchsplit/collab-seams): read run 291 and merge commit 11acc2e if green.- Vault's
VaultCollabAccessandVaultCollabAccess.install()in PTVaultUI (vault holds it locally until step 1 is on dev). - PTLanes (
split/collab): merge origin/dev, thenTeamLanesWiring.install()should callVaultCollabAccess.install()instead of settingCollabHooks.vaultitself. AddswiftLanguageModes: [.v5]toPackages/PTLanes/Package.swift(team's moved packages use it). Dispatch ios.yml onsplit/collab, fix until green. - PTAsk last, after PTAgenda, PTNews and PTVaultUI are on dev.
What split/collab already holds (prepared, uncompiled)
- 9
Lanes/filesgit mv'd toPackages/PTLanes/Sources/PTLanes/,import PTCoreadded,publiconly where outside code needs it (LanesView+init,LaneEventRow+init,LaneEvent,LanesLog,LanesStore.shared/log/laneCount/hasEnumKey,LaneIdentity.exists). LanesEnvironment.linkedVaults(new, PTLanes) replacesCoordinationStoreinTeamNotesStore.load.LanesTests.swiftmoved toPackages/PTLanes/Tests/PTLanesTests/(@testable import PTLanes,import PTCore). The placeholder test and file were removed.- Team side:
Coordination/CollabHooks.swiftkeeps only team hooks (myPickIds,agenda,dayPlanas an extension of PTCore'sCollabHooks), plusReaderFilesandPickId.Coordination/CollabErrorText.swiftremoved (now in PTCore). NewCoordination/TeamLanesWiring.swift, called fromCoordinationStore.shared's initialiser as a stopgap until the Team app entry point (phase 4) callsTeamLanesWiring.install()once at launch. AI/CollabDebugPanel.swiftuseslanes.laneCount. It stays in the Team app.LanesViewusesDebugMode(now in PTCore).- Note:
CollabSeams.swiftalso sits onsplit/collab(commit d369089), so aftersplit/collab-seamsis on dev, merging origin/dev intosplit/collabwill show it as the same file. Expect an add/add conflict if the content differs; take dev's.
The exact CollabVaultAccess protocol (PTCore, public, plain types)
/// The vault, as collaboration features need it. Plain types only.
@MainActor
public protocol CollabVaultAccess: AnyObject {
/// A vault is open on this device.
var isOpen: Bool { get }
/// My own vault (the account's reader vault), never a vault imported from someone else's key. Nil when
/// none is open. Everything "mine" (my key copy, my plan, my notes) reads and writes only this vault (S5).
var ownVaultId: String? { get }
/// My own vault was opened with a vault key (not a read key), so it can be written.
var canWrite: Bool { get }
/// A file of my own vault, or nil when there is no such file. Throws when my own vault is not open.
func read(_ path: String) async throws -> Data?
/// Write one file to my own vault (read-modify-write on the branch the website reads).
func write(_ path: String, _ data: Data, _ message: String) async throws
/// Open a teammate's vault with the read key they shared (`sgit_private_read_…:<vault_id>` or a `#key=` link).
/// Stored behind Face ID like any key. Returns the vault id.
func openShared(readKey: String, label: String) async throws -> String
/// A file of another open vault.
func read(_ path: String, vaultId: String) async throws -> Data?
/// My picks merged into the vault's copy (`apps/agenda/picks.json`, nr.schedule.v1). Returns the merged document.
func savePicks(_ document: Data) async throws -> Data
/// Commit one note file to my newsroom notes lane: the vault of my own whose read key the newsroom holds
/// (newsroom.sgit.ai "the feedback loop"; its vault key is in my master vault's `lane.json`).
func writeNewsroomNote(_ path: String, _ data: Data) async throws
/// Paths of the files under `folder` in a vault (mine when `vaultId` is nil).
func files(in folder: String, vaultId: String?) async throws -> [String]
/// A new file in `folder` of my vault, never overwriting (vault session's `append`). Returns its path.
func append(_ data: Data, to folder: String, message: String) async throws -> String
/// A file of my master vault (e.g. `inbox.json`, the inbox record the website made), or nil.
CollabHooks (PTCore): HookError (noVault, noWritableVault, noNotesLane, noOwnVault), vault (nil by default; the app installs VaultCollabAccess), me, currentPage, noteCurrentPage, clearCurrentPage. Also in PTCore: PageContext, CollabIdentity, CollabErrorText, ISO8601DateFormatter.collabJS. Team says PTWeb's WebBridgeHub now carries noteCurrentPage.
AskContextProvider design (PTAsk, approved by the conductor; not started)
@MainActor public protocol AskContextProvider: AnyObject {
var supportedKinds: Set<AIContextKind> { get } // public app: [.agenda, .picks, .page]; Team: all seven
var personName: String? { get } // nil in the public app
func agenda() async -> [Event]
func myPickIds() -> [String]
func isPicked(_ event: Event) -> Bool // AskMentions (AskView.swift:196)
func togglePick(_ event: Event) // both providers use ScheduleStore (PTAgenda)
func part(for kind: AIContextKind, now: Date) async -> AIContextPart? // .team .agents .teammates .plan; nil in public
func runTool(_ name: String, args: [String: String], now: Date) async -> (text: String, summary: String)? // team_status, my_agents, where_is_everyone
var extraStarters: [AIStarter] { get } // "My agents" only in Team
}
public enum AskEnvironment { @MainActor public static var provider: any AskContextProvider = PublicAskProvider() }
AIContextKindenum is unchanged. The consent sheet, toggles, gatherer andAITools.definitions(for:)filter bysupportedKinds. Consent behaves exactly as today in the Team app.PublicAskProvider(in PTAsk): agenda from the reader cache thenFeedClient, picks throughScheduleStore, no team.TeamAskProvider(inCoordination/): carriesAIContextGatherer.teamPart/agentsPart/teamDataand the.teammates/.planbranches (usesTeamFeatures.shared,CoordinationStore.shared,CollabTeamGlue).AIContextAssembler.teamPart/agentsPartand the 4 AITests that call them (AITests lines ~181–211) move to the Team app. All other AITests, including consent and own-vault, move toPackages/PTAsk/Tests/.AIKeyStore's vault copy keeps usingCollabHooks.vault.CollabDebugPanelstays in the Team app.AI/goes toPackages/PTAsk/Sources/PTAsk/withgit mv,import PTCore/PTAgenda/PTNews/PTVaultUI,swiftLanguageModes: [.v5].
Next steps
- Read run 291. Report commit 11acc2e and the run number to the conductor.
- After vault's file and
CollabSeams.swiftare on dev: PTLanes as above, then PTAsk. python3 scripts/apps/check_apps.pybefore any push to dev (conductor only pushes to dev).
PTSync design (proposal for the next round, no code yet)
Source: briefs/2026-11-08__app-first-platform/README.md ("Offline: download, verify, render locally") and WS-199 (the team site's manifest, same shape as promptugal.news api/v1/manifest.json: {version, count, files:[{path, bytes, sha256, feature}]}). Written 10:30 UTC for conductor.B. It waits on Dinis's answers.
Where it sits
Packages/PTSyncdepends on PTCore only (HTTPClient and its log, Lisbon time). Apple SDK only: CryptoKit (SHA-256), Network (NWPathMonitor), BackgroundTasks, FileManager. No upward edge.- Readers do not import PTSync. PTCore gets one seam file, like
CollabSeams.swift:ContentSource(func data(at path: String) -> Data?,var generation: Int) andContentSources.register(_:for:).FeedClient,ReaderStoreand Ask's agenda read local data first through it, then fall back to the network as today. That keeps PTAgenda, PTNews and PTAsk free of a new edge. (Alternative: PTAgenda and PTNews depend on PTSync directly. Simpler, one more edge each. Conductor decides.) - PTLanes depends on PTSync for the outbox types (PTLanes → PTCore, PTVaultUI, PTSync).
apps.json: PTSync is listed by the app entry points (team, promptugal, riskmandate) and PTLanes;check_apps.pycomputes the closure, so platform editsapps.jsonand the projects.
Package API (all public)
public struct SyncSource: Sendable { id: String; base: URL; manifestPath = "api/v1/manifest.json"; allowedHosts: Set<String> }
public struct Manifest: Decodable, Sendable { version: String; files: [ManifestEntry] } // unknown fields ignored
public struct ManifestEntry: Decodable, Sendable { path: String; bytes: Int; sha256: String; feature: String? }
public enum SyncPack: String, Sendable { case core, full, media }
public struct PackRules: Sendable { func pack(for: ManifestEntry) -> SyncPack } // by `feature` and path prefix; each app passes its own
public final class ContentStore: ContentSource, Sendable { // one per source
init(source: SyncSource, seed: URL?, directory: URL) // first launch installs the seed as generation 0
func data(at path: String) -> Data?; var manifest: Manifest?; var syncedAt: Date?; var generation: Int
func snapshot() -> ContentSnapshot // a screen holds one snapshot, so a switch never mixes generations
}
public actor SyncEngine {
init(store: ContentStore, rules: PackRules, http: HTTPClient = .shared)
func sync(_ packs: Set<SyncPack>, wifiOnly: Set<SyncPack> = [.full, .media]) async throws -> SyncReport // added, changed, removed, bytes, failures
var status: AsyncStream<SyncStatus> { get } // idle, fetching manifest, n of m files, switched, failed(reason)
}
public actor Outbox { // writes queued offline
init(directory: URL, sender: any OutboxSender, path: NetworkPath)
func enqueue(_ item: OutboxItem) async // kind, payload Data, idempotencyKey; persisted
func flush() async // in order, backoff 2s ... 10 min, stops on no network
var pending: [OutboxItem] { get }
}
public protocol OutboxSender: Sendable { func send(_ item: OutboxItem) async throws } // Lanes, notes to the newsroom, picks supply their own
public enum SyncBackground { static func register(taskIdentifier: String, run: @escaping @Sendable () async -> Void); static func schedule() } // id from the entry point, as VaultBackground
How a sync works
- GET the manifest (
If-None-Matchwith the stored ETag; 304 means nothing to do). Diff against the current generation's manifest: unchanged (same sha256), changed, added, removed. - Select by pack:
corealways,fullon Wi-Fi or by choice,mediaopt-in.NWPathMonitor(isExpensive,isConstrained) gates the big packs. - Stage in
gen-N.tmp/: clone the unchanged files from the current generation (APFS clone, no copy cost), download the changed ones (6 at a time,HTTPClient, so Debug → Network shows them). Each file must match the manifest'sbytesandsha256; a mismatch is fetched once more, then reported and left out of the switch. - If every wanted file verified: rename
gen-N.tmptogen-N, then replace the smallCURRENTpointer file atomically (replaceItemAt). Keep two generations, delete the rest. A killed or half-done sync never shows. - Safety: HTTPS only, host must be in
allowedHosts, paths with.., a leading/or a scheme are refused, a download larger than its manifestbytesis cut off, nothing is executed. Public data only; private data stays in vaults.
- Build seed: a CI step before
xcodebuilddownloads the manifest and thecorepack (PrompTugal alsofull, about 20 MB) intoApps/<App>/Seed/<source>/, added as a folder resource. The seed carries its manifest, so the first sync downloads only what changed since the build. A Linux check verifies the seed's hashes. The apps work in airplane mode from the first launch.
What each app calls
| App | Sources | Calls |
|---|---|---|
| PrompTugal | promptugal.news | At launch: ContentStore + SyncEngine, register with ContentSources, SyncBackground.register(task id). Pull-to-refresh: sync([.core]). Settings "Download everything": sync([.core, .full]), a media toggle. Shows "synced N min ago". |
| Team | promptugal.news and web-summit.sgit.ai (needs WS-199) | Same, two stores. Outbox is also wired for Lanes messages, newsroom notes, picks and check-ins. |
| RiskMandate | riskmandate.ai | Seed only until the site has a manifest; then the same calls. |
Test plan
- Package tests (
swift test, no app host,URLProtocolstub): manifest decoding (unknown fields, empty, huge); diff (added, changed, removed, unchanged); wrong sha256 refused, retried once, then reported; too-short and too-long downloads refused; path traversal and foreign hosts refused; a kill after N files leaves the current generation readable and unchanged; atomic switch; a snapshot held across a switch keeps the old data; pruning keeps two generations; pack and Wi-Fi gating with a fake path monitor; seed install, then diff against the seed's manifest fetches only changes; 304 fetches nothing. - Outbox tests: persistence across a reload, order kept, backoff schedule, idempotency key makes a resend safe, flush stops when the network goes and resumes when it returns, the queue is capped.
- CI: package tests run in the package matrix; the seed hash check runs on Linux in
ios-changes.yml; an optional, non-gating run reads the live manifest. On device (manual, in the build note): airplane-mode launch shows the agenda; sync over cellular fetches onlycore.
Questions for the conductor and Dinis
- Readers: the PTCore
ContentSourceseam (recommended) or direct PTSync dependencies? - Does
versionin the manifest change on every site build? (Needed so an unchanged build costs one 304.) - Are the 2,330 promptugal.news files all public and cacheable (no cookies)? Does it send an
ETag? - Should removed files be deleted from the local store at the next switch (recommended) or kept for a day?
- Seed size: is a 20 MB
fullseed in the PrompTugal IPA acceptable, or onlycore(1–2 MB) in the build andfulldownloaded on first Wi-Fi?
PTSync wiring for the PrompTugal app (for apps; not on my branch)
Conductor ruling (10:55 UTC): Apps/PrompTugal is apps' to edit, so my branch carries PTSync (package, tests, PTCore seam, seed script) and none of the app wiring. This is the wiring I built and had green-checked locally with check_apps.py: apply it with git apply (save the block below to a file) or copy the pieces.
apps.json:promptugal.packagesbecomes["PTAsk", "PTSync"].PrompTugal.xcodeproj: PTSync local package reference, product dependency and framework build file (same five places as PTVaultUI).Info.plist: addnews.promptugal.reader.syncto BGTaskSchedulerPermittedIdentifiers.PrompTugalApp.swift:import PTSync,PublicSync.start()ininit, and inonChange(of: scenePhase):SyncBackground.schedule()on background and a refresh on active.- New
Apps/PrompTugal/Shell/PublicSync.swift: opens theContentStore(looks for a bundledSeed/promptugal.newsfolder), registers it with PTCore'sContentSources, runs theSyncEnginewithPackRules.promptugalNews, and registers the background task.
diff --git a/Apps/PrompTugal/Info.plist b/Apps/PrompTugal/Info.plist
index 8be4469..26ff6d0 100644
--- a/Apps/PrompTugal/Info.plist
+++ b/Apps/PrompTugal/Info.plist
@@ -10,6 +10,7 @@
<array>
<string>news.promptugal.reader.refresh</string>
<string>news.promptugal.reader.vault-refresh</string>
+ <string>news.promptugal.reader.sync</string>
</array>
<key>CFBundleURLTypes</key>
<array>
diff --git a/Apps/PrompTugal/PrompTugal.xcodeproj/project.pbxproj b/Apps/PrompTugal/PrompTugal.xcodeproj/project.pbxproj
index 69b7e47..47a8c07 100644
--- a/Apps/PrompTugal/PrompTugal.xcodeproj/project.pbxproj
+++ b/Apps/PrompTugal/PrompTugal.xcodeproj/project.pbxproj
@@ -15,6 +15,7 @@
5B2719121FAAE433CDC36721 /* PTNews in Frameworks */ = {isa = PBXBuildFile; productRef = 8CA679B880DAE215C2AF5ED5 /* PTNews */; };
FA561A52534BBBD0C4E9F720 /* PTAsk in Frameworks */ = {isa = PBXBuildFile; productRef = FC463707B728B9D5A483F362 /* PTAsk */; };
62C152B82B7782691901FDE6 /* PTVaultUI in Frameworks */ = {isa = PBXBuildFile; productRef = 00CAC9EBB3D202E3D0898BB2 /* PTVaultUI */; };
+ A5C9E1D07B2F4A6E8D31C0F1 /* PTSync in Frameworks */ = {isa = PBXBuildFile; productRef = B6D0F2E18C3A5B7F9E42D1A2 /* PTSync */; };
AD62C18680AF9EC5A07E5B5A /* PrompTugalWidgets.appex in Embed */ = {isa = PBXBuildFile; fileRef = 0485B70324B3CAF2A57E8008 /* PrompTugalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A3DB1CA94FAE24CD2F779D42 /* PTAgenda in Frameworks */ = {isa = PBXBuildFile; productRef = 32154EC416734B3A20FF1516 /* PTAgenda */; };
FA989053DC0B7A670553676E /* PrompTugalIntents.appex in Embed */ = {isa = PBXBuildFile; fileRef = 6C6F5DFA30844948E2F80344 /* PrompTugalIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -159,6 +160,7 @@
5B2719121FAAE433CDC36721 /* PTNews in Frameworks */,
FA561A52534BBBD0C4E9F720 /* PTAsk in Frameworks */,
62C152B82B7782691901FDE6 /* PTVaultUI in Frameworks */,
+ A5C9E1D07B2F4A6E8D31C0F1 /* PTSync in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -266,6 +268,7 @@
8CA679B880DAE215C2AF5ED5 /* PTNews */,
FC463707B728B9D5A483F362 /* PTAsk */,
00CAC9EBB3D202E3D0898BB2 /* PTVaultUI */,
+ B6D0F2E18C3A5B7F9E42D1A2 /* PTSync */,
);
productName = PrompTugal;
productReference = 0D08D33B90E2593AC336F1CD;
@@ -409,6 +412,7 @@
689CF41318B6542F4B8EB9FE /* XCLocalSwiftPackageReference "../../Packages/PTNews" */,
020358F86EADE19098D947DA /* XCLocalSwiftPackageReference "../../Packages/PTAsk" */,
5B8B55ACA6103253EC13D5C9 /* XCLocalSwiftPackageReference "../../Packages/PTVaultUI" */,
+ C7E1A3F29D4B6C8A0F53E2B3 /* XCLocalSwiftPackageReference "../../Packages/PTSync" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6184002F36CB9470245CDFFC /* Products */;
@@ -857,6 +861,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = ../../Packages/PTVaultUI;
};
+ C7E1A3F29D4B6C8A0F53E2B3 /* XCLocalSwiftPackageReference "../../Packages/PTSync" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../../Packages/PTSync;
+ };
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -895,6 +903,11 @@
package = 5B8B55ACA6103253EC13D5C9 /* XCLocalSwiftPackageReference "../../Packages/PTVaultUI" */;
productName = PTVaultUI;
};
+ B6D0F2E18C3A5B7F9E42D1A2 /* PTSync */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = C7E1A3F29D4B6C8A0F53E2B3 /* XCLocalSwiftPackageReference "../../Packages/PTSync" */;
+ productName = PTSync;
+ };
32154EC416734B3A20FF1516 /* PTAgenda */ = {
isa = XCSwiftPackageProductDependency;
package = E0A0E149DD8F39A2BD3E95E5 /* XCLocalSwiftPackageReference "../../Packages/PTAgenda" */;
diff --git a/Apps/PrompTugal/PrompTugalApp.swift b/Apps/PrompTugal/PrompTugalApp.swift
index 6df6748..f64eca4 100644
--- a/Apps/PrompTugal/PrompTugalApp.swift
+++ b/Apps/PrompTugal/PrompTugalApp.swift
@@ -2,6 +2,7 @@ import SwiftUI
import PTCore
import PTAgenda
import PTVaultUI
+import PTSync
/// Entry point of the public PrompTugal app (news.promptugal.reader).
/// Background task ids for this app: news.promptugal.reader.refresh and
@@ -25,6 +26,8 @@ struct PrompTugalApp: App {
AgendaHub.publishNowNext = { agenda, saved in NowNextPublisher.publish(agenda: agenda, saved: saved) }
// Background tasks must be registered before the app finishes launching.
VaultBackground.register(taskIdentifier: "news.promptugal.reader.vault-refresh")
+ // Synced files of promptugal.news (PTSync): the build's seed, then the site's manifest. Also a background task.
+ PublicSync.start()
}
var body: some Scene {
@@ -32,7 +35,11 @@ struct PrompTugalApp: App {
PublicShell()
}
.onChange(of: scenePhase) { _, phase in
- if phase == .background { VaultBackground.schedule() }
+ if phase == .background {
+ VaultBackground.schedule()
+ SyncBackground.schedule()
+ }
+ if phase == .active { Task { await PublicSync.refresh() } }
}
}
}
diff --git a/Apps/PrompTugal/Shell/PublicSync.swift b/Apps/PrompTugal/Shell/PublicSync.swift
new file mode 100644
index 0000000..91de7d9
--- /dev/null
+++ b/Apps/PrompTugal/Shell/PublicSync.swift
@@ -0,0 +1,44 @@
+import Foundation
+import PTCore
+import PTSync
+
+/// The public app's synced files: promptugal.news' manifest, kept on this device and checked against its SHA-256 hashes.
+/// The core pack (agenda, days, stories, indexes, about 1.5 MB) comes first; the full pack (graph, search, entity pages)
+/// follows when the connection is not cellular or in Low Data Mode. Nothing here is a screen: readers take the files
+/// through PTCore's `ContentSources` (site id `promptugal.news`), and fall back to the network when it holds nothing.
+@MainActor
+enum PublicSync {
+ /// Also listed in Info.plist under BGTaskSchedulerPermittedIdentifiers.
+ static let taskIdentifier = "news.promptugal.reader.sync"
+
+ private(set) static var store: ContentStore?
+ private(set) static var engine: SyncEngine?
+ private static let network = SystemNetworkPath()
+
+ /// Called once from the app's `init`, before any view reads content. Opens the store off the main thread, registers it,
+ /// then runs a first sync.
+ static func start() {
+ SyncBackground.register(taskIdentifier: taskIdentifier) { await PublicSync.refresh() }
+ Task {
+ // A folder named "Seed" in the app bundle holds the build's seed: Seed/promptugal.news/manifest.json and files.
+ let seed = Bundle.main.url(forResource: "Seed", withExtension: nil)?
+ .appendingPathComponent(SyncSource.promptugalNews.id, isDirectory: true)
+ let opened = await ContentStore.open(source: .promptugalNews, seed: seed)
+ store = opened
+ ContentSources.register(opened, for: SyncSource.promptugalNews.id)
+ engine = SyncEngine(store: opened, rules: .promptugalNews, network: network)
+ _ = await refresh()
+ }
+ }
+
+ /// One sync. Core always; full when the connection allows (the engine leaves it out otherwise). True when nothing failed.
+ @discardableResult
+ static func refresh(packs: Set<SyncPack> = [.core, .full]) async -> Bool {
+ guard let engine else { return false }
+ do {
+ return try await engine.sync(packs).failures.isEmpty
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/apps.json b/apps.json
index 64ea72d..4e36acf 100644
--- a/apps.json
+++ b/apps.json
@@ -15,7 +15,7 @@
"paths": ["Apps/Team/"],
"packages": ["PTLanes", "PTAsk"], "testflight": true},
"promptugal": {"project": "Apps/PrompTugal/PrompTugal.xcodeproj", "scheme": "PrompTugal", "bundle": "news.promptugal.reader",
- "paths": ["Apps/PrompTugal/"], "packages": ["PTAsk"], "testflight": true},
+ "paths": ["Apps/PrompTugal/"], "packages": ["PTAsk", "PTSync"], "testflight": true},
"riskmandate": {"project": "Apps/RiskMandate/RiskMandate.xcodeproj", "scheme": "RiskMandate", "bundle": "ai.riskmandate.app",
"paths": ["Apps/RiskMandate/"], "packages": ["PTCore"], "testflight": true}
},
Live updates wiring (0.4.25, for apps; supersedes the PTSync wiring above for this round)
What is on split/collab (all packages; nothing under Apps/):
- PTSync
LiveUpdates:LiveUpdates()owns aFeedStoreand aFeedSyncerforLiveFeed.publicApp(promptugal.newsfeed.xmlandapi/v1/newsletter.json, https, host pinned).start()registers the store as PTCore'sLiveFeeds.source.refresh()fetches (skipped when the last one began less than 5 minutes ago;refresh(force: true)always fetches).backgroundWork()is for the BGAppRefresh closure. The session isURLSessionTransport.ephemeral(): no cookies, no disk cache, no stored credentials, no identifier beyond the app version in the User-Agent that PTCore already sends. - PTCore
LiveContent:LiveContent.stories()(kept feed items asStory, promptugal.news https links only) andLiveContent.agenda()(the agenda from the synced files, when aContentSourcessource forpromptugal.newsis registered). Both answer empty or nil when nothing is registered, so the readers behave as before. - PTAgenda
ReaderStore(two small edits, for design to know): on first launch with no cache it takes the agenda fromLiveContent.agenda()beforeAgendaHub.seedAgenda(), and it showsLiveContent.stories()when the cache and the network have no news. - Test:
LiveUpdatesTests.onlyPromptugalNewsfails if any feed inLiveFeed.publicApp, or the sync source, is not https on promptugal.news. A feed on another host is refused before any request (otherHostRefused).
To apply in the app:
apps.json: add"PTSync"topromptugal.packages.PrompTugal.xcodeproj: PTSync local package reference, product dependency and framework build file (same five places as PTDeck).Info.plist: addnews.promptugal.reader.syncto BGTaskSchedulerPermittedIdentifiers.- New
Apps/PrompTugal/App/PublicLive.swift:
import Foundation
import PTCore
import PTSync
/// Live news for the public app: promptugal.news feeds only, kept on the device, refreshed at launch, when the app
/// comes to the front and in the background.
enum PublicLive {
static let taskIdentifier = "news.promptugal.reader.sync" // also in Info.plist
static let updates = LiveUpdates()
/// Call once from `PrompTugalApp.init`, before the first screen reads.
static func start() {
updates.start()
SyncBackground.register(taskIdentifier: taskIdentifier) { await updates.backgroundWork() }
Task { await updates.refresh() }
}
static func foreground() { Task { await updates.refresh() } }
}
PrompTugalApp.swift:import PTSync;PublicLive.start()ininit; in.onChange(of: scenePhase)(which is#if DEBUGtoday, move it out of the guard):if phase == .active { PublicLive.foreground() }andif phase == .background { SyncBackground.schedule() }next to the DEBUG-onlyVaultBackground.schedule().
Not in this round: the content pack (ContentStore, Seed folder, SyncEngine). LiveContent.agenda() starts helping when that is registered; until then the agenda seed in the app is used as before.