1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
//! Offline issue promotion and local reference rewriting.
use anyhow::Result;
use std::cell::Cell;
use uuid::Uuid;
use crate::db::Database;
use crate::issue_file::read_issue_file;
use super::core::{PushOutcome, SharedWriter, WriteSet};
/// Stats from rewriting local issue references after promotion.
#[derive(Debug, Default)]
pub struct RewriteStats {
pub comments_updated: usize,
pub descriptions_updated: usize,
pub sessions_updated: usize,
}
impl RewriteStats {
#[must_use]
pub const fn total(&self) -> usize {
self.comments_updated + self.descriptions_updated + self.sessions_updated
}
}
/// Replace `Lx` tokens in text with their promoted `#N` equivalents.
///
/// Only replaces at word boundaries to avoid false positives (e.g. "FILE1" is not rewritten).
/// Returns `Some(new_text)` if any replacements were made, `None` otherwise.
pub(super) fn replace_local_refs(text: &str, replacements: &[(String, String)]) -> Option<String> {
let mut result = text.to_string();
let mut changed = false;
for (old, new) in replacements {
let mut i = 0;
while let Some(pos) = result[i..].find(old.as_str()) {
let abs_pos = i + pos;
let end_pos = abs_pos + old.len();
// Check word boundary before: must be start of string or non-alphanumeric
let before_ok = abs_pos == 0 || !result.as_bytes()[abs_pos - 1].is_ascii_alphanumeric();
// Check word boundary after: must be end of string or non-alphanumeric
let after_ok =
end_pos >= result.len() || !result.as_bytes()[end_pos].is_ascii_alphanumeric();
if before_ok && after_ok {
result = format!("{}{}{}", &result[..abs_pos], new, &result[end_pos..]);
changed = true;
i = abs_pos + new.len();
} else {
i = end_pos;
}
}
}
if changed {
Some(result)
} else {
None
}
}
impl SharedWriter {
/// Promote offline issues (`display_id: null`) to real display IDs.
///
/// Called during sync when connectivity is restored. Scans the cache for
/// issue files created by this agent with null `display_id`, bulk-claims
/// N sequential IDs, rewrites the JSON files, and pushes.
///
/// Returns a vec of `(old_negative_id, new_display_id, title)` for output.
///
/// # Errors
/// Returns an error if scanning, claiming IDs, or pushing fails.
pub fn promote_offline_issues(&self, db: &Database) -> Result<Vec<(i64, i64, String)>> {
let offline = self.find_offline_issues()?;
if offline.is_empty() {
return Ok(vec![]);
}
let count = i64::try_from(offline.len()).unwrap_or(i64::MAX);
// Build uuid -> negative_id from current SQLite state
let mut uuid_to_neg_id = std::collections::HashMap::new();
for issue in &offline {
if let Ok(neg_id) = db.get_issue_id_by_uuid(&issue.uuid.to_string()) {
uuid_to_neg_id.insert(issue.uuid, neg_id);
}
}
let offline_info: Vec<(Uuid, String)> =
offline.iter().map(|i| (i.uuid, i.title.clone())).collect();
let first_id = Cell::new(0i64);
let outcome = self.write_commit_push(
|writer| {
let (start_id, counters) = writer.claim_display_id(count)?;
first_id.set(start_id);
let mut files = Vec::new();
for (i, (uuid, _)) in offline_info.iter().enumerate() {
let path = writer.issue_path(uuid);
let mut issue = read_issue_file(&path)?;
issue.display_id = Some(start_id + i64::try_from(i).unwrap_or(0));
let json = serde_json::to_vec_pretty(&issue)?;
files.push((writer.issue_rel_path(uuid), json));
}
Ok(WriteSet {
files,
counters: Some(counters),
use_git_rm: false,
})
},
&format!("promote {count} offline issue(s)"),
)?;
if outcome == PushOutcome::LocalOnly {
// Keep the promotion commit local — don't revert (#467).
//
// The old approach reverted display_ids and counters on disk, then
// created a revert commit. This had 6 failure points that each
// needed error handling and could leave orphaned IDs or desynced
// counters.
//
// The new approach: do nothing. The promotion commit stays local
// with the correct display_ids and counter. On next sync:
// - If connectivity restored: push succeeds, promotion is published
// - If push conflicts: write_commit_push's retry loop resets and
// the prepare closure re-reads fresh counters after rebase,
// re-claiming from the correct value. No collision.
tracing::info!(
"promotion of {} issue(s) saved locally (push failed), will retry on next sync",
count
);
return Ok(vec![]);
}
// Re-hydrate with new positive IDs
self.hydrate_with_retry(db);
// Record promoted UUIDs so they are never re-promoted (#451).
// This MUST succeed — if it fails, the next sync will re-promote
// the same UUIDs with new display IDs, creating duplicates.
let promoted_uuids: Vec<Uuid> = offline_info.iter().map(|(uuid, _)| *uuid).collect();
self.record_promoted_uuids(&promoted_uuids)?;
let start_id = first_id.get();
let mapping: Vec<(i64, i64, String)> = offline_info
.iter()
.enumerate()
.map(|(i, (uuid, title))| {
let old_neg_id = uuid_to_neg_id.get(uuid).copied().unwrap_or(0);
let new_id = start_id + i64::try_from(i).unwrap_or(0);
(old_neg_id, new_id, title.clone())
})
.collect();
Ok(mapping)
}
/// Rewrite `Lx` references in comments, descriptions, and session notes
/// after offline issues have been promoted to real display IDs.
///
/// Returns stats on how many text fields were updated.
///
/// # Errors
/// Returns an error if database queries or file writes fail.
pub fn rewrite_local_references(
&self,
db: &Database,
mapping: &[(i64, i64, String)],
) -> Result<RewriteStats> {
if mapping.is_empty() {
return Ok(RewriteStats::default());
}
// Serialize access to the hub cache (#374)
let _lock_guard = self.sync.acquire_lock()?;
// Build replacement map: "L1" -> "#5", "L2" -> "#6"
let replacements: Vec<(String, String)> = mapping
.iter()
.filter(|(neg_id, _, _)| *neg_id != 0)
.map(|(neg_id, new_id, _)| {
(format!("L{}", neg_id.unsigned_abs()), format!("#{new_id}"))
})
.collect();
if replacements.is_empty() {
return Ok(RewriteStats::default());
}
let mut stats = RewriteStats::default();
// 1. Rewrite comments and descriptions in JSON files + SQLite
let mut json_changed = false;
for (_, new_id, _) in mapping {
// Update comments in SQLite
let comments = db.get_comments(*new_id)?;
for comment in &comments {
if let Some(new_content) = replace_local_refs(&comment.content, &replacements) {
db.update_comment_content(comment.id, &new_content)?;
stats.comments_updated += 1;
}
}
// Update description in SQLite
if let Ok(Some(issue)) = db.get_issue(*new_id) {
if let Some(ref desc) = issue.description {
if let Some(new_desc) = replace_local_refs(desc, &replacements) {
db.update_issue(*new_id, None, Some(&new_desc), None)?;
stats.descriptions_updated += 1;
}
}
}
}
// Update JSON files on coordination branch
for (_, new_id, _) in mapping {
let Ok(issue_file) = self.load_issue_by_display_id(*new_id) else {
continue;
};
let mut changed = false;
let mut updated_issue = issue_file.clone();
// Rewrite comments in JSON
for comment in &mut updated_issue.comments {
if let Some(new_content) = replace_local_refs(&comment.content, &replacements) {
comment.content = new_content;
changed = true;
}
}
// Rewrite description in JSON
if let Some(ref desc) = updated_issue.description {
if let Some(new_desc) = replace_local_refs(desc, &replacements) {
updated_issue.description = Some(new_desc);
changed = true;
}
}
if changed {
let json = serde_json::to_string_pretty(&updated_issue)?;
let path = self.issue_path(&updated_issue.uuid);
std::fs::write(&path, json)?;
json_changed = true;
}
}
// Commit JSON changes if any
if json_changed {
if let Err(e) = self.git_in_cache(&["add", "issues/"]) {
tracing::warn!("failed to stage rewritten references: {}", e);
}
if let Err(e) = self.git_commit_in_cache(&format!(
"{}: rewrite local references after promotion",
self.agent.agent_id
)) {
tracing::warn!("failed to commit rewritten references: {}", e);
}
// Push rewritten references — if offline, next sync will push.
if let Err(e) =
self.git_in_cache(&["push", self.sync.remote(), crate::sync::HUB_BRANCH])
{
tracing::info!("reference rewrite push deferred to next sync: {}", e);
}
}
// 2. Rewrite session notes in SQLite
let sessions = db.get_all_sessions_with_notes()?;
for session in &sessions {
if let Some(ref notes) = session.handoff_notes {
if let Some(new_notes) = replace_local_refs(notes, &replacements) {
db.update_session_notes(session.id, &new_notes)?;
stats.sessions_updated += 1;
}
}
}
Ok(stats)
}
}