Skip to content

Commit 776a049

Browse files
author
Joshua
committed
Add structured log cursor reads
1 parent e7bbd27 commit 776a049

3 files changed

Lines changed: 145 additions & 5 deletions

File tree

plugin/addons/godot_ai/utils/editor_log_buffer.gd

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ func get_recent(count: int) -> Array[Dictionary]:
6666
return out
6767

6868

69+
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
70+
## Single-lock so the cursor snapshot and slice copy can't race against a
71+
## Logger-thread append.
72+
_mutex.lock()
73+
var out := _get_since_unlocked(since_seq, limit)
74+
_mutex.unlock()
75+
return out
76+
77+
6978
func total_count() -> int:
7079
_mutex.lock()
7180
var n := _total_count_unlocked()
@@ -80,6 +89,13 @@ func dropped_count() -> int:
8089
return n
8190

8291

92+
func appended_total() -> int:
93+
_mutex.lock()
94+
var n := _appended_total_unlocked()
95+
_mutex.unlock()
96+
return n
97+
98+
8399
func clear() -> int:
84100
_mutex.lock()
85101
var n := _total_count_unlocked()

plugin/addons/godot_ai/utils/structured_log_ring.gd

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ var _storage: Array[Dictionary] = []
3030
## (the one about to be overwritten).
3131
var _head := 0
3232
var _dropped_count := 0
33+
## Monotonic number of entries appended since this ring was created. Unlike
34+
## `_storage.size()` and `_dropped_count`, this intentionally survives clear()
35+
## so callers can use it as a stable "next entry to read" cursor.
36+
var _appended_total := 0
3337

3438

3539
func _init(max_lines: int) -> void:
@@ -42,11 +46,12 @@ func _append_entry(entry: Dictionary) -> void:
4246
if _storage.size() < _max_lines:
4347
_storage.append(entry)
4448
_head = _storage.size() % _max_lines
45-
return
46-
## Full — overwrite oldest in place, advance head, count the drop.
47-
_storage[_head] = entry
48-
_head = (_head + 1) % _max_lines
49-
_dropped_count += 1
49+
else:
50+
## Full — overwrite oldest in place, advance head, count the drop.
51+
_storage[_head] = entry
52+
_head = (_head + 1) % _max_lines
53+
_dropped_count += 1
54+
_appended_total += 1
5055

5156

5257
## Lockless slice. Subclasses with a mutex wrap their `get_range` /
@@ -72,6 +77,34 @@ func get_recent(count: int) -> Array[Dictionary]:
7277
return _get_range_unlocked(start, size - start)
7378

7479

80+
## Lockless cursor read. The cursor is the next sequence to read: calling
81+
## get_since(appended_total()) after a snapshot returns only later appends.
82+
func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary:
83+
var size := _storage.size()
84+
var oldest_seq := _appended_total - size
85+
var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total)
86+
var start := start_seq - oldest_seq
87+
var available := maxi(0, size - start)
88+
var count := available
89+
if limit >= 0:
90+
count = mini(available, limit)
91+
var entries := _get_range_unlocked(start, count)
92+
var next_cursor := start_seq + entries.size()
93+
return {
94+
"cursor": since_seq,
95+
"oldest_cursor": oldest_seq,
96+
"next_cursor": next_cursor,
97+
"appended_total": _appended_total,
98+
"truncated": since_seq < oldest_seq,
99+
"has_more": next_cursor < _appended_total,
100+
"entries": entries,
101+
}
102+
103+
104+
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
105+
return _get_since_unlocked(since_seq, limit)
106+
107+
75108
## Lockless accessors. Subclasses with a mutex use these under their lock
76109
## so the field reads stay encapsulated in the base instead of leaking
77110
## `_storage` / `_dropped_count` reach-through into the subclass.
@@ -83,6 +116,10 @@ func _dropped_count_unlocked() -> int:
83116
return _dropped_count
84117

85118

119+
func _appended_total_unlocked() -> int:
120+
return _appended_total
121+
122+
86123
func total_count() -> int:
87124
return _total_count_unlocked()
88125

@@ -91,6 +128,10 @@ func dropped_count() -> int:
91128
return _dropped_count_unlocked()
92129

93130

131+
func appended_total() -> int:
132+
return _appended_total_unlocked()
133+
134+
94135
## Translate a logical index (0 = oldest retained) to a physical
95136
## `_storage` slot. Before the first wrap, storage-order is logical-
96137
## order. After wrapping, the oldest entry lives at `_head`.

test_project/tests/test_editor.gd

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,19 +1011,102 @@ func test_editor_log_buffer_ring_evicts_and_tracks_dropped() -> void:
10111011
buf.append("error", "n %d" % i, "res://x.gd", i)
10121012
assert_eq(buf.total_count(), cap, "Buffer should cap at MAX_LINES")
10131013
assert_eq(buf.dropped_count(), 7, "Should record 7 evictions")
1014+
assert_eq(buf.appended_total(), cap + 7, "Cursor should advance for every append")
10141015
## Oldest 7 dropped: first remaining entry should be index 7.
10151016
var first := buf.get_range(0, 1)
10161017
assert_eq(first[0].text, "n 7")
10171018

