~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS check holds writes to lexicons, and words to each pull state

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-ejected

Only what this pull adds. just ios-look draws the iOS app's screens in any state landed as 1c3ae05, so its changes do not show here again.

.knotless/workflows/ios.yml+16−3
@@ -1,6 +1,7 @@
# The iOS app's models against the XRPC answers the screenshots recorded, so a
# model that misreads an answer fails here rather than on a screen. Xcode needs
# macOS, so the app itself is built by hand (ios/README.md). Only on pulls that
# model that misreads an answer fails here rather than on a screen; what its
# merge section says of each state; and every write it makes against its lexicon.
# Xcode needs macOS, so the app itself is built by hand (ios/README.md). Only on pulls that
# touch what it reads, and no check deploy.yml waits for: nothing the app does
# holds up a deploy.
image: swift:6.2
@@ -9,5 +10,17 @@ on:
base: [refs/heads/main, refs/heads/feature/*]
paths: [ios/**, lexicons/**, harness/shots/fixtures.json, .knotless/workflows/ios.yml]
steps:
- name: tools
run: |
# What the step below needs to fetch and unpack Node, as this image hasn't them.
apt-get update -qq
apt-get install -y -qq --no-install-recommends curl ca-certificates xz-utils
- uses: chadtmiller.com/steps/node@1
with: {version: 24.22.1}
- uses: chadtmiller.com/steps/npm-ci@1
name: harness
with: {dir: harness, omit: dev}
- name: models
run: sh ios/check-models.sh
run: |
export PATH=/opt/node/bin:$PATH
sh ios/check-models.sh
crates/knotless-serve/tests/pages.rs+24−3
@@ -348,11 +348,32 @@ fn the_app_logs_in_with_a_code_only_it_can_trade() {
let named = format!("{}/.well-known/apple-app-site-association", server.base);
let (status, _, said) = browse("GET", &named, None, None);
assert_eq!(status, 200);
// What it answers is what the app is built to ask: without both, a browser
// session won't open for it, or won't come back. The file on Apple's CDN
// follows this one.
let project = include_str!("../../../ios/project.yml");
let entitlements = include_str!("../../../ios/Knotless/Knotless.entitlements");
let setting = |key: &str| {
project
.lines()
.find_map(|line| line.trim().strip_prefix(key))
.unwrap()
};
let app = format!(
"{}.{}",
setting("DEVELOPMENT_TEAM: "),
setting("PRODUCT_BUNDLE_IDENTIFIER: ")
);
let details = &said["applinks"]["details"][0];
assert_eq!(details["appIDs"][0], json!("YN68LN9T7Z.dev.knotless.app"));
assert_eq!(details["appIDs"][0], json!(app));
assert_eq!(details["components"][0]["/"], json!(where_to));
let apps = &said["webcredentials"]["apps"];
assert_eq!(apps[0], json!("YN68LN9T7Z.dev.knotless.app"));
assert_eq!(said["webcredentials"]["apps"][0], json!(app));
for service in ["applinks:knotless.dev", "webcredentials:knotless.dev"] {
assert!(
project.contains(service) && entitlements.contains(service),
"{service}"
);
}
// A wrong verifier spends the code, for whoever stole it.
let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
ios/Knotless/API/Client.swift+5−1
@@ -44,7 +44,11 @@ actor Client {
/// A write, as the account signed in. What it answers is left, as a screen
/// that writes reads what it shows again.
func call(_ method: String, _ input: [String: String]) async throws {
func send(_ write: Write) async throws {
try await call(write.method, write.input)
}
private func call(_ method: String, _ input: [String: String]) async throws {
#if DEBUG
if DevAccount.recording != nil { return }
#endif
ios/Knotless/API/Standing.swiftnew+73−0
@@ -0,0 +1,73 @@
import Foundation
/// Where a pull stands on landing, in the app's own words. An answer's `reason`
/// is written for the command line, with commands to run, so it isn't shown.
struct Standing: Equatable {
enum Tone { case good, waiting, bad, neutral }
let title: String
let icon: String
let tone: Tone
var lines: [String] = []
static func of(_ s: MergeStatus, base: String) -> Standing {
let queues = s.may?.queue == true && s.queueBlocked == nil
var after: [String] = []
if s.queueBlocked != nil { after.append("The merge queue is off until the owner delegates writes to this server.") }
if s.may?.merge != true { after.append("Only the owner and collaborators merge it.") }
if s.state == "draft" {
return Standing(title: "Still a draft", icon: "pencil.circle", tone: .neutral, lines: ["It can't be merged or queued until it's marked ready for review."])
}
if s.state == "conflicting" {
return Standing(title: "Conflicts with \(base)", icon: "exclamationmark.triangle.fill", tone: .bad, lines: ["Merge \(base) into its branch, resolve the files that conflict, then push."])
}
if s.reviews == "blocked" {
let missing = (s.reviewsMissing ?? []).map { $0.text.prefix(1).uppercased() + $0.text.dropFirst() }
return Standing(title: "Review required", icon: "xmark.circle.fill", tone: .bad, lines: missing + after)
}
switch s.checks {
case "failed":
return Standing(title: "Checks failed", icon: "xmark.circle.fill", tone: .bad, lines: ["It can land once they pass. Fix what failed and push."] + after)
case "running":
let line = queues ? "It can land once they pass. Queued now, it lands then." : "It can land once they pass."
return Standing(title: "Checks are running", icon: "clock", tone: .waiting, lines: [line] + after)
case "pending":
let line = queues
? "The merge queue checks it against \(base) as it is now, then lands it."
: "Its checks must pass against \(base) as it is now first."
return Standing(title: "Not checked yet", icon: "clock", tone: .waiting, lines: [line] + after)
default:
break
}
if s.queueOnly == true {
return Standing(title: "Ready for the merge queue", icon: "checkmark.circle.fill", tone: .good, lines: ["\(base) takes pulls only through the merge queue."] + after)
}
if s.state == "clean" {
return Standing(title: "Ready to merge", icon: "checkmark.circle.fill", tone: .good, lines: after)
}
return Standing(title: "Not ready to merge", icon: "clock", tone: .waiting, lines: after)
}
/// One the queue turned away, which can be queued again once fixed. The
/// commits it names mean nothing on a phone, and an older runner's reason
/// ends with a command to run.
static func ejected(reason: String?) -> Standing {
var why = reason ?? ""
for pattern in [#"[.;] [Tt]heir logs: `knotless run list [^`]*`.*$"#, #",? on \S+ at [0-9a-f]{7,40}"#, #" at [0-9a-f]{7,40}"#] {
why = why.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
let line = why.prefix(1).uppercased() + why.dropFirst()
return Standing(title: "Taken out of the merge queue", icon: "xmark.circle.fill", tone: .bad, lines: line.isEmpty ? [] : [line])
}
/// Whether the queue would take it, as the server would answer if asked.
static func canQueue(_ s: MergeStatus) -> Bool {
s.may?.queue == true && s.queueBlocked == nil && s.state != "conflicting" && s.state != "draft"
&& s.checks != "failed" && s.reviews != "blocked"
}
static func canMerge(_ s: MergeStatus) -> Bool {
s.may?.merge == true && s.queueOnly != true && s.state == "clean"
}
}
ios/Knotless/API/Writes.swiftnew+46−0
@@ -0,0 +1,46 @@
import Foundation
/// A write the app makes: a procedure, and what it's given. They're all here so
/// that `ios/check-models.sh` can hold each to its lexicon, with no server.
struct Write: Sendable {
let method: String
let input: [String: String]
}
enum Writes {
/// `verdict` makes it a review: `approve` or `changes`.
static func comment(repo: RepoRef, subject: String, body: String, verdict: String?) -> Write {
var input = ["repo": repo.uri, "subject": subject, "body": body]
if let verdict { input["verdict"] = verdict }
return Write(method: "createComment", input: input)
}
static func react(repo: RepoRef, subject: String, content: String, undo: Bool) -> Write {
Write(method: undo ? "removeReaction" : "addReaction", input: ["repo": repo.uri, "subject": subject, "content": content])
}
/// `open` or `closed`.
static func setState(repo: RepoRef, subject: String, state: String) -> Write {
Write(method: "setItemState", input: ["repo": repo.uri, "subject": subject, "state": state])
}
static func merge(repo: RepoRef, pull: String) -> Write {
Write(method: "mergePull", input: ["repo": repo.uri, "pull": pull])
}
static func queue(repo: RepoRef, pull: String) -> Write {
Write(method: "queuePull", input: ["repo": repo.uri, "pull": pull])
}
static func leaveQueue(repo: RepoRef, pull: String) -> Write {
Write(method: "leaveQueue", input: ["repo": repo.uri, "pull": pull])
}
static func star(repo: RepoRef, undo: Bool) -> Write {
Write(method: undo ? "unstarRepo" : "starRepo", input: ["repo": repo.uri])
}
static func seen(at: String) -> Write {
Write(method: "updateSeen", input: ["seenAt": at])
}
}
ios/Knotless/Views/InboxView.swift+1−1
@@ -96,7 +96,7 @@ struct InboxView: View {
// Seen, once all of them were on offer. The dots stay until the next
// look, so someone can see what was new this time.
if filter == nil, SessionStore.token != nil, let newest = items.first?.createdAt {
try? await Client.shared.call("updateSeen", ["seenAt": newest])
try? await Client.shared.send(Writes.seen(at: newest))
}
} catch let error as APIError where error.needsSignIn {
phase = .signedOut
ios/Knotless/Views/RepoView.swift+1−1
@@ -219,7 +219,7 @@ struct StarButton: View {
busy = true
defer { busy = false }
do {
try await Client.shared.call(starred ? "unstarRepo" : "starRepo", ["repo": repo.uri])
try await Client.shared.send(Writes.star(repo: repo, undo: starred))
count += starred ? -1 : 1
starred.toggle()
} catch {
ios/Knotless/Views/ThreadView.swift+1−1
@@ -86,7 +86,7 @@ struct ThreadView: View {
private func setState(_ next: String) {
Task {
do {
try await Client.shared.call("setItemState", ["repo": repo.uri, "subject": uri, "state": next])
try await Client.shared.send(Writes.setState(repo: repo, subject: uri, state: next))
edits += 1
} catch {
failure = error.localizedDescription
ios/Knotless/Views/Writing.swift+28−76
@@ -70,14 +70,13 @@ struct Composer: View {
sending = true
failure = nil
defer { sending = false }
var input = ["repo": repo.uri, "subject": uri, "body": text]
switch kind {
case .comment: break
case .approve: input["verdict"] = "approve"
case .changes: input["verdict"] = "changes"
let verdict: String? = switch kind {
case .comment: nil
case .approve: "approve"
case .changes: "changes"
}
do {
try await Client.shared.call("createComment", input)
try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: verdict))
sent()
dismiss()
} catch {
@@ -158,8 +157,7 @@ struct ReactionBar: View {
touched = next
Task {
do {
try await Client.shared.call(mine ? "removeReaction" : "addReaction",
["repo": repo.uri, "subject": subject, "content": content])
try await Client.shared.send(Writes.react(repo: repo, subject: subject, content: content, undo: mine))
changed()
} catch {
touched = nil
@@ -193,23 +191,23 @@ struct MergeSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let queue, queued {
said(title: queue.said, icon: "clock", color: .attention, lines: [])
BigButton(title: doing == "leaveQueue" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaveQueue") }
said(Standing(title: queue.said, icon: "clock", tone: .waiting))
BigButton(title: doing == "leaving" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaving", Writes.leaveQueue(repo: repo, pull: pull)) }
} else if let status {
// One the queue turned away can be queued again, once fixed.
if let queue {
said(title: queue.reason ?? queue.said, icon: "xmark.circle.fill", color: .danger, lines: [])
said(.ejected(reason: queue.reason))
}
summary(status)
let merges = canMerge(status)
said(.of(status, base: base))
let merges = Standing.canMerge(status)
if merges {
BigButton(title: doing == "mergePull" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true }
BigButton(title: doing == "merging" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true }
.confirmationDialog("Merge this pull?", isPresented: $confirming, titleVisibility: .visible) {
Button("Merge pull") { act("mergePull") }
Button("Merge pull") { act("merging", Writes.merge(repo: repo, pull: pull)) }
}
}
if canQueue(status) {
BigButton(title: doing == "queuePull" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("queuePull") }
if Standing.canQueue(status) {
BigButton(title: doing == "adding" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("adding", Writes.queue(repo: repo, pull: pull)) }
}
} else if loadFailed {
Text("Can't say whether this can merge.").foregroundStyle(.secondary)
@@ -234,79 +232,33 @@ struct MergeSection: View {
}
}
private func canQueue(_ s: MergeStatus) -> Bool {
s.may?.queue == true && s.queueBlocked == nil && s.state != "conflicting" && s.state != "draft"
&& s.checks != "failed" && s.reviews != "blocked"
}
private func canMerge(_ s: MergeStatus) -> Bool {
s.may?.merge == true && s.queueOnly != true && s.state == "clean"
}
/// Where it stands in the app's own words. The answer's `reason` is written
/// for the command line, with commands to run, so it isn't shown.
private func summary(_ s: MergeStatus) -> some View {
let it = standing(s)
return said(title: it.title, icon: it.icon, color: it.color, lines: it.lines)
}
private func said(title: String, icon: String, color: Color, lines: [String]) -> some View {
private func said(_ it: Standing) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon).foregroundStyle(color).frame(width: 24)
Image(systemName: it.icon).foregroundStyle(color(it.tone)).frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(title)
ForEach(lines, id: \.self) { line in
Text(it.title)
ForEach(it.lines, id: \.self) { line in
Text(line).font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
private func standing(_ s: MergeStatus) -> (title: String, icon: String, color: Color, lines: [String]) {
let queues = s.may?.queue == true && s.queueBlocked == nil
var after: [String] = []
if s.queueBlocked != nil { after.append("The merge queue is off until the owner delegates writes to this server.") }
if s.may?.merge != true { after.append("Only the owner and collaborators merge it.") }
if s.state == "draft" {
return ("Still a draft", "pencil.circle", .neutral, ["It can't be merged or queued until it's marked ready for review."])
}
if s.state == "conflicting" {
return ("Conflicts with \(base)", "exclamationmark.triangle.fill", .danger, ["Merge \(base) into its branch, resolve the files that conflict, then push."])
}
if s.reviews == "blocked" {
let missing = (s.reviewsMissing ?? []).map { $0.text.prefix(1).uppercased() + $0.text.dropFirst() }
return ("Review required", "xmark.circle.fill", .danger, missing + after)
}
switch s.checks {
case "failed":
return ("Checks failed", "xmark.circle.fill", .danger, ["It can land once they pass. Fix what failed and push."] + after)
case "running":
let line = queues ? "It can land once they pass. Queued now, it lands then." : "It can land once they pass."
return ("Checks are running", "clock", .attention, [line] + after)
case "pending":
let line = queues
? "The merge queue checks it against \(base) as it is now, then lands it."
: "Its checks must pass against \(base) as it is now first."
return ("Not checked yet", "clock", .attention, [line] + after)
default:
break
}
if s.queueOnly == true {
return ("Ready for the merge queue", "checkmark.circle.fill", .success, ["\(base) takes pulls only through the merge queue."] + after)
}
if s.state == "clean" {
return ("Ready to merge", "checkmark.circle.fill", .success, after)
private func color(_ tone: Standing.Tone) -> Color {
switch tone {
case .good: .success
case .waiting: .attention
case .bad: .danger
case .neutral: .neutral
}
return ("Not ready to merge", "clock", .attention, after)
}
private func act(_ method: String) {
doing = method
private func act(_ what: String, _ write: Write) {
doing = what
failure = nil
Task {
do {
try await Client.shared.call(method, ["repo": repo.uri, "pull": pull])
try await Client.shared.send(write)
changed()
// The change usually shows sooner, and ends this: see onChange.
try? await Task.sleep(for: .seconds(3))
ios/ModelCheck/main.swift+57−0
@@ -40,3 +40,60 @@ for (url, answer) in answers.sorted(by: { $0.key < $1.key }) {
}
print("\(read) answers read, \(failed) not")
if failed > 0 || read == 0 { exit(1) }
// What the merge section says of each state: nothing from the command line, and
// the queue offered only to a pull it would take.
func status(_ state: String, checks: String = "none", reviews: String? = nil, queueOnly: Bool = false) -> MergeStatus {
MergeStatus(state: state, checks: checks, queueOnly: queueOnly, reviews: reviews, may: .init(merge: true, queue: true))
}
let saying: [(MergeStatus, String, Bool)] = [
(status("clean", checks: "passed"), "Ready to merge", true),
(status("clean", checks: "passed", queueOnly: true), "Ready for the merge queue", true),
(status("conflicting"), "Conflicts with main", false),
(status("draft"), "Still a draft", false),
(status("blocked", checks: "failed"), "Checks failed", false),
(status("blocked", checks: "running"), "Checks are running", true),
(status("blocked", checks: "pending"), "Not checked yet", true),
(status("blocked", checks: "passed", reviews: "blocked"), "Review required", false),
]
var wrong = 0
func expect(_ held: Bool, _ what: String) {
if !held {
print(what)
wrong += 1
}
}
for (s, title, queues) in saying {
let said = Standing.of(s, base: "main")
expect(said.title == title, "\(s.state)/\(s.checks ?? ""): says \(said.title), not \(title)")
expect(Standing.canQueue(s) == queues, "\(title): the queue \(queues ? "isn't" : "is") offered")
}
let ejected = Standing.ejected(reason: "its batch failed its checks at 6f89484c63a3, on main at 226488be4afa: test (failure). Their logs: `knotless run list knotless://a/b --pull at://c --log`")
expect(ejected.lines == ["Its batch failed its checks: test (failure)"], "ejected says \(ejected.lines)")
for said in saying.map({ Standing.of($0.0, base: "main") }) + [ejected] {
for line in said.lines where line.contains("knotless ") || line.contains("at://") || line.contains("--") {
expect(false, "reads like the command line: \(line)")
}
}
if wrong > 0 { exit(1) }
// Every write the app makes, for check-writes.mjs to hold to its lexicon.
let repo = RepoRef(did: "did:plc:bcgltzqazw5tb6k2g3ttenbj", handle: "alice.test", name: "tally")
let pull = "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzlqso3vpyqg"
let writes: [Write] = [
Writes.comment(repo: repo, subject: pull, body: "Looks right", verdict: nil),
Writes.comment(repo: repo, subject: pull, body: "", verdict: "approve"),
Writes.comment(repo: repo, subject: pull, body: "Not yet", verdict: "changes"),
Writes.react(repo: repo, subject: pull, content: "+1", undo: false),
Writes.react(repo: repo, subject: pull, content: "+1", undo: true),
Writes.setState(repo: repo, subject: pull, state: "closed"),
Writes.setState(repo: repo, subject: pull, state: "open"),
Writes.merge(repo: repo, pull: pull),
Writes.queue(repo: repo, pull: pull),
Writes.leaveQueue(repo: repo, pull: pull),
Writes.star(repo: repo, undo: false),
Writes.star(repo: repo, undo: true),
Writes.seen(at: "2026-11-05T12:00:00.000Z"),
]
let made = writes.map { ["method": $0.method, "input": $0.input] as [String: Any] }
try JSONSerialization.data(withJSONObject: made).write(to: URL(fileURLWithPath: CommandLine.arguments[2]))
ios/check-models.sh+6−4
ios/check-writes.mjsnew+30−0
ios/README.md+1−1
AGENTS.md+2−1