@plinth-music/cli 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/README.md +2 -2
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +550 -135
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,129 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
|
|
4
4
|
|
|
5
|
+
## 0.9.1 — 2026-08-25
|
|
6
|
+
|
|
7
|
+
Seven PRs since `0.9.0` (`v0.9.0..1efa7d7`, #98–#104). Every one of them is the
|
|
8
|
+
same bug seen from a different side: the daemon treated a failed compare-and-swap
|
|
9
|
+
as proof that the cloud content had changed, and then let the cloud win. It had
|
|
10
|
+
not changed. Fixes only, all client-side; there is no server prerequisite and a
|
|
11
|
+
0.9.0 client talks to the same API. Upgrading is strongly recommended — the
|
|
12
|
+
defects below lose user work silently, and `plinth status` said "All local writes
|
|
13
|
+
synced" the whole time.
|
|
14
|
+
|
|
15
|
+
### ⚠️ A cloud write that touched only a timestamp no longer costs you your edit
|
|
16
|
+
|
|
17
|
+
This is the one to read. The CAS token is `updated_at`, so **any** write that moves
|
|
18
|
+
that column fails the precondition — including a server-side backfill that changes
|
|
19
|
+
no bytes at all. On 24 Aug a backfill moved `updated_at` on 161 of 307 live rows to
|
|
20
|
+
the same microsecond; every later local edit to one of those rows was stashed out
|
|
21
|
+
of sight into `~/.plinth/_conflicts/` and overwritten with a cloud copy that was
|
|
22
|
+
byte-identical to what the daemon had already acknowledged.
|
|
23
|
+
|
|
24
|
+
A 409 is now a question rather than a verdict: the daemon asks the row what its
|
|
25
|
+
content hash is, compares it with the hash it last acked, and re-pushes when they
|
|
26
|
+
match. Nothing is stashed and nothing is overwritten, because there was no conflict
|
|
27
|
+
to reconcile. A genuine cloud edit still conflicts, still stashes, and still wins —
|
|
28
|
+
that half is unchanged and is pinned by its own tests.
|
|
29
|
+
|
|
30
|
+
Fixed on every path, not just the one that bit: `files/` rows, entity rows, tasks,
|
|
31
|
+
and the three singleton lanes (`user-memory.md`, `workspace-rules.md`,
|
|
32
|
+
`workspace-memory.md`), which shared an engine that had been missed.
|
|
33
|
+
|
|
34
|
+
### ⚠️ A local status, priority or due-date edit is no longer silently discarded
|
|
35
|
+
|
|
36
|
+
Found while fixing the above. On an entity conflict the retry dropped the
|
|
37
|
+
structured fields the first attempt had carried, as "cloud's win" — so a metadata
|
|
38
|
+
bump on a row you had just re-prioritised threw the re-prioritisation away with no
|
|
39
|
+
stash and no record. A bump is not a conflict, so it no longer gets a conflict's
|
|
40
|
+
resolution.
|
|
41
|
+
|
|
42
|
+
### ⚠️ `plinth login` no longer wipes your sync cursors, and `--reset-sync` is new
|
|
43
|
+
|
|
44
|
+
Logging in again to the same workspace is a token rotation, not a re-registration,
|
|
45
|
+
and it now keeps your cursors — the next sync resumes instead of re-pulling the
|
|
46
|
+
world. Cursors are still cleared when the token belongs to a different workspace or
|
|
47
|
+
user, and login now says which happened rather than leaving you to guess.
|
|
48
|
+
|
|
49
|
+
That removed the only way to force a full re-pull, so `--reset-sync` restores it as
|
|
50
|
+
something you ask for. Use it after rebuilding a mirror from scratch; without it, a
|
|
51
|
+
mirror rebuilt from an empty ledger would never refill, silently, behind a green
|
|
52
|
+
sync.
|
|
53
|
+
|
|
54
|
+
### Two files saved at the same time no longer lose one file's bookkeeping
|
|
55
|
+
|
|
56
|
+
Every write of `state.json` used to land on a snapshot taken before the network
|
|
57
|
+
call that preceded it, so two saves a moment apart — or a scheduled pull arriving
|
|
58
|
+
while a push was in flight — could leave the file missing one of the two
|
|
59
|
+
acknowledgements. The losing write's record simply vanished, and the next edit to
|
|
60
|
+
that file hit the defect above through a door the fix could not close.
|
|
61
|
+
|
|
62
|
+
State is now merged at write time rather than overwritten, an acknowledgement can
|
|
63
|
+
never be committed over a newer one, and a cursor is never advanced ahead of the
|
|
64
|
+
rows it describes — so an interrupted pull leaves cursors, state and disk agreeing
|
|
65
|
+
rather than stranding rows that would never be delivered again. HTTP concurrency is
|
|
66
|
+
untouched; the cost is about 2 ms per state write.
|
|
67
|
+
|
|
68
|
+
### `plinth status` shows conflict stashes
|
|
69
|
+
|
|
70
|
+
The Conflicts block lists how many local copies are set aside in
|
|
71
|
+
`~/.plinth/_conflicts/<workspace>/`, names the newest, and says what to do with
|
|
72
|
+
them. They had been accumulating unseen since May.
|
|
73
|
+
|
|
74
|
+
### Proven against production
|
|
75
|
+
|
|
76
|
+
The changes above were reproduced end to end against a live workspace before this
|
|
77
|
+
release rather than only in tests: a metadata-only bump followed by a local edit
|
|
78
|
+
survives on both the file and entity paths, two overlapping pushes both keep their
|
|
79
|
+
acknowledgement, and — the part that makes the rest mean anything — a genuine cloud
|
|
80
|
+
content change still conflicts and still stashes under otherwise identical
|
|
81
|
+
conditions. The pull-ordering half and the three singleton lanes remain
|
|
82
|
+
test-proven only; both are named in the repo's `CLAUDE.md`.
|
|
83
|
+
|
|
84
|
+
## 0.9.0 — 2026-08-24
|
|
85
|
+
|
|
86
|
+
Two PRs since `0.8.0` (`v0.8.0..4842d40`). An agent is now told to read a draft
|
|
87
|
+
back from the tool that staged it before calling it ready. No server prerequisite:
|
|
88
|
+
the change is client-side, and a 0.8.0 client is unaffected.
|
|
89
|
+
|
|
90
|
+
### An agent reads its own draft back before saying it is ready
|
|
91
|
+
|
|
92
|
+
Primitive (e)'s outbound group gains a paragraph. The voice guidance added on
|
|
93
|
+
20 Aug already told the agent that nothing inspects a draft between it and the
|
|
94
|
+
reader, then aimed that observation only at the WORDS. This is the other half:
|
|
95
|
+
inspect the artefact. Fetch the staged draft, look at its body, its recipients
|
|
96
|
+
and the thread it landed on, and only then say it is ready. A tool's own
|
|
97
|
+
description of how it handles your text is a claim like any other, and the first
|
|
98
|
+
use in a session is checked against what actually came out.
|
|
99
|
+
|
|
100
|
+
**The gap was the trigger, not the rule.** The workspace `CLAUDE.md` already
|
|
101
|
+
carried "where a document and the running system disagree, the system wins", and
|
|
102
|
+
it did not fire — a line describing tool usage does not read like a claim needing
|
|
103
|
+
verification. So this ships as a concrete read-back step rather than as another
|
|
104
|
+
principle the agent grades itself against.
|
|
105
|
+
|
|
106
|
+
Found by ordinary management work rather than by a sweep. An agent staged a mail
|
|
107
|
+
reply whose body was visible `<p>` and `<b>` markup and reported it staged; the
|
|
108
|
+
user found it in his own mailbox. The cause was not the tool but prose describing
|
|
109
|
+
it — a rule said a local helper "handles `text/html`", true of what that script
|
|
110
|
+
emits and false of what it takes. Two more defects of the same shape surfaced
|
|
111
|
+
while repairing it: an update path that silently strips `In-Reply-To` and
|
|
112
|
+
`References`, and a re-run that duplicates a draft rather than replacing it.
|
|
113
|
+
|
|
114
|
+
The prose deliberately names no email tool, and a test pins that: Plinth ships
|
|
115
|
+
none and cannot know what the user has. Grounding budget 9,050 → 9,944 bytes,
|
|
116
|
+
all of it in (e); primitive (j) held at 1,266 for the seventh time.
|
|
117
|
+
|
|
118
|
+
Honest limit, recorded rather than argued away: this is prose, and prose gets
|
|
119
|
+
skipped. The durable fix is a Plinth-owned outbound draft path that owns escaping
|
|
120
|
+
and threading in code for every user, at which point this paragraph becomes a
|
|
121
|
+
backstop rather than the surface.
|
|
122
|
+
|
|
123
|
+
### Housekeeping
|
|
124
|
+
|
|
125
|
+
The test baseline in `CLAUDE.md` now reads 2052 pass / 0 fail / 107 files at
|
|
126
|
+
`b4d8ed3`, re-measured on a clean checkout rather than carried forward.
|
|
127
|
+
|
|
5
128
|
## 0.8.0 — 2026-08-23
|
|
6
129
|
|
|
7
130
|
Two PRs and two direct commits since `0.7.0` (`v0.7.0..af83c59`). The daemon
|
package/README.md
CHANGED
|
@@ -22,10 +22,10 @@ plinth start # run the daemon: continuous pull + watch + pu
|
|
|
22
22
|
|
|
23
23
|
## Commands
|
|
24
24
|
|
|
25
|
-
- `plinth login --workspace <slug
|
|
25
|
+
- `plinth login --workspace <slug> [--reset-sync]` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager). Logging in again to the same workspace is a token rotation and **keeps your sync cursors**, so the next sync picks up where the last one left off; it clears them only when the token belongs to a different workspace or user. `--reset-sync` discards them deliberately, making the next sync a full re-pull — use it after rebuilding a mirror from scratch, which is otherwise unreachable now that rotation preserves them.
|
|
26
26
|
- `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted.
|
|
27
27
|
- `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`.
|
|
28
|
-
- `plinth status` — local sync state: daemon liveness, last sync and per-type counts,
|
|
28
|
+
- `plinth status` — local sync state: daemon liveness, last sync and per-type counts, any destructive batches being held, writes awaiting retry or permanently rejected, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout.
|
|
29
29
|
- `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
|
|
30
30
|
- `plinth refresh-context [--workspace <slug>]` — force-regenerate the workspace-root `CLAUDE.md` **and the two generated commands** from the current Plinth schema + workspace. The user-customisable block between the `<!-- BEGIN: user-customisable -->` / `<!-- END -->` markers is always preserved verbatim in every generated file; `CLAUDE.local.md` is never touched.
|
|
31
31
|
- `plinth grounding` — print the session-start grounding block (current date, entity resolution, write confirmation, workspace rules, workspace memory, user memory). Invoked by the generated Claude Code SessionStart hook.
|
package/dist/build-stamp.json
CHANGED
package/dist/cli.js
CHANGED
|
@@ -9193,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
|
|
|
9193
9193
|
// package.json
|
|
9194
9194
|
var package_default = {
|
|
9195
9195
|
name: "@plinth-music/cli",
|
|
9196
|
-
version: "0.
|
|
9196
|
+
version: "0.9.1",
|
|
9197
9197
|
description: "Plinth capstone sync daemon and CLI",
|
|
9198
9198
|
type: "module",
|
|
9199
9199
|
private: false,
|
|
@@ -26007,12 +26007,28 @@ function writeAtomic(config2) {
|
|
|
26007
26007
|
throw err;
|
|
26008
26008
|
}
|
|
26009
26009
|
}
|
|
26010
|
-
function
|
|
26010
|
+
function sameWorkspaceIdentity(a2, b2) {
|
|
26011
|
+
return a2.workspace_id === b2.workspace_id && a2.user_id === b2.user_id && a2.api_base_url === b2.api_base_url;
|
|
26012
|
+
}
|
|
26013
|
+
function carryForwardSyncState(prior, entry) {
|
|
26014
|
+
if (!prior || !sameWorkspaceIdentity(prior, entry))
|
|
26015
|
+
return entry;
|
|
26016
|
+
return {
|
|
26017
|
+
...entry,
|
|
26018
|
+
last_synced_at: prior.last_synced_at,
|
|
26019
|
+
...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {}
|
|
26020
|
+
};
|
|
26021
|
+
}
|
|
26022
|
+
function upsertWorkspace(slug, entry, options2 = {}) {
|
|
26011
26023
|
const existing = loadConfig() ?? emptyConfig(slug);
|
|
26024
|
+
const prior = options2.resetSyncState === true ? undefined : existing.workspaces[slug];
|
|
26012
26025
|
const next = {
|
|
26013
26026
|
...existing,
|
|
26014
26027
|
active_workspace: slug,
|
|
26015
|
-
workspaces: {
|
|
26028
|
+
workspaces: {
|
|
26029
|
+
...existing.workspaces,
|
|
26030
|
+
[slug]: carryForwardSyncState(prior, entry)
|
|
26031
|
+
}
|
|
26016
26032
|
};
|
|
26017
26033
|
writeAtomic(next);
|
|
26018
26034
|
return next;
|
|
@@ -26916,7 +26932,9 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
|
|
|
26916
26932
|
``,
|
|
26917
26933
|
`Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
|
|
26918
26934
|
``,
|
|
26919
|
-
`Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and the Voice section of your own \`user-memory.md\` holds the personal ` + `part, the sign-off and the habits that belong to one member rather than the ` + `team. Read them before you write and follow them as written, rather than ` + `drafting in your own register and correcting after. Nothing inspects a draft ` + `between you and the person reading it, so the rules in those files are the ` + `whole of it
|
|
26935
|
+
`Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and the Voice section of your own \`user-memory.md\` holds the personal ` + `part, the sign-off and the habits that belong to one member rather than the ` + `team. Read them before you write and follow them as written, rather than ` + `drafting in your own register and correcting after. Nothing inspects a draft ` + `between you and the person reading it, so the rules in those files are the ` + `whole of it.`,
|
|
26936
|
+
``,
|
|
26937
|
+
`Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`
|
|
26920
26938
|
];
|
|
26921
26939
|
if (workspaceRules !== null) {
|
|
26922
26940
|
block.push(``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:
|
|
@@ -27100,6 +27118,140 @@ function writeState(state) {
|
|
|
27100
27118
|
throw err;
|
|
27101
27119
|
}
|
|
27102
27120
|
}
|
|
27121
|
+
function isLaterVersion(a2, b2) {
|
|
27122
|
+
if (!a2 || !b2)
|
|
27123
|
+
return false;
|
|
27124
|
+
const ta = Date.parse(a2.updated_at);
|
|
27125
|
+
const tb = Date.parse(b2.updated_at);
|
|
27126
|
+
if (Number.isNaN(ta) || Number.isNaN(tb))
|
|
27127
|
+
return false;
|
|
27128
|
+
return ta > tb;
|
|
27129
|
+
}
|
|
27130
|
+
function mergeKey(fresh, before, after) {
|
|
27131
|
+
if (before === after)
|
|
27132
|
+
return { value: fresh, overruled: false };
|
|
27133
|
+
if (after === undefined) {
|
|
27134
|
+
if (isLaterVersion(fresh, before))
|
|
27135
|
+
return { value: fresh, overruled: true };
|
|
27136
|
+
return { value: undefined, overruled: false };
|
|
27137
|
+
}
|
|
27138
|
+
if (isLaterVersion(fresh, after))
|
|
27139
|
+
return { value: fresh, overruled: true };
|
|
27140
|
+
return { value: after, overruled: false };
|
|
27141
|
+
}
|
|
27142
|
+
function mergeKeyed(fresh, before, after) {
|
|
27143
|
+
const merged = { ...fresh };
|
|
27144
|
+
const overruled = [];
|
|
27145
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
27146
|
+
for (const key of keys) {
|
|
27147
|
+
const decision = mergeKey(fresh[key], before[key], after[key]);
|
|
27148
|
+
if (decision.overruled)
|
|
27149
|
+
overruled.push(key);
|
|
27150
|
+
if (decision.value === undefined)
|
|
27151
|
+
delete merged[key];
|
|
27152
|
+
else
|
|
27153
|
+
merged[key] = decision.value;
|
|
27154
|
+
}
|
|
27155
|
+
return { merged, overruled };
|
|
27156
|
+
}
|
|
27157
|
+
function recordKey(r3) {
|
|
27158
|
+
return `${r3.target} ${r3.path}`;
|
|
27159
|
+
}
|
|
27160
|
+
function mergeRecords(fresh, before, after) {
|
|
27161
|
+
const index = (list) => new Map(list.map((r3) => [recordKey(r3), r3]));
|
|
27162
|
+
const beforeIdx = index(before);
|
|
27163
|
+
const afterIdx = index(after);
|
|
27164
|
+
const merged = index(fresh);
|
|
27165
|
+
for (const key of new Set([...beforeIdx.keys(), ...afterIdx.keys()])) {
|
|
27166
|
+
const b2 = beforeIdx.get(key);
|
|
27167
|
+
const a2 = afterIdx.get(key);
|
|
27168
|
+
if (b2 === a2)
|
|
27169
|
+
continue;
|
|
27170
|
+
if (a2 === undefined)
|
|
27171
|
+
merged.delete(key);
|
|
27172
|
+
else
|
|
27173
|
+
merged.set(key, a2);
|
|
27174
|
+
}
|
|
27175
|
+
return Array.from(merged.values());
|
|
27176
|
+
}
|
|
27177
|
+
function mergedList(fresh, before, after) {
|
|
27178
|
+
const merged = mergeRecords(fresh ?? [], before ?? [], after ?? []);
|
|
27179
|
+
if (merged.length > 0)
|
|
27180
|
+
return merged;
|
|
27181
|
+
return fresh === undefined && after === undefined ? undefined : merged;
|
|
27182
|
+
}
|
|
27183
|
+
function mergeScalar(fresh, before, after) {
|
|
27184
|
+
return before === after ? fresh : after;
|
|
27185
|
+
}
|
|
27186
|
+
function mergeVersioned(fresh, before, after) {
|
|
27187
|
+
if (before === after)
|
|
27188
|
+
return { value: fresh, overruled: false };
|
|
27189
|
+
if (fresh?.updated_at && after?.updated_at) {
|
|
27190
|
+
const tf = Date.parse(fresh.updated_at);
|
|
27191
|
+
const ta = Date.parse(after.updated_at);
|
|
27192
|
+
if (!Number.isNaN(tf) && !Number.isNaN(ta) && tf > ta) {
|
|
27193
|
+
return { value: fresh, overruled: true };
|
|
27194
|
+
}
|
|
27195
|
+
}
|
|
27196
|
+
return { value: after, overruled: false };
|
|
27197
|
+
}
|
|
27198
|
+
function commitState(before, after) {
|
|
27199
|
+
const fresh = loadState(after.workspace_slug);
|
|
27200
|
+
const entityTypes = new Set([
|
|
27201
|
+
...Object.keys(fresh.entries),
|
|
27202
|
+
...Object.keys(before.entries),
|
|
27203
|
+
...Object.keys(after.entries)
|
|
27204
|
+
]);
|
|
27205
|
+
const entries = {};
|
|
27206
|
+
const overruledEntries = [];
|
|
27207
|
+
for (const entityType of entityTypes) {
|
|
27208
|
+
const { merged: merged2, overruled } = mergeKeyed(fresh.entries[entityType] ?? {}, before.entries[entityType] ?? {}, after.entries[entityType] ?? {});
|
|
27209
|
+
if (Object.keys(merged2).length > 0 || fresh.entries[entityType] !== undefined) {
|
|
27210
|
+
entries[entityType] = merged2;
|
|
27211
|
+
}
|
|
27212
|
+
for (const id of overruled)
|
|
27213
|
+
overruledEntries.push({ entityType, id });
|
|
27214
|
+
}
|
|
27215
|
+
const files = mergeKeyed(fresh.files ?? {}, before.files ?? {}, after.files ?? {});
|
|
27216
|
+
const mergedFiles = Object.keys(files.merged).length > 0 || fresh.files !== undefined || after.files !== undefined ? files.merged : undefined;
|
|
27217
|
+
const rules = mergeVersioned(fresh.workspace_rules, before.workspace_rules, after.workspace_rules);
|
|
27218
|
+
const memory = mergeVersioned(fresh.workspace_memory, before.workspace_memory, after.workspace_memory);
|
|
27219
|
+
const userMemory = mergeVersioned(fresh.user_memory, before.user_memory, after.user_memory);
|
|
27220
|
+
const merged = {
|
|
27221
|
+
...fresh,
|
|
27222
|
+
version: 2,
|
|
27223
|
+
workspace_slug: after.workspace_slug,
|
|
27224
|
+
entries,
|
|
27225
|
+
files: mergedFiles,
|
|
27226
|
+
unsynced: mergedList(fresh.unsynced, before.unsynced, after.unsynced),
|
|
27227
|
+
rejected: mergedList(fresh.rejected, before.rejected, after.rejected),
|
|
27228
|
+
workspace_rules: rules.value,
|
|
27229
|
+
workspace_memory: memory.value,
|
|
27230
|
+
user_memory: userMemory.value,
|
|
27231
|
+
derived_text_version: mergeScalar(fresh.derived_text_version, before.derived_text_version, after.derived_text_version)
|
|
27232
|
+
};
|
|
27233
|
+
writeState(merged);
|
|
27234
|
+
const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled;
|
|
27235
|
+
if (overruledEntries.length === 0 && files.overruled.length === 0 && !singletonOverruled) {
|
|
27236
|
+
return after;
|
|
27237
|
+
}
|
|
27238
|
+
let corrected = after;
|
|
27239
|
+
if (rules.overruled)
|
|
27240
|
+
corrected = { ...corrected, workspace_rules: rules.value };
|
|
27241
|
+
if (memory.overruled)
|
|
27242
|
+
corrected = { ...corrected, workspace_memory: memory.value };
|
|
27243
|
+
if (userMemory.overruled)
|
|
27244
|
+
corrected = { ...corrected, user_memory: userMemory.value };
|
|
27245
|
+
for (const { entityType, id } of overruledEntries) {
|
|
27246
|
+
const committed = merged.entries[entityType]?.[id];
|
|
27247
|
+
corrected = committed ? upsertEntry(corrected, entityType, committed).state : removeEntry(corrected, entityType, id).state;
|
|
27248
|
+
}
|
|
27249
|
+
for (const path3 of files.overruled) {
|
|
27250
|
+
const committed = merged.files?.[path3];
|
|
27251
|
+
corrected = committed ? upsertFileEntry(corrected, committed).state : removeFileEntry(corrected, path3).state;
|
|
27252
|
+
}
|
|
27253
|
+
return corrected;
|
|
27254
|
+
}
|
|
27103
27255
|
function upsertEntry(state, entityType, entry) {
|
|
27104
27256
|
const bucket = state.entries[entityType] ?? {};
|
|
27105
27257
|
const existing = bucket[entry.id];
|
|
@@ -27796,12 +27948,22 @@ var macKeychain = {
|
|
|
27796
27948
|
|
|
27797
27949
|
// src/commands/login.ts
|
|
27798
27950
|
var DEFAULT_API_BASE_URL = "https://app.plinthmusic.com";
|
|
27951
|
+
function identityDifference(prior, entry2) {
|
|
27952
|
+
if (prior.workspace_id !== entry2.workspace_id)
|
|
27953
|
+
return "a different workspace";
|
|
27954
|
+
if (prior.user_id !== entry2.user_id)
|
|
27955
|
+
return "a different user";
|
|
27956
|
+
return "a different server";
|
|
27957
|
+
}
|
|
27958
|
+
function storedCursorCount(entry2) {
|
|
27959
|
+
return Object.keys(entry2.sync_cursors ?? {}).length;
|
|
27960
|
+
}
|
|
27799
27961
|
function defaultOpenBrowser(url2) {
|
|
27800
27962
|
const child = spawn("open", [url2], { stdio: "ignore", detached: true });
|
|
27801
27963
|
child.on("error", () => {});
|
|
27802
27964
|
child.unref();
|
|
27803
27965
|
}
|
|
27804
|
-
async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
|
|
27966
|
+
async function runLogin(workspaceSlug, apiBaseUrl, deps = {}, options2 = {}) {
|
|
27805
27967
|
const out = deps.stdout ?? process.stdout;
|
|
27806
27968
|
const err = deps.stderr ?? process.stderr;
|
|
27807
27969
|
const keychain = deps.keychain ?? macKeychain;
|
|
@@ -27850,13 +28012,22 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
|
|
|
27850
28012
|
`);
|
|
27851
28013
|
return 1;
|
|
27852
28014
|
}
|
|
28015
|
+
const entry2 = {
|
|
28016
|
+
workspace_id: principal.workspace_id,
|
|
28017
|
+
user_id: principal.user_id,
|
|
28018
|
+
api_base_url: base2,
|
|
28019
|
+
last_synced_at: null,
|
|
28020
|
+
workspace_name: principal.workspace_name
|
|
28021
|
+
};
|
|
28022
|
+
let prior;
|
|
27853
28023
|
try {
|
|
27854
|
-
|
|
27855
|
-
|
|
27856
|
-
|
|
27857
|
-
|
|
27858
|
-
|
|
27859
|
-
|
|
28024
|
+
prior = loadConfig()?.workspaces[workspaceSlug];
|
|
28025
|
+
} catch {
|
|
28026
|
+
prior = undefined;
|
|
28027
|
+
}
|
|
28028
|
+
try {
|
|
28029
|
+
upsertWorkspace(workspaceSlug, entry2, {
|
|
28030
|
+
resetSyncState: options2.resetSync === true
|
|
27860
28031
|
});
|
|
27861
28032
|
} catch (e2) {
|
|
27862
28033
|
keychain.delete(workspaceSlug, principal.user_id);
|
|
@@ -27865,7 +28036,15 @@ async function runLogin(workspaceSlug, apiBaseUrl, deps = {}) {
|
|
|
27865
28036
|
`);
|
|
27866
28037
|
return 1;
|
|
27867
28038
|
}
|
|
27868
|
-
|
|
28039
|
+
let note = "Run `plinth sync` to begin syncing.";
|
|
28040
|
+
if (prior !== undefined && storedCursorCount(prior) > 0) {
|
|
28041
|
+
if (options2.resetSync === true) {
|
|
28042
|
+
note = "Sync cursors cleared as asked - the next `plinth sync` is a full re-pull.";
|
|
28043
|
+
} else {
|
|
28044
|
+
note = sameWorkspaceIdentity(prior, entry2) ? "Sync cursors kept - the next sync picks up where the last one left off." : `The stored entry was for ${identityDifference(prior, entry2)}, so sync ` + "cursors were cleared - the next `plinth sync` is a full re-pull.";
|
|
28045
|
+
}
|
|
28046
|
+
}
|
|
28047
|
+
out.write(`Logged in to ${workspaceSlug} as ${principal.profile.email} ` + `(token ending …${patLastFour(pat)}). ${note}
|
|
27869
28048
|
`);
|
|
27870
28049
|
return 0;
|
|
27871
28050
|
}
|
|
@@ -27879,11 +28058,17 @@ var loginCommand = defineCommand({
|
|
|
27879
28058
|
type: "string",
|
|
27880
28059
|
description: "Workspace slug to log in to",
|
|
27881
28060
|
required: true
|
|
28061
|
+
},
|
|
28062
|
+
"reset-sync": {
|
|
28063
|
+
type: "boolean",
|
|
28064
|
+
description: "Discard stored sync cursors so the next sync re-pulls everything. " + "Use after rebuilding a mirror from scratch."
|
|
27882
28065
|
}
|
|
27883
28066
|
},
|
|
27884
28067
|
async run({ args }) {
|
|
27885
28068
|
const baseUrl = process.env.PLINTH_API_URL ?? DEFAULT_API_BASE_URL;
|
|
27886
|
-
const code = await runLogin(args.workspace, baseUrl
|
|
28069
|
+
const code = await runLogin(args.workspace, baseUrl, {}, {
|
|
28070
|
+
resetSync: args["reset-sync"] === true
|
|
28071
|
+
});
|
|
27887
28072
|
if (code !== 0)
|
|
27888
28073
|
process.exit(code);
|
|
27889
28074
|
}
|
|
@@ -30360,7 +30545,7 @@ var sessionCommand = defineCommand({
|
|
|
30360
30545
|
|
|
30361
30546
|
// src/commands/start.ts
|
|
30362
30547
|
var import_gray_matter10 = __toESM(require_gray_matter(), 1);
|
|
30363
|
-
import { readFileSync as readFileSync20, statSync as
|
|
30548
|
+
import { readFileSync as readFileSync20, statSync as statSync13 } from "node:fs";
|
|
30364
30549
|
|
|
30365
30550
|
// src/lib/daemon-log.ts
|
|
30366
30551
|
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
|
|
@@ -30607,7 +30792,7 @@ class DestructiveGate {
|
|
|
30607
30792
|
import { readFileSync as readFileSync15 } from "node:fs";
|
|
30608
30793
|
|
|
30609
30794
|
// src/lib/conflicts.ts
|
|
30610
|
-
import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync7 } from "node:fs";
|
|
30795
|
+
import { mkdirSync as mkdirSync9, readdirSync as readdirSync5, statSync as statSync6, writeFileSync as writeFileSync7 } from "node:fs";
|
|
30611
30796
|
import { dirname as dirname7, join as join10 } from "node:path";
|
|
30612
30797
|
function conflictsRoot(workspaceSlug) {
|
|
30613
30798
|
return join10(plinthHome(), "_conflicts", workspaceSlug);
|
|
@@ -30635,9 +30820,60 @@ function stashFileConflict(args) {
|
|
|
30635
30820
|
writeFileSync7(target, args.bytes, { mode: 384 });
|
|
30636
30821
|
return target;
|
|
30637
30822
|
}
|
|
30823
|
+
var STAMP_RE2 = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(\.md)?$/;
|
|
30824
|
+
function parseStamp(stamp) {
|
|
30825
|
+
const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
|
|
30826
|
+
const d2 = new Date(iso);
|
|
30827
|
+
return Number.isNaN(d2.getTime()) ? null : d2;
|
|
30828
|
+
}
|
|
30829
|
+
function labelFor(file2) {
|
|
30830
|
+
const m2 = STAMP_RE2.exec(file2);
|
|
30831
|
+
if (!m2)
|
|
30832
|
+
return file2;
|
|
30833
|
+
const head = file2.slice(0, m2.index);
|
|
30834
|
+
if (head.startsWith("files.")) {
|
|
30835
|
+
return `files/${head.slice("files.".length).replace(/__/g, "/")}`;
|
|
30836
|
+
}
|
|
30837
|
+
const dot = head.indexOf(".");
|
|
30838
|
+
if (dot === -1)
|
|
30839
|
+
return head;
|
|
30840
|
+
return `${head.slice(0, dot)}/${head.slice(dot + 1)}`;
|
|
30841
|
+
}
|
|
30842
|
+
function listConflictStashes(workspaceSlug) {
|
|
30843
|
+
const dir = conflictsRoot(workspaceSlug);
|
|
30844
|
+
let names;
|
|
30845
|
+
try {
|
|
30846
|
+
names = readdirSync5(dir);
|
|
30847
|
+
} catch {
|
|
30848
|
+
return [];
|
|
30849
|
+
}
|
|
30850
|
+
const out = [];
|
|
30851
|
+
for (const file2 of names) {
|
|
30852
|
+
const absPath = join10(dir, file2);
|
|
30853
|
+
let st;
|
|
30854
|
+
try {
|
|
30855
|
+
st = statSync6(absPath);
|
|
30856
|
+
} catch {
|
|
30857
|
+
continue;
|
|
30858
|
+
}
|
|
30859
|
+
if (!st.isFile())
|
|
30860
|
+
continue;
|
|
30861
|
+
if (file2.startsWith("."))
|
|
30862
|
+
continue;
|
|
30863
|
+
const m2 = STAMP_RE2.exec(file2);
|
|
30864
|
+
out.push({
|
|
30865
|
+
file: file2,
|
|
30866
|
+
absPath,
|
|
30867
|
+
label: labelFor(file2),
|
|
30868
|
+
stashedAt: m2 ? parseStamp(m2[1]) : null,
|
|
30869
|
+
mtimeMs: st.mtimeMs
|
|
30870
|
+
});
|
|
30871
|
+
}
|
|
30872
|
+
return out.sort((a2, b2) => (b2.stashedAt?.getTime() ?? b2.mtimeMs) - (a2.stashedAt?.getTime() ?? a2.mtimeMs));
|
|
30873
|
+
}
|
|
30638
30874
|
|
|
30639
30875
|
// src/lib/files/push.ts
|
|
30640
|
-
import { readFileSync as readFileSync12, statSync as
|
|
30876
|
+
import { readFileSync as readFileSync12, statSync as statSync7 } from "node:fs";
|
|
30641
30877
|
|
|
30642
30878
|
// src/lib/log-tokens.ts
|
|
30643
30879
|
function categorizeError(err) {
|
|
@@ -30727,9 +30963,9 @@ function isPermanentReject(err) {
|
|
|
30727
30963
|
return PERMANENT_REJECT_REASONS.has(categorizeError(err).reason);
|
|
30728
30964
|
}
|
|
30729
30965
|
function persist(state, next) {
|
|
30730
|
-
if (next.changed)
|
|
30731
|
-
|
|
30732
|
-
return next.state;
|
|
30966
|
+
if (!next.changed)
|
|
30967
|
+
return state;
|
|
30968
|
+
return commitState(state, next.state);
|
|
30733
30969
|
}
|
|
30734
30970
|
function recordEntityRejected(args) {
|
|
30735
30971
|
const cat = categorizeError(args.err);
|
|
@@ -30794,15 +31030,15 @@ function clearEntityRejected(state, workspaceSlug, absolutePath) {
|
|
|
30794
31030
|
path5 = absolutePath;
|
|
30795
31031
|
}
|
|
30796
31032
|
const next = removeRejected(state, "entity", path5);
|
|
30797
|
-
if (next.changed)
|
|
30798
|
-
|
|
30799
|
-
return next.state;
|
|
31033
|
+
if (!next.changed)
|
|
31034
|
+
return state;
|
|
31035
|
+
return commitState(state, next.state);
|
|
30800
31036
|
}
|
|
30801
31037
|
function clearFileRejected(state, relPath) {
|
|
30802
31038
|
const next = removeRejected(state, "file", relPath);
|
|
30803
|
-
if (next.changed)
|
|
30804
|
-
|
|
30805
|
-
return next.state;
|
|
31039
|
+
if (!next.changed)
|
|
31040
|
+
return state;
|
|
31041
|
+
return commitState(state, next.state);
|
|
30806
31042
|
}
|
|
30807
31043
|
|
|
30808
31044
|
// src/lib/files/api.ts
|
|
@@ -31137,26 +31373,26 @@ function inferMime(relPath) {
|
|
|
31137
31373
|
// src/lib/files/push.ts
|
|
31138
31374
|
function persistFiles(state, entry2) {
|
|
31139
31375
|
const { state: next, changed } = upsertFileEntry(state, entry2);
|
|
31140
|
-
if (changed)
|
|
31141
|
-
|
|
31142
|
-
return next;
|
|
31376
|
+
if (!changed)
|
|
31377
|
+
return state;
|
|
31378
|
+
return commitState(state, next);
|
|
31143
31379
|
}
|
|
31144
31380
|
function dropFiles(state, relPath) {
|
|
31145
31381
|
const { state: next, changed } = removeFileEntry(state, relPath);
|
|
31146
|
-
if (changed)
|
|
31147
|
-
|
|
31148
|
-
return next;
|
|
31382
|
+
if (!changed)
|
|
31383
|
+
return state;
|
|
31384
|
+
return commitState(state, next);
|
|
31149
31385
|
}
|
|
31150
31386
|
function safeInode(absolutePath) {
|
|
31151
31387
|
try {
|
|
31152
|
-
return
|
|
31388
|
+
return statSync7(absolutePath).ino;
|
|
31153
31389
|
} catch {
|
|
31154
31390
|
return;
|
|
31155
31391
|
}
|
|
31156
31392
|
}
|
|
31157
31393
|
async function restoreFileFromCloud(args) {
|
|
31158
31394
|
const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
|
|
31159
|
-
const got = await getFile({ baseUrl, pat, id, fetchImpl });
|
|
31395
|
+
const got = args.prefetched ?? await getFile({ baseUrl, pat, id, fetchImpl });
|
|
31160
31396
|
const abs = filesSubtreePath(workspaceSlug, relPath);
|
|
31161
31397
|
if (!got.ok) {
|
|
31162
31398
|
suppressor?.registerDelete(abs);
|
|
@@ -31202,7 +31438,7 @@ async function pushFilesUpsert(args) {
|
|
|
31202
31438
|
} else {
|
|
31203
31439
|
let st;
|
|
31204
31440
|
try {
|
|
31205
|
-
st =
|
|
31441
|
+
st = statSync7(absolutePath);
|
|
31206
31442
|
} catch (err) {
|
|
31207
31443
|
if (err?.code === "ENOENT") {
|
|
31208
31444
|
return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
|
|
@@ -31252,6 +31488,42 @@ async function pushFilesUpsert(args) {
|
|
|
31252
31488
|
return { outcome: { kind: "noop", relPath }, state };
|
|
31253
31489
|
}
|
|
31254
31490
|
const mimeType = inferMime(relPath);
|
|
31491
|
+
const acceptPush = (row, action) => {
|
|
31492
|
+
output?.log(filePushLine({ path: relPath, id: row.id, action }));
|
|
31493
|
+
return {
|
|
31494
|
+
outcome: { kind: action, relPath, id: row.id },
|
|
31495
|
+
state: persistFiles(state, {
|
|
31496
|
+
id: row.id,
|
|
31497
|
+
path: relPath,
|
|
31498
|
+
content_hash: hash2,
|
|
31499
|
+
size_bytes: row.size_bytes,
|
|
31500
|
+
mime_type: row.mime_type,
|
|
31501
|
+
updated_at: row.updated_at,
|
|
31502
|
+
inode
|
|
31503
|
+
})
|
|
31504
|
+
};
|
|
31505
|
+
};
|
|
31506
|
+
const rejectOverCap = (serverMax) => {
|
|
31507
|
+
const max = serverMax ?? MAX_WORKSPACE_FILE_BYTES;
|
|
31508
|
+
const source = serverMax === null ? "client" : "server";
|
|
31509
|
+
output?.log(fileOverCapLine({ path: relPath, size, max, source }));
|
|
31510
|
+
return {
|
|
31511
|
+
outcome: { kind: "over_cap", relPath, size, max, source },
|
|
31512
|
+
state: recordFileOverCap({
|
|
31513
|
+
state,
|
|
31514
|
+
relPath,
|
|
31515
|
+
size,
|
|
31516
|
+
max,
|
|
31517
|
+
source,
|
|
31518
|
+
tracked: existing !== undefined,
|
|
31519
|
+
now
|
|
31520
|
+
})
|
|
31521
|
+
};
|
|
31522
|
+
};
|
|
31523
|
+
const rejectHashMismatch = () => {
|
|
31524
|
+
output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
|
|
31525
|
+
return { outcome: { kind: "ignored", relPath, reason: "hash_mismatch" }, state };
|
|
31526
|
+
};
|
|
31255
31527
|
const res = await postFile({
|
|
31256
31528
|
baseUrl,
|
|
31257
31529
|
pat,
|
|
@@ -31262,43 +31534,38 @@ async function pushFilesUpsert(args) {
|
|
|
31262
31534
|
expectedUpdatedAt: existing?.updated_at ?? null,
|
|
31263
31535
|
fetchImpl
|
|
31264
31536
|
});
|
|
31265
|
-
if (res.ok)
|
|
31266
|
-
|
|
31267
|
-
|
|
31268
|
-
|
|
31269
|
-
|
|
31270
|
-
|
|
31271
|
-
|
|
31272
|
-
|
|
31273
|
-
|
|
31274
|
-
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31280
|
-
|
|
31281
|
-
|
|
31282
|
-
|
|
31283
|
-
|
|
31284
|
-
|
|
31285
|
-
|
|
31286
|
-
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
31291
|
-
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
}
|
|
31295
|
-
if (res.code === 400) {
|
|
31296
|
-
output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
|
|
31297
|
-
return { outcome: { kind: "ignored", relPath, reason: "hash_mismatch" }, state };
|
|
31537
|
+
if (res.ok)
|
|
31538
|
+
return acceptPush(res.row, existing ? "updated" : "created");
|
|
31539
|
+
if (res.code === 413)
|
|
31540
|
+
return rejectOverCap(res.maxBytes);
|
|
31541
|
+
if (res.code === 400)
|
|
31542
|
+
return rejectHashMismatch();
|
|
31543
|
+
let nextState = state;
|
|
31544
|
+
let cloudRow;
|
|
31545
|
+
if (existing) {
|
|
31546
|
+
cloudRow = await getFile({ baseUrl, pat, id: existing.id, fetchImpl });
|
|
31547
|
+
if (cloudRow.ok && cloudRow.row.download_url !== null && cloudRow.row.content_hash === existing.content_hash) {
|
|
31548
|
+
const retry = await postFile({
|
|
31549
|
+
baseUrl,
|
|
31550
|
+
pat,
|
|
31551
|
+
relPath,
|
|
31552
|
+
bytes,
|
|
31553
|
+
contentHash: hash2,
|
|
31554
|
+
mimeType,
|
|
31555
|
+
expectedUpdatedAt: cloudRow.row.updated_at,
|
|
31556
|
+
fetchImpl
|
|
31557
|
+
});
|
|
31558
|
+
if (retry.ok)
|
|
31559
|
+
return acceptPush(retry.row, "updated");
|
|
31560
|
+
if (retry.code === 413)
|
|
31561
|
+
return rejectOverCap(retry.maxBytes);
|
|
31562
|
+
if (retry.code === 400)
|
|
31563
|
+
return rejectHashMismatch();
|
|
31564
|
+
cloudRow = undefined;
|
|
31565
|
+
}
|
|
31298
31566
|
}
|
|
31299
31567
|
const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes });
|
|
31300
31568
|
output?.log(fileConflictLine({ path: relPath, stash: stashPath }));
|
|
31301
|
-
let nextState = state;
|
|
31302
31569
|
if (existing) {
|
|
31303
31570
|
nextState = await restoreFileFromCloud({
|
|
31304
31571
|
workspaceSlug,
|
|
@@ -31309,7 +31576,8 @@ async function pushFilesUpsert(args) {
|
|
|
31309
31576
|
state,
|
|
31310
31577
|
fetchImpl,
|
|
31311
31578
|
suppressor,
|
|
31312
|
-
output
|
|
31579
|
+
output,
|
|
31580
|
+
prefetched: cloudRow
|
|
31313
31581
|
});
|
|
31314
31582
|
}
|
|
31315
31583
|
return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
|
|
@@ -31374,7 +31642,7 @@ class PerIdMutex {
|
|
|
31374
31642
|
|
|
31375
31643
|
// src/lib/push.ts
|
|
31376
31644
|
var import_gray_matter9 = __toESM(require_gray_matter(), 1);
|
|
31377
|
-
import { readFileSync as readFileSync14, statSync as
|
|
31645
|
+
import { readFileSync as readFileSync14, statSync as statSync8 } from "node:fs";
|
|
31378
31646
|
import { basename as basename4, relative as relative4 } from "node:path";
|
|
31379
31647
|
|
|
31380
31648
|
// src/lib/pull-write.ts
|
|
@@ -31472,22 +31740,31 @@ function omitKeys(obj, keys) {
|
|
|
31472
31740
|
}
|
|
31473
31741
|
function safeInode2(absolutePath) {
|
|
31474
31742
|
try {
|
|
31475
|
-
return
|
|
31743
|
+
return statSync8(absolutePath).ino;
|
|
31476
31744
|
} catch {
|
|
31477
31745
|
return;
|
|
31478
31746
|
}
|
|
31479
31747
|
}
|
|
31480
31748
|
function persistEntry(state, entityType, entry2) {
|
|
31481
31749
|
const { state: nextState, changed } = upsertEntry(state, entityType, entry2);
|
|
31482
|
-
if (changed)
|
|
31483
|
-
|
|
31484
|
-
return nextState;
|
|
31750
|
+
if (!changed)
|
|
31751
|
+
return state;
|
|
31752
|
+
return commitState(state, nextState);
|
|
31485
31753
|
}
|
|
31486
31754
|
function dropEntry(state, entityType, id) {
|
|
31487
31755
|
const { state: nextState, changed } = removeEntry(state, entityType, id);
|
|
31488
|
-
if (changed)
|
|
31489
|
-
|
|
31490
|
-
return nextState;
|
|
31756
|
+
if (!changed)
|
|
31757
|
+
return state;
|
|
31758
|
+
return commitState(state, nextState);
|
|
31759
|
+
}
|
|
31760
|
+
function cloudMatchesAcked(strategy, cloud, acked) {
|
|
31761
|
+
const { bodyHash, frontmatterHash } = computeMirrorHashes(strategy, strategy.serialise(cloud));
|
|
31762
|
+
if (cloud.slug !== acked.slug)
|
|
31763
|
+
return false;
|
|
31764
|
+
if (strategy.preserveLocalBodyOnPull) {
|
|
31765
|
+
return frontmatterHash === acked.frontmatter_hash;
|
|
31766
|
+
}
|
|
31767
|
+
return bodyHash === acked.body_hash && frontmatterHash === acked.frontmatter_hash;
|
|
31491
31768
|
}
|
|
31492
31769
|
function stampStateFromMirror(args) {
|
|
31493
31770
|
const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
|
|
@@ -31936,7 +32213,7 @@ async function runPushUpsert(args) {
|
|
|
31936
32213
|
state: nextState2
|
|
31937
32214
|
};
|
|
31938
32215
|
}
|
|
31939
|
-
|
|
32216
|
+
let patchResult = await patchEntity({
|
|
31940
32217
|
strategy,
|
|
31941
32218
|
baseUrl: args.baseUrl,
|
|
31942
32219
|
pat: args.pat,
|
|
@@ -31947,6 +32224,34 @@ async function runPushUpsert(args) {
|
|
|
31947
32224
|
topLevel: wireTopLevel,
|
|
31948
32225
|
fetchImpl: args.fetchImpl
|
|
31949
32226
|
});
|
|
32227
|
+
if (!patchResult.ok && patchResult.code === 409 && patchResult.reason === "precondition_failed") {
|
|
32228
|
+
let cloud = null;
|
|
32229
|
+
try {
|
|
32230
|
+
cloud = await getEntity({
|
|
32231
|
+
strategy,
|
|
32232
|
+
baseUrl: args.baseUrl,
|
|
32233
|
+
pat: args.pat,
|
|
32234
|
+
id: liveEntry.id,
|
|
32235
|
+
fetchImpl: args.fetchImpl
|
|
32236
|
+
});
|
|
32237
|
+
} catch (err) {
|
|
32238
|
+
if (!(err instanceof ApiError) || err.status !== 404)
|
|
32239
|
+
throw err;
|
|
32240
|
+
}
|
|
32241
|
+
if (cloud !== null && cloudMatchesAcked(strategy, cloud, liveEntry)) {
|
|
32242
|
+
patchResult = await patchEntity({
|
|
32243
|
+
strategy,
|
|
32244
|
+
baseUrl: args.baseUrl,
|
|
32245
|
+
pat: args.pat,
|
|
32246
|
+
id: liveEntry.id,
|
|
32247
|
+
expectedUpdatedAt: cloud.updated_at,
|
|
32248
|
+
body: wireBody,
|
|
32249
|
+
frontmatter: split ? wireFrontmatter : undefined,
|
|
32250
|
+
topLevel: wireTopLevel,
|
|
32251
|
+
fetchImpl: args.fetchImpl
|
|
32252
|
+
});
|
|
32253
|
+
}
|
|
32254
|
+
}
|
|
31950
32255
|
if (patchResult.ok) {
|
|
31951
32256
|
const updated = {
|
|
31952
32257
|
id: patchResult.doc.id,
|
|
@@ -32419,7 +32724,7 @@ async function pushRename(args) {
|
|
|
32419
32724
|
return mutex.run(id, async () => {
|
|
32420
32725
|
const live = getEntry(state, entityType, id);
|
|
32421
32726
|
const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
|
|
32422
|
-
|
|
32727
|
+
let patchResult = await patchEntity({
|
|
32423
32728
|
strategy,
|
|
32424
32729
|
baseUrl,
|
|
32425
32730
|
pat,
|
|
@@ -32428,6 +32733,26 @@ async function pushRename(args) {
|
|
|
32428
32733
|
slug: toSlug,
|
|
32429
32734
|
fetchImpl
|
|
32430
32735
|
});
|
|
32736
|
+
if (!patchResult.ok && patchResult.code === 409 && patchResult.reason === "precondition_failed" && live !== undefined) {
|
|
32737
|
+
let probe2 = null;
|
|
32738
|
+
try {
|
|
32739
|
+
probe2 = await getEntity({ strategy, baseUrl, pat, id, fetchImpl });
|
|
32740
|
+
} catch (err) {
|
|
32741
|
+
if (!(err instanceof ApiError) || err.status !== 404)
|
|
32742
|
+
throw err;
|
|
32743
|
+
}
|
|
32744
|
+
if (probe2 !== null && cloudMatchesAcked(strategy, probe2, live)) {
|
|
32745
|
+
patchResult = await patchEntity({
|
|
32746
|
+
strategy,
|
|
32747
|
+
baseUrl,
|
|
32748
|
+
pat,
|
|
32749
|
+
id,
|
|
32750
|
+
expectedUpdatedAt: probe2.updated_at,
|
|
32751
|
+
slug: toSlug,
|
|
32752
|
+
fetchImpl
|
|
32753
|
+
});
|
|
32754
|
+
}
|
|
32755
|
+
}
|
|
32431
32756
|
if (patchResult.ok) {
|
|
32432
32757
|
const doc2 = patchResult.doc;
|
|
32433
32758
|
const prior = live ?? null;
|
|
@@ -32862,7 +33187,7 @@ class FileDestructiveGate {
|
|
|
32862
33187
|
}
|
|
32863
33188
|
|
|
32864
33189
|
// src/lib/files/reconcile.ts
|
|
32865
|
-
import { readFileSync as readFileSync16, statSync as
|
|
33190
|
+
import { readFileSync as readFileSync16, statSync as statSync10 } from "node:fs";
|
|
32866
33191
|
|
|
32867
33192
|
// src/lib/unsynced.ts
|
|
32868
33193
|
import { join as join11, relative as relative6 } from "node:path";
|
|
@@ -32877,9 +33202,9 @@ function isRecordableFailure(err) {
|
|
|
32877
33202
|
}
|
|
32878
33203
|
function persistUpsert(state, candidate) {
|
|
32879
33204
|
const { state: next, changed } = upsertUnsynced(state, candidate);
|
|
32880
|
-
if (changed)
|
|
32881
|
-
|
|
32882
|
-
return next;
|
|
33205
|
+
if (!changed)
|
|
33206
|
+
return state;
|
|
33207
|
+
return commitState(state, next);
|
|
32883
33208
|
}
|
|
32884
33209
|
function recordEntityUnsynced(args) {
|
|
32885
33210
|
const cat = categorizeError(args.err);
|
|
@@ -32918,11 +33243,10 @@ async function drainUnsyncedOps(args) {
|
|
|
32918
33243
|
let stillFailing = 0;
|
|
32919
33244
|
let rejected = 0;
|
|
32920
33245
|
const clear = (target, path5) => {
|
|
32921
|
-
const
|
|
32922
|
-
|
|
32923
|
-
|
|
32924
|
-
setState(next);
|
|
32925
|
-
}
|
|
33246
|
+
const before = getState();
|
|
33247
|
+
const { state: next, changed } = removeUnsynced(before, target, path5);
|
|
33248
|
+
if (changed)
|
|
33249
|
+
setState(commitState(before, next));
|
|
32926
33250
|
};
|
|
32927
33251
|
for (const op of ops) {
|
|
32928
33252
|
attempted += 1;
|
|
@@ -32974,7 +33298,8 @@ async function drainUnsyncedOps(args) {
|
|
|
32974
33298
|
const cat = categorizeError(err);
|
|
32975
33299
|
if (isPermanentReject(err)) {
|
|
32976
33300
|
rejected += 1;
|
|
32977
|
-
const
|
|
33301
|
+
const beforeReject = getState();
|
|
33302
|
+
const removed = removeUnsynced(beforeReject, op.target, op.path).state;
|
|
32978
33303
|
const { state: next2 } = upsertRejected(removed, {
|
|
32979
33304
|
target: op.target,
|
|
32980
33305
|
op: op.op,
|
|
@@ -32985,22 +33310,20 @@ async function drainUnsyncedOps(args) {
|
|
|
32985
33310
|
message: cat.serverMessage,
|
|
32986
33311
|
rejected_at: now().toISOString()
|
|
32987
33312
|
});
|
|
32988
|
-
|
|
32989
|
-
setState(next2);
|
|
33313
|
+
setState(commitState(beforeReject, next2));
|
|
32990
33314
|
continue;
|
|
32991
33315
|
}
|
|
32992
33316
|
stillFailing += 1;
|
|
32993
|
-
const
|
|
33317
|
+
const beforeRetry = getState();
|
|
33318
|
+
const { state: next, changed } = upsertUnsynced(beforeRetry, {
|
|
32994
33319
|
target: op.target,
|
|
32995
33320
|
op: op.op,
|
|
32996
33321
|
path: op.path,
|
|
32997
33322
|
last_status: cat.status,
|
|
32998
33323
|
last_reason: cat.reason
|
|
32999
33324
|
});
|
|
33000
|
-
if (changed)
|
|
33001
|
-
|
|
33002
|
-
setState(next);
|
|
33003
|
-
}
|
|
33325
|
+
if (changed)
|
|
33326
|
+
setState(commitState(beforeRetry, next));
|
|
33004
33327
|
}
|
|
33005
33328
|
}
|
|
33006
33329
|
return { attempted, resolved, stillFailing, rejected };
|
|
@@ -34619,7 +34942,7 @@ function watch(paths, options2 = {}) {
|
|
|
34619
34942
|
var chokidar_default = { watch, FSWatcher };
|
|
34620
34943
|
|
|
34621
34944
|
// src/lib/watcher.ts
|
|
34622
|
-
import { statSync as
|
|
34945
|
+
import { statSync as statSync9 } from "node:fs";
|
|
34623
34946
|
import { sep as sep5 } from "node:path";
|
|
34624
34947
|
|
|
34625
34948
|
// src/lib/rename-classifier.ts
|
|
@@ -34835,7 +35158,7 @@ function startWatcher(opts, handler) {
|
|
|
34835
35158
|
pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
|
|
34836
35159
|
classifyPath: opts.classifyPath,
|
|
34837
35160
|
getStateEntry: opts.getStateEntry,
|
|
34838
|
-
statSync: opts.statSync ?? ((p) => ({ ino:
|
|
35161
|
+
statSync: opts.statSync ?? ((p) => ({ ino: statSync9(p).ino }))
|
|
34839
35162
|
});
|
|
34840
35163
|
const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
|
|
34841
35164
|
const watcher = factory(workspaceRoot, {
|
|
@@ -34870,7 +35193,7 @@ function readBlobForPush(args) {
|
|
|
34870
35193
|
const { absolutePath, relPath, output } = args;
|
|
34871
35194
|
let st;
|
|
34872
35195
|
try {
|
|
34873
|
-
st =
|
|
35196
|
+
st = statSync10(absolutePath);
|
|
34874
35197
|
} catch (err) {
|
|
34875
35198
|
if (err?.code === "ENOENT")
|
|
34876
35199
|
return { kind: "gone" };
|
|
@@ -35108,9 +35431,9 @@ function conflictSlug(slug) {
|
|
|
35108
35431
|
}
|
|
35109
35432
|
function persistEntry2(state, entry2) {
|
|
35110
35433
|
const { state: next, changed } = upsertEntry(state, ENTITY_TYPE, entry2);
|
|
35111
|
-
if (changed)
|
|
35112
|
-
|
|
35113
|
-
return next;
|
|
35434
|
+
if (!changed)
|
|
35435
|
+
return state;
|
|
35436
|
+
return commitState(state, next);
|
|
35114
35437
|
}
|
|
35115
35438
|
function memoryStub(name) {
|
|
35116
35439
|
return `# ${name} — Long-term memory
|
|
@@ -36572,9 +36895,9 @@ function writeLocal(workspaceSlug, body, suppressor) {
|
|
|
36572
36895
|
}
|
|
36573
36896
|
function persistRules(state, entry2) {
|
|
36574
36897
|
const { state: next, changed } = setWorkspaceRulesState(state, entry2);
|
|
36575
|
-
if (changed)
|
|
36576
|
-
|
|
36577
|
-
return next;
|
|
36898
|
+
if (!changed)
|
|
36899
|
+
return state;
|
|
36900
|
+
return commitState(state, next);
|
|
36578
36901
|
}
|
|
36579
36902
|
function classifyWorkspaceRulesPath(args) {
|
|
36580
36903
|
let root;
|
|
@@ -36690,6 +37013,30 @@ async function pushWorkspaceRules(args) {
|
|
|
36690
37013
|
output?.warn(`${WORKSPACE_RULES_FILENAME} is over the ${Math.round(MAX_WORKSPACE_RULES_BYTES / 1024)}KB cap — not synced.`);
|
|
36691
37014
|
return { state, outcome: { kind: "over_cap" } };
|
|
36692
37015
|
}
|
|
37016
|
+
{
|
|
37017
|
+
const probe2 = await getWorkspaceRules({ baseUrl, pat, fetchImpl });
|
|
37018
|
+
if (acked && stableHash(probe2.body_md) === acked.body_hash) {
|
|
37019
|
+
const retry = await putWorkspaceRules({
|
|
37020
|
+
baseUrl,
|
|
37021
|
+
pat,
|
|
37022
|
+
bodyMd: local,
|
|
37023
|
+
expectedUpdatedAt: probe2.updated_at,
|
|
37024
|
+
fetchImpl
|
|
37025
|
+
});
|
|
37026
|
+
if (retry.ok) {
|
|
37027
|
+
const next2 = persistRules(state, {
|
|
37028
|
+
body_hash: localHash,
|
|
37029
|
+
updated_at: retry.row.updated_at
|
|
37030
|
+
});
|
|
37031
|
+
output?.log(`[WORKSPACE_RULES_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
|
|
37032
|
+
return { state: next2, outcome: { kind: "pushed" } };
|
|
37033
|
+
}
|
|
37034
|
+
if (retry.code === 413) {
|
|
37035
|
+
output?.warn(`${WORKSPACE_RULES_FILENAME} is over the ${Math.round(MAX_WORKSPACE_RULES_BYTES / 1024)}KB cap — not synced.`);
|
|
37036
|
+
return { state, outcome: { kind: "over_cap" } };
|
|
37037
|
+
}
|
|
37038
|
+
}
|
|
37039
|
+
}
|
|
36693
37040
|
const stashPath = stashConflict({
|
|
36694
37041
|
workspaceSlug,
|
|
36695
37042
|
entityType: CONFLICT_ENTITY,
|
|
@@ -36832,9 +37179,9 @@ function writeLocal2(workspaceSlug, body, suppressor) {
|
|
|
36832
37179
|
}
|
|
36833
37180
|
function persistMemory(state, entry2) {
|
|
36834
37181
|
const { state: next, changed } = setWorkspaceMemoryState(state, entry2);
|
|
36835
|
-
if (changed)
|
|
36836
|
-
|
|
36837
|
-
return next;
|
|
37182
|
+
if (!changed)
|
|
37183
|
+
return state;
|
|
37184
|
+
return commitState(state, next);
|
|
36838
37185
|
}
|
|
36839
37186
|
function classifyWorkspaceMemoryPath(args) {
|
|
36840
37187
|
let root;
|
|
@@ -36932,6 +37279,30 @@ async function pushWorkspaceMemory(args) {
|
|
|
36932
37279
|
output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
|
|
36933
37280
|
return { state, outcome: { kind: "over_cap" } };
|
|
36934
37281
|
}
|
|
37282
|
+
{
|
|
37283
|
+
const probe2 = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
|
|
37284
|
+
if (acked && stableHash(probe2.body_md) === acked.body_hash) {
|
|
37285
|
+
const retry = await putWorkspaceMemory({
|
|
37286
|
+
baseUrl,
|
|
37287
|
+
pat,
|
|
37288
|
+
bodyMd: local,
|
|
37289
|
+
expectedUpdatedAt: probe2.updated_at,
|
|
37290
|
+
fetchImpl
|
|
37291
|
+
});
|
|
37292
|
+
if (retry.ok) {
|
|
37293
|
+
const next2 = persistMemory(state, {
|
|
37294
|
+
body_hash: localHash,
|
|
37295
|
+
updated_at: retry.row.updated_at
|
|
37296
|
+
});
|
|
37297
|
+
output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
|
|
37298
|
+
return { state: next2, outcome: { kind: "pushed" } };
|
|
37299
|
+
}
|
|
37300
|
+
if (retry.code === 413) {
|
|
37301
|
+
output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
|
|
37302
|
+
return { state, outcome: { kind: "over_cap" } };
|
|
37303
|
+
}
|
|
37304
|
+
}
|
|
37305
|
+
}
|
|
36935
37306
|
const stashPath = stashConflict({
|
|
36936
37307
|
workspaceSlug,
|
|
36937
37308
|
entityType: CONFLICT_ENTITY2,
|
|
@@ -37076,9 +37447,9 @@ function writeLocal3(descriptor, workspaceSlug, body, suppressor) {
|
|
|
37076
37447
|
}
|
|
37077
37448
|
function persist2(descriptor, state, entry2) {
|
|
37078
37449
|
const { state: next, changed } = descriptor.setState(state, entry2);
|
|
37079
|
-
if (changed)
|
|
37080
|
-
|
|
37081
|
-
return next;
|
|
37450
|
+
if (!changed)
|
|
37451
|
+
return state;
|
|
37452
|
+
return commitState(state, next);
|
|
37082
37453
|
}
|
|
37083
37454
|
function classifySingletonPath(descriptor, args) {
|
|
37084
37455
|
let root;
|
|
@@ -37176,6 +37547,30 @@ async function pushSingleton(descriptor, args) {
|
|
|
37176
37547
|
output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
|
|
37177
37548
|
return { state, outcome: { kind: "over_cap" } };
|
|
37178
37549
|
}
|
|
37550
|
+
{
|
|
37551
|
+
const probe2 = await descriptor.api.get({ baseUrl, pat, fetchImpl });
|
|
37552
|
+
if (acked && stableHash(probe2.body_md) === acked.body_hash) {
|
|
37553
|
+
const retry = await descriptor.api.put({
|
|
37554
|
+
baseUrl,
|
|
37555
|
+
pat,
|
|
37556
|
+
bodyMd: local,
|
|
37557
|
+
expectedUpdatedAt: probe2.updated_at,
|
|
37558
|
+
fetchImpl
|
|
37559
|
+
});
|
|
37560
|
+
if (retry.ok) {
|
|
37561
|
+
const next2 = persist2(descriptor, state, {
|
|
37562
|
+
body_hash: localHash,
|
|
37563
|
+
updated_at: retry.row.updated_at
|
|
37564
|
+
});
|
|
37565
|
+
output?.log(`[${descriptor.logTag}_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
|
|
37566
|
+
return { state: next2, outcome: { kind: "pushed" } };
|
|
37567
|
+
}
|
|
37568
|
+
if (retry.code === 413) {
|
|
37569
|
+
output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
|
|
37570
|
+
return { state, outcome: { kind: "over_cap" } };
|
|
37571
|
+
}
|
|
37572
|
+
}
|
|
37573
|
+
}
|
|
37179
37574
|
const stashPath = stashConflict({
|
|
37180
37575
|
workspaceSlug,
|
|
37181
37576
|
entityType: descriptor.conflictEntity,
|
|
@@ -37324,15 +37719,15 @@ function createThrottledFetch(baseFetch, opts = {}) {
|
|
|
37324
37719
|
// src/commands/sync.ts
|
|
37325
37720
|
import {
|
|
37326
37721
|
existsSync as existsSync6,
|
|
37327
|
-
readdirSync as
|
|
37722
|
+
readdirSync as readdirSync6,
|
|
37328
37723
|
readFileSync as readFileSync19,
|
|
37329
|
-
statSync as
|
|
37724
|
+
statSync as statSync12,
|
|
37330
37725
|
unlinkSync as unlinkSync7
|
|
37331
37726
|
} from "node:fs";
|
|
37332
37727
|
import { join as join14 } from "node:path";
|
|
37333
37728
|
|
|
37334
37729
|
// src/lib/files/pull.ts
|
|
37335
|
-
import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as
|
|
37730
|
+
import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync11 } from "node:fs";
|
|
37336
37731
|
|
|
37337
37732
|
// src/lib/files/extracted-text.ts
|
|
37338
37733
|
import { createHash as createHash3 } from "node:crypto";
|
|
@@ -37428,6 +37823,7 @@ async function pullFiles(args) {
|
|
|
37428
37823
|
let cursorBlocked = false;
|
|
37429
37824
|
while (true) {
|
|
37430
37825
|
const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
|
|
37826
|
+
const pageBase = state;
|
|
37431
37827
|
let pageTextFailed = false;
|
|
37432
37828
|
for (const row of page.rows) {
|
|
37433
37829
|
fetched++;
|
|
@@ -37458,6 +37854,8 @@ async function pullFiles(args) {
|
|
|
37458
37854
|
if (outcome.textFailed)
|
|
37459
37855
|
pageTextFailed = true;
|
|
37460
37856
|
}
|
|
37857
|
+
if (args.commit && state !== pageBase)
|
|
37858
|
+
state = args.commit(pageBase, state);
|
|
37461
37859
|
if (pageTextFailed)
|
|
37462
37860
|
cursorBlocked = true;
|
|
37463
37861
|
if (page.next_cursor === null)
|
|
@@ -37663,7 +38061,7 @@ async function applyFileRow(args) {
|
|
|
37663
38061
|
}
|
|
37664
38062
|
let inode;
|
|
37665
38063
|
try {
|
|
37666
|
-
inode =
|
|
38064
|
+
inode = statSync11(abs).ino;
|
|
37667
38065
|
} catch {
|
|
37668
38066
|
inode = undefined;
|
|
37669
38067
|
}
|
|
@@ -37745,11 +38143,11 @@ function hasExistingContent(workspacePath) {
|
|
|
37745
38143
|
if (!existsSync6(workspacePath))
|
|
37746
38144
|
return false;
|
|
37747
38145
|
try {
|
|
37748
|
-
return
|
|
38146
|
+
return readdirSync6(workspacePath, { withFileTypes: true }).some((e2) => {
|
|
37749
38147
|
if (!e2.isDirectory())
|
|
37750
38148
|
return true;
|
|
37751
38149
|
try {
|
|
37752
|
-
return
|
|
38150
|
+
return readdirSync6(join14(workspacePath, e2.name)).length > 0;
|
|
37753
38151
|
} catch {
|
|
37754
38152
|
return true;
|
|
37755
38153
|
}
|
|
@@ -37769,7 +38167,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
|
|
|
37769
38167
|
}
|
|
37770
38168
|
let liveInode;
|
|
37771
38169
|
try {
|
|
37772
|
-
liveInode =
|
|
38170
|
+
liveInode = statSync12(target).ino;
|
|
37773
38171
|
} catch {
|
|
37774
38172
|
return { didUnlink: false, skippedReason: "absent" };
|
|
37775
38173
|
}
|
|
@@ -37826,7 +38224,13 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37826
38224
|
out.info(`Syncing ${slug}…`);
|
|
37827
38225
|
const startedAt = now();
|
|
37828
38226
|
let state = loadState(slug);
|
|
37829
|
-
let
|
|
38227
|
+
let committed = state;
|
|
38228
|
+
const commitPending = () => {
|
|
38229
|
+
if (state === committed)
|
|
38230
|
+
return;
|
|
38231
|
+
state = commitState(committed, state);
|
|
38232
|
+
committed = state;
|
|
38233
|
+
};
|
|
37830
38234
|
const counters = {};
|
|
37831
38235
|
try {
|
|
37832
38236
|
for (const entityType of allEntityTypes()) {
|
|
@@ -37848,13 +38252,13 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37848
38252
|
const outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen);
|
|
37849
38253
|
if (outcome.state !== state) {
|
|
37850
38254
|
state = outcome.state;
|
|
37851
|
-
stateDirty = true;
|
|
37852
38255
|
}
|
|
37853
38256
|
if (outcome.action === "written")
|
|
37854
38257
|
ctr.written++;
|
|
37855
38258
|
else if (outcome.action === "deleted")
|
|
37856
38259
|
ctr.deleted++;
|
|
37857
38260
|
}
|
|
38261
|
+
commitPending();
|
|
37858
38262
|
if (page.next_cursor === null)
|
|
37859
38263
|
break;
|
|
37860
38264
|
lastAdvancedCursor = page.next_cursor;
|
|
@@ -37881,21 +38285,21 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37881
38285
|
output: out,
|
|
37882
38286
|
suppressor: deps.suppressor,
|
|
37883
38287
|
isFileFrozen: deps.isFileFrozen,
|
|
38288
|
+
commit: commitState,
|
|
37884
38289
|
forceTextRewalk: needsDerivedTextRewalk
|
|
37885
38290
|
});
|
|
37886
38291
|
if (filesResult.state !== state) {
|
|
37887
38292
|
state = filesResult.state;
|
|
37888
|
-
stateDirty = true;
|
|
37889
38293
|
}
|
|
37890
38294
|
if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
|
|
37891
38295
|
state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
|
|
37892
|
-
stateDirty = true;
|
|
37893
38296
|
}
|
|
37894
38297
|
counters["files"] = {
|
|
37895
38298
|
fetched: filesResult.fetched,
|
|
37896
38299
|
written: filesResult.written + filesResult.textWritten,
|
|
37897
38300
|
deleted: filesResult.deleted + filesResult.textDeleted
|
|
37898
38301
|
};
|
|
38302
|
+
commitPending();
|
|
37899
38303
|
if (filesResult.lastCursor !== filesStartCursor) {
|
|
37900
38304
|
updateSyncCursor(slug, "files", filesResult.lastCursor);
|
|
37901
38305
|
}
|
|
@@ -37911,8 +38315,8 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37911
38315
|
});
|
|
37912
38316
|
if (rulesResult.state !== state) {
|
|
37913
38317
|
state = rulesResult.state;
|
|
37914
|
-
stateDirty = true;
|
|
37915
38318
|
}
|
|
38319
|
+
commitPending();
|
|
37916
38320
|
if (rulesResult.action === "written") {
|
|
37917
38321
|
counters["workspace-rules"] = { fetched: 1, written: 1, deleted: 0 };
|
|
37918
38322
|
}
|
|
@@ -37928,8 +38332,8 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37928
38332
|
});
|
|
37929
38333
|
if (memoryResult.state !== state) {
|
|
37930
38334
|
state = memoryResult.state;
|
|
37931
|
-
stateDirty = true;
|
|
37932
38335
|
}
|
|
38336
|
+
commitPending();
|
|
37933
38337
|
if (memoryResult.action === "written") {
|
|
37934
38338
|
counters["workspace-memory"] = { fetched: 1, written: 1, deleted: 0 };
|
|
37935
38339
|
}
|
|
@@ -37945,8 +38349,8 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37945
38349
|
});
|
|
37946
38350
|
if (userMemoryResult.state !== state) {
|
|
37947
38351
|
state = userMemoryResult.state;
|
|
37948
|
-
stateDirty = true;
|
|
37949
38352
|
}
|
|
38353
|
+
commitPending();
|
|
37950
38354
|
if (userMemoryResult.action === "written") {
|
|
37951
38355
|
counters["user-memory"] = { fetched: 1, written: 1, deleted: 0 };
|
|
37952
38356
|
}
|
|
@@ -37962,8 +38366,7 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
37962
38366
|
}
|
|
37963
38367
|
try {
|
|
37964
38368
|
updateLastSyncedAt(slug, now().toISOString());
|
|
37965
|
-
|
|
37966
|
-
writeState(state);
|
|
38369
|
+
commitPending();
|
|
37967
38370
|
} catch (err) {
|
|
37968
38371
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
37969
38372
|
out.error(`Sync state could not be persisted: ${msg}`);
|
|
@@ -38099,7 +38502,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
38099
38502
|
const hashes = computeMirrorHashes(strategy, content);
|
|
38100
38503
|
let inode;
|
|
38101
38504
|
try {
|
|
38102
|
-
inode =
|
|
38505
|
+
inode = statSync12(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
|
|
38103
38506
|
} catch {
|
|
38104
38507
|
inode = undefined;
|
|
38105
38508
|
}
|
|
@@ -38156,7 +38559,7 @@ var syncCommand = defineCommand({
|
|
|
38156
38559
|
// src/commands/start.ts
|
|
38157
38560
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
|
|
38158
38561
|
var PULL_HEARTBEAT_MS = 900000;
|
|
38159
|
-
function backfillStateInodes(workspaceSlug, state, statFn = (p) =>
|
|
38562
|
+
function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync13(p)) {
|
|
38160
38563
|
let changed = false;
|
|
38161
38564
|
const nextEntries = {};
|
|
38162
38565
|
for (const [entityType, bucket] of Object.entries(state.entries)) {
|
|
@@ -38286,7 +38689,7 @@ function makeWatchHandler(deps) {
|
|
|
38286
38689
|
}
|
|
38287
38690
|
let st;
|
|
38288
38691
|
try {
|
|
38289
|
-
st =
|
|
38692
|
+
st = statSync13(absolutePath);
|
|
38290
38693
|
} catch (err) {
|
|
38291
38694
|
if (err?.code === "ENOENT")
|
|
38292
38695
|
return;
|
|
@@ -38942,12 +39345,13 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
38942
39345
|
if (initial.exitCode !== 0)
|
|
38943
39346
|
return initial;
|
|
38944
39347
|
let state = loadState(slug);
|
|
39348
|
+
const beforeBackfill = state;
|
|
38945
39349
|
const inodeBackfill = backfillStateInodes(slug, state);
|
|
38946
39350
|
state = inodeBackfill.state;
|
|
38947
39351
|
const lenBackfill = backfillBodyLengths(slug, state);
|
|
38948
39352
|
state = lenBackfill.state;
|
|
38949
39353
|
if (inodeBackfill.changed || lenBackfill.changed) {
|
|
38950
|
-
|
|
39354
|
+
state = commitState(beforeBackfill, state);
|
|
38951
39355
|
}
|
|
38952
39356
|
const spinner = (deps.spinnerFactory ?? (() => createSpinner({ noColor: deps.noColor })))();
|
|
38953
39357
|
spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
@@ -39580,7 +39984,7 @@ var startCommand = defineCommand({
|
|
|
39580
39984
|
});
|
|
39581
39985
|
|
|
39582
39986
|
// src/commands/status.ts
|
|
39583
|
-
import { statSync as
|
|
39987
|
+
import { statSync as statSync14 } from "node:fs";
|
|
39584
39988
|
|
|
39585
39989
|
// src/lib/build-stamp.ts
|
|
39586
39990
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
@@ -39716,7 +40120,7 @@ function renderBuild(out, staleness) {
|
|
|
39716
40120
|
}
|
|
39717
40121
|
function lastActivity(now) {
|
|
39718
40122
|
try {
|
|
39719
|
-
const mtimeMs =
|
|
40123
|
+
const mtimeMs = statSync14(daemonLogPath()).mtimeMs;
|
|
39720
40124
|
return formatRelative(now.getTime() - mtimeMs);
|
|
39721
40125
|
} catch {
|
|
39722
40126
|
return null;
|
|
@@ -39821,6 +40225,17 @@ async function runStatus(args, deps = {}) {
|
|
|
39821
40225
|
out.info(cont(`…and ${rejected.length - MAX_SHOWN} more (see ~/.plinth/daemon.log).`));
|
|
39822
40226
|
}
|
|
39823
40227
|
}
|
|
40228
|
+
out.info("");
|
|
40229
|
+
const stashes = listConflictStashes(slug);
|
|
40230
|
+
if (stashes.length === 0) {
|
|
40231
|
+
out.info(row("Conflicts", "No conflict stashes."));
|
|
40232
|
+
} else {
|
|
40233
|
+
out.info(row("Conflicts", `${stashes.length} stashed local cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
|
|
40234
|
+
const newest = stashes[0];
|
|
40235
|
+
const when = newest.stashedAt ? `${formatAbsolute(newest.stashedAt)} (${formatRelative(now.getTime() - newest.stashedAt.getTime())})` : "time unknown";
|
|
40236
|
+
out.info(cont(`Newest: ${newest.label} — ${when}`));
|
|
40237
|
+
out.info(cont("Each is your version of a file that was replaced by the cloud copy. Compare it with the file in your mirror, re-apply anything you still want, then delete the stash."));
|
|
40238
|
+
}
|
|
39824
40239
|
return { exitCode: 0 };
|
|
39825
40240
|
}
|
|
39826
40241
|
var statusCommand = defineCommand({
|
|
@@ -40159,7 +40574,7 @@ function denyUngated(detail) {
|
|
|
40159
40574
|
}
|
|
40160
40575
|
|
|
40161
40576
|
// src/lib/voice-gate/payload.ts
|
|
40162
|
-
import { readFileSync as readFileSync23, statSync as
|
|
40577
|
+
import { readFileSync as readFileSync23, statSync as statSync15 } from "node:fs";
|
|
40163
40578
|
import { homedir as homedir2 } from "node:os";
|
|
40164
40579
|
import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
|
|
40165
40580
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
@@ -40316,7 +40731,7 @@ function isHelperInvocation(tokens) {
|
|
|
40316
40731
|
return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
|
|
40317
40732
|
}
|
|
40318
40733
|
function defaultReadBodyFile(path5) {
|
|
40319
|
-
const stat4 =
|
|
40734
|
+
const stat4 = statSync15(path5);
|
|
40320
40735
|
if (stat4.size > MAX_BODY_BYTES) {
|
|
40321
40736
|
throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
|
|
40322
40737
|
}
|