10181019

1020+
func test_editor_log_buffer_get_since_returns_entries_after_cursor() -> void:
1021+
var buf := McpEditorLogBuffer.new()
1022+
buf.append("error", "before-a", "res://a.gd", 1)
1023+
buf.append("error", "before-b", "res://b.gd", 2)
1024+
var cursor := buf.appended_total()
1025+
buf.append("error", "after-a", "res://a.gd", 3)
1026+
buf.append("warn", "after-b", "res://b.gd", 4)
1027+
1028+
var result := buf.get_since(cursor)
1029+
assert_eq(result.cursor, cursor)
1030+
assert_eq(result.entries.size(), 2)
1031+
assert_eq(result.entries[0].text, "after-a")
1032+
assert_eq(result.entries[1].text, "after-b")
1033+
assert_eq(result.next_cursor, buf.appended_total())
1034+
assert_false(result.truncated)
1035+
assert_false(result.has_more)
1036+
1037+
1038+
func test_editor_log_buffer_get_since_reports_overflow_truncation() -> void:
1039+
var buf := McpEditorLogBuffer.new()
1040+
var cap := McpEditorLogBuffer.MAX_LINES
1041+
var cursor := buf.appended_total()
1042+
for i in range(cap + 3):
1043+
buf.append("error", "storm %d" % i, "res://storm.gd", i)
1044+
1045+
var result := buf.get_since(cursor)
1046+
assert_true(result.truncated, "Overflow after the cursor must be visible")
1047+
assert_eq(result.entries.size(), cap)
1048+
assert_eq(result.entries[0].text, "storm 3")
1049+
assert_eq(result.entries[cap - 1].text, "storm %d" % (cap + 2))
1050+
assert_eq(result.oldest_cursor, 3)
1051+
assert_eq(result.next_cursor, buf.appended_total())
1052+
1053+
1054+
func test_editor_log_buffer_get_since_limit_paginates_without_losing_cursor() -> void:
1055+
var buf := McpEditorLogBuffer.new()
1056+
for i in range(5):
1057+
buf.append("error", "page %d" % i)
1058+
1059+
var first := buf.get_since(0, 2)
1060+
assert_eq(first.entries.size(), 2)
1061+
assert_eq(first.entries[0].text, "page 0")
1062+
assert_eq(first.next_cursor, 2)
1063+
assert_true(first.has_more)
1064+
1065+
var second := buf.get_since(first.next_cursor, 10)
1066+
assert_eq(second.entries.size(), 3)
1067+
assert_eq(second.entries[0].text, "page 2")
1068+
assert_eq(second.next_cursor, 5)
1069+
assert_false(second.has_more)
1070+
1071+
1072+
func test_editor_log_buffer_get_since_future_cursor_clamps_to_tail() -> void:
1073+
var buf := McpEditorLogBuffer.new()
1074+
for i in range(3):
1075+
buf.append("error", "future %d" % i)
1076+
1077+
var result := buf.get_since(99)
1078+
assert_false(result.truncated)
1079+
assert_eq(result.entries.size(), 0)
1080+
assert_eq(result.next_cursor, buf.appended_total())
1081+
assert_false(result.has_more)
1082+
1083+
10191084
func test_editor_log_buffer_clear_resets_counts() -> void:
10201085
var buf := McpEditorLogBuffer.new()
10211086
for i in range(5):
10221087
buf.append("error", "n %d" % i)
1088+
var cursor := buf.appended_total()
10231089
var cleared := buf.clear()
10241090
assert_eq(cleared, 5, "clear() should report cleared count")
10251091
assert_eq(buf.total_count(), 0)
10261092
assert_eq(buf.dropped_count(), 0)
1093+
assert_eq(buf.appended_total(), cursor, "clear() must not reset the cursor")
1094+
1095+
1096+
func test_editor_log_buffer_get_since_reports_clear_truncation() -> void:
1097+
var buf := McpEditorLogBuffer.new()
1098+
for i in range(5):
1099+
buf.append("error", "before-clear %d" % i)
1100+
var stale_cursor := 2
1101+
buf.clear()
1102+
buf.append("error", "after-clear", "res://after.gd", 9)
1103+
1104+
var result := buf.get_since(stale_cursor)
1105+
assert_true(result.truncated, "Clear after the cursor should degrade the window")
1106+
assert_eq(result.entries.size(), 1)
1107+
assert_eq(result.entries[0].text, "after-clear")
1108+
assert_eq(result.oldest_cursor, 5)
1109+
assert_eq(result.next_cursor, 6)
10271110

10281111

10291112
# ----- get_logs source="editor" routing (issue #231) -----

0 commit comments

Comments
 (0)