mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
Wait for the board before a merged Start
The board's note cache lags ~10 s after a merge, so Start found "no startable item" (T1863); it now waits up to 30 s for the merged section's card. A Start POST slower than its timeout that did take effect was reported "board unreachable" (T1864); it now re-reads the board and reports ok when the card left Start. Todo: 64d39ab5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
beb1d1a49f
commit
ba25e8f176
@ -26,16 +26,24 @@ typedef SplitAdd = ({Note note, SplitKind kind, String? why});
|
||||
|
||||
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
|
||||
|
||||
/// The open `## title` headings of [text], outside code fences.
|
||||
Iterable<String> openSections(String text) sync* {
|
||||
/// Every `## title` heading of [text] outside code fences, open or done.
|
||||
Iterable<RegExpMatch> _sections(String text) sync* {
|
||||
var fenced = false;
|
||||
for (final line in text.split('\n')) {
|
||||
if (line.trimLeft().startsWith('```')) fenced = !fenced;
|
||||
final m = fenced ? null : _heading.firstMatch(line);
|
||||
if (m != null && m.group(1) == null) yield m.group(2)!.trim();
|
||||
if (m != null) yield m;
|
||||
}
|
||||
}
|
||||
|
||||
/// The open `## title` headings of [text], outside code fences.
|
||||
Iterable<String> openSections(String text) => _sections(
|
||||
text,
|
||||
).where((m) => m.group(1) == null).map((m) => m.group(2)!.trim());
|
||||
|
||||
/// How many board items [text] splits into: one per section, done or not.
|
||||
int sectionCount(String text) => _sections(text).length;
|
||||
|
||||
/// The separate open note of [repo] with an open section titled [title].
|
||||
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
|
||||
final hits =
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
/// a board that is down must not lose the task that was just saved.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -19,17 +20,34 @@ final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"');
|
||||
|
||||
/// Talks to the board over [client] at [base].
|
||||
class BoardClient {
|
||||
/// [client] is injectable so tests need no network.
|
||||
BoardClient({http.Client? client, Uri? base})
|
||||
: _http = client ?? http.Client(),
|
||||
base = base ?? kBoardBase;
|
||||
/// [client] is injectable so tests need no network. [wait] bounds how
|
||||
/// long Start waits for the board to see a merged section (its note cache
|
||||
/// lags ~10 s, T1863) and to confirm a Start whose reply outran
|
||||
/// [startTimeout] (T1864); [poll] is the gap between board reads.
|
||||
BoardClient({
|
||||
http.Client? client,
|
||||
Uri? base,
|
||||
this.wait = const Duration(seconds: 30),
|
||||
this.poll = const Duration(seconds: 1),
|
||||
this.startTimeout = const Duration(seconds: 30),
|
||||
}) : _http = client ?? http.Client(),
|
||||
base = base ?? kBoardBase;
|
||||
|
||||
final http.Client _http;
|
||||
|
||||
/// The board's origin; also the `Origin` header of a POST.
|
||||
final Uri base;
|
||||
|
||||
static const _timeout = Duration(seconds: 5);
|
||||
/// Longest wait for the board to catch up with the note.
|
||||
final Duration wait;
|
||||
|
||||
/// Gap between two board reads while waiting.
|
||||
final Duration poll;
|
||||
|
||||
/// Longest wait for the reply to a Start POST.
|
||||
final Duration startTimeout;
|
||||
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
Future<List<Map<String, dynamic>>> _cards(String noteId) async {
|
||||
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
|
||||
@ -54,30 +72,77 @@ class BoardClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks Start on the note's last startable item titled [title].
|
||||
Future<StartResult> start(String noteId, String title) async {
|
||||
/// Clicks Start on the note's last startable item titled [title]. With
|
||||
/// [sections], first waits until the board shows the note's last section
|
||||
/// (`<id8>-<sections - 1>`), since a merge just added it.
|
||||
Future<StartResult> start(
|
||||
String noteId,
|
||||
String title, {
|
||||
int? sections,
|
||||
}) async {
|
||||
try {
|
||||
final card = (await _cards(noteId)).reversed
|
||||
.where((c) => c['title'] == title && _startable(c))
|
||||
.firstOrNull;
|
||||
final last = sections == null ? null : _item(noteId, sections - 1);
|
||||
bool seen(List<Map<String, dynamic>> cs) =>
|
||||
last == null || cs.any((c) => c['id'] == last);
|
||||
final cards = await _until(noteId, seen);
|
||||
// Never an older same-titled item in place of the merged one.
|
||||
final card = seen(cards)
|
||||
? cards.reversed
|
||||
.where((c) => c['title'] == title && _startable(c))
|
||||
.firstOrNull
|
||||
: null;
|
||||
if (card == null) return _no('no startable item');
|
||||
final page = await _http.get(base).timeout(_timeout);
|
||||
final token = _csrf.firstMatch(page.body)?.group(1);
|
||||
if (token == null) return _no('no CSRF token on the board page');
|
||||
final id = card['id']! as String;
|
||||
final res = await _http
|
||||
.post(
|
||||
base.resolve('/api/task/$id/start'),
|
||||
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
|
||||
)
|
||||
.timeout(_timeout);
|
||||
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
|
||||
return _no('board refused start: ${res.statusCode} ${_message(res)}');
|
||||
return await _post(noteId, id, token);
|
||||
} on Object catch (e) {
|
||||
return _no('board unreachable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StartResult> _post(String noteId, String id, String token) async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _http
|
||||
.post(
|
||||
base.resolve('/api/task/$id/start'),
|
||||
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
|
||||
)
|
||||
.timeout(startTimeout);
|
||||
} on TimeoutException catch (e) {
|
||||
// The board may still be starting it: a card that left Start took it.
|
||||
final cards = await _until(
|
||||
noteId,
|
||||
(cs) => cs.any((c) => c['id'] == id && !_startable(c)),
|
||||
);
|
||||
if (cards.any((c) => c['id'] == id && !_startable(c))) {
|
||||
return (ok: true, reason: null, item: id);
|
||||
}
|
||||
return _no('board unreachable: $e');
|
||||
}
|
||||
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
|
||||
return _no('board refused start: ${res.statusCode} ${_message(res)}');
|
||||
}
|
||||
|
||||
/// The note's cards once [done] holds, or the last read after [wait].
|
||||
Future<List<Map<String, dynamic>>> _until(
|
||||
String noteId,
|
||||
bool Function(List<Map<String, dynamic>>) done,
|
||||
) async {
|
||||
final deadline = DateTime.now().add(wait);
|
||||
var cards = await _cards(noteId);
|
||||
while (!done(cards) && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(poll);
|
||||
cards = await _cards(noteId);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
static String _item(String noteId, int index) =>
|
||||
'${noteId.substring(0, 8)}-$index';
|
||||
|
||||
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.any((a) => a['id'] == 'start' && a['enabled'] == true);
|
||||
|
||||
@ -41,7 +41,11 @@ Future<String> addSplit(
|
||||
if (block != null) return '$reply\nstart: skipped ($block)';
|
||||
final start = board == null
|
||||
? (ok: false, reason: 'no board configured', item: null)
|
||||
: await board.start(added.note.id, title.trim());
|
||||
: await board.start(
|
||||
added.note.id,
|
||||
title.trim(),
|
||||
sections: sectionCount(added.note.text),
|
||||
);
|
||||
if (start.ok) return '$reply\nstart: ok ${start.item}';
|
||||
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; '
|
||||
'the merge is saved, click Start on the board by hand';
|
||||
|
||||
@ -32,13 +32,26 @@ class _Board {
|
||||
String startBody = '{"ok":false,"error":{"message":"nope"}}';
|
||||
String page = '<meta name="todoimpl-csrf" content="tok-1">';
|
||||
|
||||
BoardClient client() => BoardClient(
|
||||
/// Board reads so far; [onRead] may change [cards] between reads.
|
||||
int reads = 0;
|
||||
void Function(int read)? onRead;
|
||||
Duration postDelay = Duration.zero;
|
||||
|
||||
BoardClient client({
|
||||
Duration wait = Duration.zero,
|
||||
Duration startTimeout = const Duration(seconds: 5),
|
||||
}) => BoardClient(
|
||||
wait: wait,
|
||||
poll: const Duration(milliseconds: 5),
|
||||
startTimeout: startTimeout,
|
||||
client: MockClient((req) async {
|
||||
if (req.method == 'POST') {
|
||||
posts.add(req);
|
||||
await Future<void>.delayed(postDelay);
|
||||
return http.Response(startBody, startStatus);
|
||||
}
|
||||
if (req.url.path == '/api/board') {
|
||||
onRead?.call(++reads);
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({'tasks': cards})),
|
||||
boardStatus,
|
||||
@ -124,6 +137,60 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('T1863: waits for the board to see a freshly merged section', () async {
|
||||
final b = _Board([_card('cccccccc-0', 'Młody')]);
|
||||
b.onRead = (n) {
|
||||
if (n == 3) b.cards.add(_card('cccccccc-1', 'Młody'));
|
||||
};
|
||||
final r = await b
|
||||
.client(wait: const Duration(seconds: 2))
|
||||
.start(_id, 'Młody', sections: 2);
|
||||
expect(r, (ok: true, reason: null, item: 'cccccccc-1'));
|
||||
});
|
||||
|
||||
test('T1863: a board that never shows the section gives up', () async {
|
||||
final b = _Board([_card('cccccccc-0', 'Młody')]);
|
||||
final r = await b
|
||||
.client(wait: const Duration(milliseconds: 50))
|
||||
.start(_id, 'Młody', sections: 2);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.reason, 'no startable item');
|
||||
expect(b.posts, isEmpty);
|
||||
});
|
||||
|
||||
test('T1864: a slow Start that took effect is ok, not unreachable', () async {
|
||||
final b = _Board([_card('cccccccc-0', 'Młody')])
|
||||
..postDelay = const Duration(milliseconds: 200);
|
||||
b.onRead = (n) {
|
||||
// After the POST was sent the card has left backlog (no Start action).
|
||||
if (b.posts.isNotEmpty) {
|
||||
b.cards[0] = _card(
|
||||
'cccccccc-0',
|
||||
'Młody',
|
||||
column: 'running',
|
||||
start: false,
|
||||
);
|
||||
}
|
||||
};
|
||||
final r = await b
|
||||
.client(startTimeout: const Duration(milliseconds: 50))
|
||||
.start(_id, 'Młody');
|
||||
expect(r, (ok: true, reason: null, item: 'cccccccc-0'));
|
||||
});
|
||||
|
||||
test(
|
||||
'T1864: a slow Start that did not take effect is still a reason',
|
||||
() async {
|
||||
final b = _Board([_card('cccccccc-0', 'Młody')])
|
||||
..postDelay = const Duration(milliseconds: 200);
|
||||
final r = await b
|
||||
.client(startTimeout: const Duration(milliseconds: 50))
|
||||
.start(_id, 'Młody');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.reason, startsWith('board unreachable'));
|
||||
},
|
||||
);
|
||||
|
||||
test('the default client targets the loopback board', () {
|
||||
expect(BoardClient().base.toString(), 'http://127.0.0.1:8786');
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:mcp_dart/mcp_dart.dart';
|
||||
import 'package:todo/mcp/board_client.dart';
|
||||
import 'package:todo/mcp/task_service.dart';
|
||||
|
||||
import 'mcp_harness.dart';
|
||||
|
||||
@ -15,9 +16,14 @@ void main() {
|
||||
late McpHarness h;
|
||||
late McpClient c;
|
||||
var cards = <Map<String, dynamic>>[];
|
||||
// Cards the board shows only from its next read on (its note cache lags).
|
||||
var late_ = <Map<String, dynamic>>[];
|
||||
var lag = 0; // board reads before [late_] shows
|
||||
var up = true;
|
||||
final posts = <String>[];
|
||||
final board = BoardClient(
|
||||
wait: const Duration(seconds: 2),
|
||||
poll: const Duration(milliseconds: 5),
|
||||
client: MockClient((req) async {
|
||||
if (!up) throw http.ClientException('connection refused');
|
||||
if (req.method == 'POST') {
|
||||
@ -25,17 +31,26 @@ void main() {
|
||||
return http.Response('{"ok":true}', 200);
|
||||
}
|
||||
if (req.url.path == '/api/board') {
|
||||
if (lag-- == 0) {
|
||||
cards = [...cards, ...late_];
|
||||
late_ = [];
|
||||
}
|
||||
final shown = [...cards];
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({'tasks': cards})),
|
||||
utf8.encode(jsonEncode({'tasks': shown})),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
|
||||
}),
|
||||
);
|
||||
List<Map<String, dynamic>> cardsOf(String id, String column) => [
|
||||
List<Map<String, dynamic>> cardsOf(
|
||||
String id,
|
||||
String column, [
|
||||
int index = 0,
|
||||
]) => [
|
||||
{
|
||||
'id': '${id.substring(0, 8)}-0',
|
||||
'id': '${id.substring(0, 8)}-$index',
|
||||
'title': 'Młody',
|
||||
'column': column,
|
||||
'actions': [
|
||||
@ -46,6 +61,8 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
cards = [];
|
||||
late_ = [];
|
||||
lag = 0;
|
||||
up = true;
|
||||
posts.clear();
|
||||
h = McpHarness(board: board);
|
||||
@ -78,16 +95,22 @@ void main() {
|
||||
expect(posts, isEmpty);
|
||||
});
|
||||
|
||||
test('C3/C6: a duplicate merges and Start is POSTed', () async {
|
||||
final x = idIn(await add({'done_check': 'true'}));
|
||||
cards = cardsOf(x, 'backlog');
|
||||
final r = await add({'done_check': 'true'});
|
||||
expect(r, startsWith('merged into $x'));
|
||||
expect(r, contains('start: ok ${x.substring(0, 8)}-0'));
|
||||
expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']);
|
||||
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
|
||||
expect(listed.trim().split('\n'), hasLength(1));
|
||||
});
|
||||
test(
|
||||
'C3/C6/T1863: a duplicate merges, Start waits for the new item',
|
||||
() async {
|
||||
final x = idIn(await add({'done_check': 'true'}));
|
||||
cards = cardsOf(x, 'backlog');
|
||||
// The merged section shows up one read later than the merge itself.
|
||||
late_ = cardsOf(x, 'backlog', 1);
|
||||
lag = 2; // past the guard's running read and the first Start read
|
||||
final r = await add({'done_check': 'true'});
|
||||
expect(r, startsWith('merged into $x'));
|
||||
expect(r, contains('start: ok ${x.substring(0, 8)}-1'));
|
||||
expect(posts, ['/api/task/${x.substring(0, 8)}-1/start']);
|
||||
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
|
||||
expect(listed.trim().split('\n'), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('C6: done_check none or low priority merges without Start', () async {
|
||||
final x = idIn(await add({}));
|
||||
@ -120,6 +143,22 @@ void main() {
|
||||
expect(r, contains('click Start on the board by hand'));
|
||||
});
|
||||
|
||||
test('a non-boolean new_note is refused by the service too', () {
|
||||
// The MCP schema rejects it first; the service guards its own callers.
|
||||
expect(
|
||||
TaskService(
|
||||
h.session,
|
||||
).add({'repo': _repo, 'title': 'Młody', 'new_note': 'yes'}),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
'new_note: must be a boolean',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the add_task schema offers new_note', () async {
|
||||
final tools = (await c.listTools()).tools;
|
||||
final add = tools.singleWhere((t) => t.name == 'add_task');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user