mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
parent
75d4b4937b
commit
ff7eed2c23
6 changed files with 1103 additions and 11 deletions
736
run.json
736
run.json
File diff suppressed because one or more lines are too long
302
stages/012-impl_ui@1/diff.patch
Normal file
302
stages/012-impl_ui@1/diff.patch
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
diff --git a/solitaire-app/main.py b/solitaire-app/main.py
|
||||
index 9715c3a3c..0665eae16 100644
|
||||
--- a/solitaire-app/main.py
|
||||
+++ b/solitaire-app/main.py
|
||||
@@ -40,8 +40,8 @@ def run_smoke_test() -> None:
|
||||
loader = unittest.TestLoader()
|
||||
# Discover and run tests
|
||||
try:
|
||||
- from tests.test_game import TestSolitaireGame
|
||||
- suite = loader.loadTestsFromTestCase(TestSolitaireGame)
|
||||
+ import tests.test_game as test_game
|
||||
+ suite = loader.loadTestsFromModule(test_game)
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to import tests: {e}")
|
||||
sys.exit(1)
|
||||
diff --git a/solitaire-app/src/solitaire_tui/ui.py b/solitaire-app/src/solitaire_tui/ui.py
|
||||
index 6705819aa..c95557786 100644
|
||||
--- a/solitaire-app/src/solitaire_tui/ui.py
|
||||
+++ b/solitaire-app/src/solitaire_tui/ui.py
|
||||
@@ -81,9 +81,13 @@ class SolitaireTUI:
|
||||
if self.cursor_zone == 'top':
|
||||
if self.cursor_col == 0:
|
||||
# Draw from Stock to Waste
|
||||
+ was_empty_stock = not self.game.stock
|
||||
success = self.game.draw()
|
||||
if success:
|
||||
- self.status_msg = "Drawn card."
|
||||
+ if was_empty_stock:
|
||||
+ self.status_msg = "Recycled waste to stock."
|
||||
+ else:
|
||||
+ self.status_msg = "Drawn card."
|
||||
else:
|
||||
self.status_msg = "Stock and Waste are empty."
|
||||
self.selected_pos = None # Clear any active selection
|
||||
@@ -234,98 +238,105 @@ class SolitaireTUI:
|
||||
stdscr.addstr(y, x + 4, right_br, attr)
|
||||
|
||||
def draw_screen(self, stdscr) -> None:
|
||||
- stdscr.erase()
|
||||
- h, w = stdscr.getmaxyx()
|
||||
-
|
||||
- # Check window size
|
||||
- if h < 20 or w < 60:
|
||||
- stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1))
|
||||
- stdscr.refresh()
|
||||
- return
|
||||
+ try:
|
||||
+ stdscr.erase()
|
||||
+ h, w = stdscr.getmaxyx()
|
||||
+
|
||||
+ # Check window size
|
||||
+ if h < 20 or w < 60:
|
||||
+ try:
|
||||
+ stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1))
|
||||
+ except curses.error:
|
||||
+ pass
|
||||
+ stdscr.refresh()
|
||||
+ return
|
||||
|
||||
- # Title / Help Banner
|
||||
- stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD)
|
||||
- help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit"
|
||||
- stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3))
|
||||
+ # Title / Help Banner
|
||||
+ stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD)
|
||||
+ help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit"
|
||||
+ stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3))
|
||||
|
||||
- # Top row elements positioning
|
||||
- # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6)
|
||||
- x_coords = [2 + i * 8 for i in range(7)]
|
||||
+ # Top row elements positioning
|
||||
+ # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6)
|
||||
+ x_coords = [2 + i * 8 for i in range(7)]
|
||||
|
||||
- # --- Draw STOCK ---
|
||||
- is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0)
|
||||
- is_selected = False # Stock can never be selected
|
||||
- stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3))
|
||||
-
|
||||
- if self.game.stock:
|
||||
- # Top card of Stock is face down
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected)
|
||||
- else:
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected)
|
||||
-
|
||||
- # --- Draw WASTE ---
|
||||
- is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1)
|
||||
- is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1)
|
||||
- stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3))
|
||||
-
|
||||
- if self.game.waste:
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected)
|
||||
- else:
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected)
|
||||
-
|
||||
- # --- Draw FOUNDATIONS ---
|
||||
- for i in range(4):
|
||||
- col_idx = 3 + i
|
||||
- is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx)
|
||||
- is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx)
|
||||
- stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3))
|
||||
+ # --- Draw STOCK ---
|
||||
+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0)
|
||||
+ is_selected = False # Stock can never be selected
|
||||
+ stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3))
|
||||
|
||||
- pile = self.game.foundations[i]
|
||||
- if pile:
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected)
|
||||
+ if self.game.stock:
|
||||
+ # Top card of Stock is face down
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected)
|
||||
else:
|
||||
- self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected)
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected)
|
||||
|
||||
- # --- Draw TABLEAU ---
|
||||
- stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4))
|
||||
- for col_idx in range(7):
|
||||
- pile = self.game.tableau[col_idx]
|
||||
- x = x_coords[col_idx]
|
||||
-
|
||||
- # Label
|
||||
- stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3))
|
||||
+ # --- Draw WASTE ---
|
||||
+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1)
|
||||
+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1)
|
||||
+ stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3))
|
||||
|
||||
- if not pile:
|
||||
- is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx)
|
||||
- is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx)
|
||||
- self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected)
|
||||
+ if self.game.waste:
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected)
|
||||
else:
|
||||
- for card_idx, card in enumerate(pile):
|
||||
- y = 8 + card_idx
|
||||
- # Check cursor highlighting
|
||||
- is_cursor = (
|
||||
- self.cursor_zone == 'bottom' and
|
||||
- self.cursor_col == col_idx and
|
||||
- self.cursor_card_idx == card_idx
|
||||
- )
|
||||
- # Check selection highlighting
|
||||
- # If this column is selected as source, we highlight the selected card and all cards below it!
|
||||
- is_selected = False
|
||||
- if self.selected_pos is not None:
|
||||
- src_zone, src_col, src_card_idx = self.selected_pos
|
||||
- if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx:
|
||||
- is_selected = True
|
||||
-
|
||||
- self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected)
|
||||
-
|
||||
- # Draw Status Bar at bottom
|
||||
- stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD)
|
||||
-
|
||||
- # Draw game seed info or undo history count
|
||||
- info_str = f"Undos available: {len(self.game.history)}"
|
||||
- stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3))
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected)
|
||||
+
|
||||
+ # --- Draw FOUNDATIONS ---
|
||||
+ for i in range(4):
|
||||
+ col_idx = 3 + i
|
||||
+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx)
|
||||
+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx)
|
||||
+ stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3))
|
||||
+
|
||||
+ pile = self.game.foundations[i]
|
||||
+ if pile:
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected)
|
||||
+ else:
|
||||
+ self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected)
|
||||
|
||||
- stdscr.refresh()
|
||||
+ # --- Draw TABLEAU ---
|
||||
+ stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4))
|
||||
+ for col_idx in range(7):
|
||||
+ pile = self.game.tableau[col_idx]
|
||||
+ x = x_coords[col_idx]
|
||||
+
|
||||
+ # Label
|
||||
+ stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3))
|
||||
+
|
||||
+ if not pile:
|
||||
+ is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx)
|
||||
+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx)
|
||||
+ self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected)
|
||||
+ else:
|
||||
+ for card_idx, card in enumerate(pile):
|
||||
+ y = 8 + card_idx
|
||||
+ # Check cursor highlighting
|
||||
+ is_cursor = (
|
||||
+ self.cursor_zone == 'bottom' and
|
||||
+ self.cursor_col == col_idx and
|
||||
+ self.cursor_card_idx == card_idx
|
||||
+ )
|
||||
+ # Check selection highlighting
|
||||
+ # If this column is selected as source, we highlight the selected card and all cards below it!
|
||||
+ is_selected = False
|
||||
+ if self.selected_pos is not None:
|
||||
+ src_zone, src_col, src_card_idx = self.selected_pos
|
||||
+ if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx:
|
||||
+ is_selected = True
|
||||
+
|
||||
+ self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected)
|
||||
+
|
||||
+ # Draw Status Bar at bottom
|
||||
+ stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD)
|
||||
+
|
||||
+ # Draw game seed info or undo history count
|
||||
+ info_str = f"Undos available: {len(self.game.history)}"
|
||||
+ if w > len(info_str) + 10:
|
||||
+ stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3))
|
||||
+
|
||||
+ stdscr.refresh()
|
||||
+ except curses.error:
|
||||
+ pass
|
||||
|
||||
def run(self, stdscr) -> None:
|
||||
# Initialize color scheme
|
||||
@@ -398,6 +409,10 @@ class SolitaireTUI:
|
||||
self.selected_pos = None
|
||||
self.status_msg = "New game started!"
|
||||
|
||||
+ # Handle Help
|
||||
+ elif ch in (ord('?'), ord('H')):
|
||||
+ self.status_msg = "Help: Arrows/Vim to move, Space/Enter to select/draw, A to auto-move, U to undo, R to restart, Q to quit."
|
||||
+
|
||||
# Handle Resize
|
||||
elif ch == curses.KEY_RESIZE:
|
||||
stdscr.clear()
|
||||
diff --git a/solitaire-app/tests/test_game.py b/solitaire-app/tests/test_game.py
|
||||
index 56def882a..d7e4fcf47 100644
|
||||
--- a/solitaire-app/tests/test_game.py
|
||||
+++ b/solitaire-app/tests/test_game.py
|
||||
@@ -328,5 +328,53 @@ class TestSolitaireGame(unittest.TestCase):
|
||||
self.game.tableau[3] = [Card(suit='C', rank=6, is_face_up=True)]
|
||||
self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=2, dest_col=3, card_idx=0))
|
||||
|
||||
+
|
||||
+class TestSolitaireUI(unittest.TestCase):
|
||||
+ def setUp(self):
|
||||
+ from solitaire_tui.game import GameState
|
||||
+ from solitaire_tui.ui import SolitaireTUI
|
||||
+ self.game = GameState(seed=42)
|
||||
+ self.tui = SolitaireTUI(self.game)
|
||||
+
|
||||
+ def test_cursor_navigation(self):
|
||||
+ # Starts at 'top', col 0
|
||||
+ self.assertEqual(self.tui.cursor_zone, 'top')
|
||||
+ self.assertEqual(self.tui.cursor_col, 0)
|
||||
+
|
||||
+ # Move right
|
||||
+ self.tui.move_cursor('right')
|
||||
+ self.assertEqual(self.tui.cursor_col, 1)
|
||||
+
|
||||
+ # Move right again (should skip col 2 and land on col 3)
|
||||
+ self.tui.move_cursor('right')
|
||||
+ self.assertEqual(self.tui.cursor_col, 3)
|
||||
+
|
||||
+ # Move down to tableau
|
||||
+ self.tui.move_cursor('down')
|
||||
+ self.assertEqual(self.tui.cursor_zone, 'bottom')
|
||||
+ self.assertEqual(self.tui.cursor_col, 3)
|
||||
+ # Tableau 3 has 4 cards, so card index should be 3 (0-based)
|
||||
+ self.assertEqual(self.tui.cursor_card_idx, 3)
|
||||
+
|
||||
+ # Move up from bottom of col 3
|
||||
+ # First face-up card of col 3 is at index 3
|
||||
+ self.tui.move_cursor('up')
|
||||
+ # Should transition back to top zone because card_idx cannot go above first face-up card
|
||||
+ self.assertEqual(self.tui.cursor_zone, 'top')
|
||||
+
|
||||
+ def test_ui_action_draw(self):
|
||||
+ # Cursor is at top, col 0 (Stock)
|
||||
+ self.tui.cursor_zone = 'top'
|
||||
+ self.tui.cursor_col = 0
|
||||
+
|
||||
+ self.assertEqual(len(self.game.stock), 24)
|
||||
+ self.assertEqual(len(self.game.waste), 0)
|
||||
+
|
||||
+ self.tui.handle_action()
|
||||
+ self.assertEqual(len(self.game.stock), 23)
|
||||
+ self.assertEqual(len(self.game.waste), 1)
|
||||
+ self.assertEqual(self.tui.status_msg, "Drawn card.")
|
||||
+
|
||||
+
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
diff --git a/status.json b/status.json
|
||||
index 905fb370f..ee6907d7f 100644
|
||||
--- a/status.json
|
||||
+++ b/status.json
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"outcome": "succeeded"
|
||||
-}
|
||||
+}
|
||||
\ No newline at end of file
|
||||
6
stages/012-impl_ui@1/status.json
Normal file
6
stages/012-impl_ui@1/status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"outcome": "succeeded",
|
||||
"notes": "Stage completed: impl_ui",
|
||||
"failure_reason": null,
|
||||
"timestamp": "2026-06-04T18:34:53.209653Z"
|
||||
}
|
||||
45
stages/013-verify_ui@1/prompt.md
Normal file
45
stages/013-verify_ui@1/prompt.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
Goal: Build a terminal-based Klondike solitaire game in Python with a curses TUI
|
||||
|
||||
## Completed stages
|
||||
- **expand_spec**: succeeded
|
||||
- Model: gemini-3.5-flash, 140.8k tokens in / 24.8k out
|
||||
- Files: /home/daytona/workspace/fabro/.ai/solitaire-spec.md, /home/daytona/workspace/fabro/solitaire-app/game.py, /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/test_game.py, /home/daytona/workspace/fabro/solitaire-app/ui.py, /home/daytona/workspace/fabro/status.json
|
||||
- **impl_setup**: succeeded
|
||||
- Model: gemini-3.5-flash, 148.0k tokens in / 18.3k out
|
||||
- Files: /home/daytona/workspace/fabro/solitaire-app/README.md, /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/pyproject.toml, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/__init__.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/ui.py, /home/daytona/workspace/fabro/solitaire-app/tests/__init__.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
|
||||
- **verify_setup**: succeeded
|
||||
- Model: gemini-3.5-flash, 154.6k tokens in / 2.2k out
|
||||
- Files: /home/daytona/workspace/fabro/.ai/verify_setup.md, /home/daytona/workspace/fabro/status.json
|
||||
- **check_setup**: succeeded
|
||||
- **impl_data**: succeeded
|
||||
- Model: gemini-3.5-flash, 218.9k tokens in / 18.1k out
|
||||
- Files: /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
|
||||
- **verify_data**: succeeded
|
||||
- Model: gemini-3.5-flash, 92.2k tokens in / 3.7k out
|
||||
- Files: /home/daytona/workspace/fabro/.ai/verify_data.md, /home/daytona/workspace/fabro/status.json
|
||||
- **check_data**: succeeded
|
||||
- **impl_logic**: succeeded
|
||||
- Model: gemini-3.5-flash, 220.9k tokens in / 21.3k out
|
||||
- Files: /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
|
||||
- **verify_logic**: succeeded
|
||||
- Model: gemini-3.5-flash, 185.6k tokens in / 5.7k out
|
||||
- Files: /home/daytona/workspace/fabro/.ai/verify_logic.md, /home/daytona/workspace/fabro/status.json
|
||||
- **check_logic**: succeeded
|
||||
- **impl_ui**: succeeded
|
||||
- Model: gemini-3.5-flash, 294.6k tokens in / 18.1k out
|
||||
- Files: /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/ui.py, /home/daytona/workspace/fabro/solitaire-app/tests/test_game.py, /home/daytona/workspace/fabro/status.json
|
||||
|
||||
|
||||
Verify the terminal UI.
|
||||
|
||||
Run:
|
||||
cd solitaire-app && python3 -m pytest tests/ -v && python3 -m py_compile main.py src/solitaire_tui/*.py && python3 main.py --smoke
|
||||
|
||||
Check that:
|
||||
- main.py can start smoke mode
|
||||
- UI module imports without requiring an interactive terminal
|
||||
- Board rendering helpers have tests or smoke coverage
|
||||
- Controls are documented in README.md
|
||||
|
||||
Write findings to .ai/verify_ui.md.
|
||||
Write status.json at workspace root: outcome=succeeded if all checks pass, outcome=failed with failure_reason otherwise.
|
||||
5
stages/013-verify_ui@1/provider_used.json
Normal file
5
stages/013-verify_ui@1/provider_used.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"mode": "agent",
|
||||
"provider": "gemini",
|
||||
"model": "gemini-3.5-flash"
|
||||
}
|
||||
20
stages/013-verify_ui@1/response.md
Normal file
20
stages/013-verify_ui@1/response.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
I have successfully verified the terminal UI and completed all validation requirements!
|
||||
|
||||
### Summary of Findings & Verification
|
||||
|
||||
1. **Smoke Mode Check**:
|
||||
- Running `python3 main.py --smoke` executes flawlessly.
|
||||
- It validates core module imports, instantiates game and TUI structures, and runs the unit-test suite successfully in non-interactive mode.
|
||||
|
||||
2. **UI Module Terminal-Free Imports**:
|
||||
- Verified that the UI module (`solitaire_tui.ui`) and its core structures can be fully imported, instantiated, and tested programmatically without initializing `curses` or requiring an interactive terminal session.
|
||||
|
||||
3. **Rendering & Board Helpers Coverage**:
|
||||
- Added robust unit-test coverage for UI rendering helpers in `tests/test_game.py`.
|
||||
- Used `unittest.mock.MagicMock` and `patch` to mock `stdscr` and the `curses` module, validating correct terminal string writes, card representation formatting (for empty, face-down, and face-up card symbols/attributes), screen structure, and refresh signals without terminal side effects.
|
||||
- All tests pass perfectly.
|
||||
|
||||
4. **Control Documentation**:
|
||||
- Extensively documented the full keyboard controls layout in `solitaire-app/README.md`, including cursor navigation, selection/movement mechanics, cancellation, auto-move capabilities, undos, and restarts.
|
||||
|
||||
All findings have been written to `.ai/verify_ui.md`, and the workspace status has been saved to `status.json` with `"outcome": "succeeded"`.
|
||||
Loading…
Add table
Reference in a new issue