close
small gleam coding and (not yet) persistent agent daemon with a detachable cli
9

Configure Feed

Select the types of activity you want to include in your feed.

Scope work ledger to workspace (#3) #13

Merged opened by okami.mom targeting main from issue-3-work-ledger-cwd

Resolves #3 (3mw7qfm4slh22).

Scope the revision-checked work ledger by the session's canonical workspace, consistently across human /work, Python/remote work RPC, and CRUD. Cross-workspace get/update/delete and parent assignment fail closed. Existing global rows are retained in a reserved legacy scope instead of exposed to unrelated workspaces; recovery instructions are included. Migration adds cwd before creating its index, including on existing databases.

Verified by manager in isolated worktree: gleam format --check src test; gleam test (109 passed); python3 test/e2e/run.py work_test.py (1 passed); python3 test/e2e/run.py extensions_test.py:ExtensionTests.test_work_command_is_scoped_to_each_workspace (1 passed).

Labels

None yet.

Participants 1
AT URI
at://did:plc:3rwz3xfw2crswgifqgc3g7zh/sh.tangled.repo.pull/3mwip3gtus622
+296 -47
Diff #1
+1 -1
src/albedo/harness/extensions/python/kernel.gleam
··· 52 52 executable, 53 53 script, 54 54 cwd, 55 - rpc.handle(store, _), 55 + rpc.handle(store, cwd, _), 56 56 ["run", "work"], 57 57 ) 58 58 }
+15 -13
src/albedo/harness/extensions/work/command.gleam
··· 15 15 import gleam/result 16 16 import gleam/string 17 17 18 - pub fn command(store: work.Store) -> Command { 18 + pub fn command(store: work.Store, cwd: String) -> Command { 19 19 Command( 20 20 "/work", 21 21 "Show the shared work ledger, or change it: add <title>, edit <id> <title>, status <id> <status>, remove <id>. The agent is told about every change.", ··· 38 38 let details = 39 39 dict.get(args, "details") |> result.unwrap("") |> string.trim 40 40 case action, caller { 41 - "", _ -> listing(store) 42 - _, UserCall -> change(store, ctx, action, details) 41 + "", _ -> listing(store, cwd) 42 + _, UserCall -> change(store, cwd, ctx, action, details) 43 43 _, _ -> Error("only a user changes the ledger through /work") 44 44 } 45 45 }, 46 46 ) 47 47 } 48 48 49 - fn listing(store: work.Store) -> Result(command.Outcome, String) { 49 + fn listing(store: work.Store, cwd: String) -> Result(command.Outcome, String) { 50 50 use items <- result.try( 51 - work.list(store, 0, 200) |> result.map_error(describe), 51 + work.list(store, cwd, 0, 200) |> result.map_error(describe), 52 52 ) 53 53 let ordered = list.sort(items, fn(a, b) { int.compare(rank(a), rank(b)) }) 54 54 let rows = list.map(ordered, row) ··· 132 132 133 133 fn change( 134 134 store: work.Store, 135 + cwd: String, 135 136 ctx: Context, 136 137 action: String, 137 138 details: String, 138 139 ) -> Result(command.Outcome, String) { 139 140 use #(verb, item) <- result.try(case action { 140 141 "add" -> 141 - work.create(store, details, "", None) 142 + work.create(store, cwd, details, "", None) 142 143 |> result.map(fn(item) { #("added", item) }) 143 144 |> result.map_error(describe) 144 145 "edit" -> { 145 - use #(current, title) <- result.try(target(store, details)) 146 - work.update(store, work.Item(..current, title: title)) 146 + use #(current, title) <- result.try(target(store, cwd, details)) 147 + work.update(store, cwd, work.Item(..current, title: title)) 147 148 |> result.map(fn(item) { #("renamed", item) }) 148 149 |> result.map_error(describe) 149 150 } 150 151 "status" -> { 151 - use #(current, name) <- result.try(target(store, details)) 152 + use #(current, name) <- result.try(target(store, cwd, details)) 152 153 use status <- result.try( 153 154 work.parse_status(name) |> result.map_error(describe), 154 155 ) 155 - work.update(store, work.Item(..current, status: status)) 156 + work.update(store, cwd, work.Item(..current, status: status)) 156 157 |> result.map(fn(item) { #("marked " <> name, item) }) 157 158 |> result.map_error(describe) 158 159 } 159 160 "remove" -> { 160 - use #(current, _) <- result.try(target(store, details)) 161 - work.delete(store, current.id, current.revision) 161 + use #(current, _) <- result.try(target(store, cwd, details)) 162 + work.delete(store, cwd, current.id, current.revision) 162 163 |> result.map(fn(item) { #("removed", item) }) 163 164 |> result.map_error(describe) 164 165 } ··· 202 203 /// `<id> [rest]`: the current item and whatever follows its id. 203 204 fn target( 204 205 store: work.Store, 206 + cwd: String, 205 207 details: String, 206 208 ) -> Result(#(work.Item, String), String) { 207 209 let #(first, rest) = ··· 210 212 int.parse(string.replace(first, "#", "")) 211 213 |> result.replace_error("expected a work item id, got " <> first), 212 214 ) 213 - use item <- result.try(work.get(store, id) |> result.map_error(describe)) 215 + use item <- result.try(work.get(store, cwd, id) |> result.map_error(describe)) 214 216 Ok(#(item, string.trim(rest))) 215 217 } 216 218
+13 -3
src/albedo/harness/extensions/work/extension.gleam
··· 1 + import albedo/daemon/conversation 1 2 import albedo/harness/extension as harness_extension 2 3 import albedo/harness/extensions/work/command as work_command 3 4 import albedo/harness/extensions/work/ledger as work 4 5 import albedo/harness/extensions/work/rpc 6 + import gleam/result 5 7 6 8 pub fn extension() -> harness_extension.Extension { 7 9 harness_extension.Extension( ··· 13 15 "Humans and agents share this revision-checked work ledger; every call is async. await work.list(after=0, limit=50), work.get(id), work.create(title, notes=\"\", parent=None), work.update(id, revision=, title=, notes=, status=), and work.delete(id, revision=) return item records: item.id and item[\"id\"] both work, and so do revision, status, title, notes, and parent. Status is open, active, blocked, done, or cancelled. Pass the revision you last read; a stale one raises WorkError, so get the item again. Keep execution status separate from work status. People manage the same ledger with /work, and their changes reach you as notes.", 14 16 [], 15 17 ["work"], 16 - [#("work", fn(store, _, request) { rpc.handle(store, request) })], 18 + [ 19 + #("work", fn(store, session, request) { 20 + let cwd = 21 + conversation.get(store, session) 22 + |> result.map(fn(info) { info.cwd }) 23 + |> result.unwrap("") 24 + rpc.handle(store, cwd, request) 25 + }), 26 + ], 17 27 ), 18 28 // The /work page needs the ledger handle, which only a prepared plugin receives. 19 - harness_extension.ManagedPlugin(fn(store, _, _) { 29 + harness_extension.ManagedPlugin(fn(store, _, workspace) { 20 30 Ok( 21 31 harness_extension.Managed( 22 32 "", ··· 24 34 [], 25 35 [], 26 36 [], 27 - [work_command.command(store)], 37 + [work_command.command(store, workspace)], 28 38 fn() { Nil }, 29 39 ), 30 40 )
+92 -17
src/albedo/harness/extensions/work/ledger.gleam
··· 72 72 parent INTEGER REFERENCES work(id), 73 73 session TEXT, 74 74 run TEXT CHECK(run IS NULL OR session IS NOT NULL), 75 + cwd TEXT NOT NULL DEFAULT '__albedo_legacy__', 75 76 revision INTEGER NOT NULL DEFAULT 1, 76 77 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), 77 78 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ··· 82 83 83 84 const columns = "id,title,notes,status,parent,session,run,revision" 84 85 86 + const legacy_cwd = "__albedo_legacy__" 87 + 88 + fn valid_cwd(cwd: String) -> Result(Nil, Error) { 89 + case cwd == legacy_cwd || cwd == "" { 90 + True -> Error(Invalid("reserved or empty cwd")) 91 + False -> Ok(Nil) 92 + } 93 + } 94 + 85 95 pub fn status_name(status: Status) -> String { 86 96 case status { 87 97 Open -> "open" ··· 138 148 } 139 149 140 150 /// Keyset pagination. No unbounded ledger dumps into a model context. 141 - pub fn list(store: Store, after: Int, limit: Int) -> Result(List(Item), Error) { 151 + pub fn list( 152 + store: Store, 153 + cwd: String, 154 + after: Int, 155 + limit: Int, 156 + ) -> Result(List(Item), Error) { 157 + use _ <- result.try(valid_cwd(cwd)) 142 158 case after < 0 || limit < 1 || limit > 200 { 143 159 True -> Error(Invalid("after >= 0 and 1 <= limit <= 200 required")) 144 160 False -> 145 161 query(store, fn(db) { 146 162 rows( 147 163 db, 148 - "SELECT " <> columns <> " FROM work WHERE id > ? ORDER BY id LIMIT ?", 149 - [sqlight.int(after), sqlight.int(limit)], 164 + "SELECT " 165 + <> columns 166 + <> " FROM work WHERE cwd=? AND id > ? ORDER BY id LIMIT ?", 167 + [sqlight.text(cwd), sqlight.int(after), sqlight.int(limit)], 150 168 ) 151 169 }) 152 170 } 153 171 } 154 172 155 - pub fn get(store: Store, id: Int) -> Result(Item, Error) { 173 + pub fn get(store: Store, cwd: String, id: Int) -> Result(Item, Error) { 174 + use _ <- result.try(valid_cwd(cwd)) 156 175 use items <- result.try( 157 176 query(store, fn(db) { 158 - rows(db, "SELECT " <> columns <> " FROM work WHERE id = ?", [ 177 + rows(db, "SELECT " <> columns <> " FROM work WHERE cwd=? AND id = ?", [ 178 + sqlight.text(cwd), 159 179 sqlight.int(id), 160 180 ]) 161 181 }), ··· 165 185 166 186 pub fn create( 167 187 store: Store, 188 + cwd: String, 168 189 title: String, 169 190 notes: String, 170 191 parent: Option(Int), 171 192 ) -> Result(Item, Error) { 193 + use _ <- result.try(valid_cwd(cwd)) 172 194 use _ <- result.try(validate(title, notes, None, None)) 173 195 use items <- result.try( 174 196 query(store, fn(db) { 197 + use _ <- result.try(case parent { 198 + None -> Ok(Nil) 199 + Some(parent_id) -> 200 + case 201 + rows(db, "SELECT " <> columns <> " FROM work WHERE cwd=? AND id=?", [ 202 + sqlight.text(cwd), 203 + sqlight.int(parent_id), 204 + ]) 205 + { 206 + Error(error) -> Error(error) 207 + Ok([]) -> Error(NotFound) 208 + Ok(_) -> Ok(Nil) 209 + } 210 + }) 175 211 rows( 176 212 db, 177 - "INSERT INTO work(title,notes,parent) VALUES(?,?,?) RETURNING " 213 + "INSERT INTO work(cwd,title,notes,parent) VALUES(?,?,?,?) RETURNING " 178 214 <> columns, 179 215 [ 216 + sqlight.text(cwd), 180 217 sqlight.text(title), 181 218 sqlight.text(notes), 182 219 sqlight.nullable(sqlight.int, parent), ··· 187 224 one(items) 188 225 } 189 226 190 - pub fn update(store: Store, item: Item) -> Result(Item, Error) { 227 + pub fn update(store: Store, cwd: String, item: Item) -> Result(Item, Error) { 228 + use _ <- result.try(valid_cwd(cwd)) 191 229 use _ <- result.try(validate(item.title, item.notes, item.session, item.run)) 192 230 use items <- result.try( 193 231 query(store, fn(db) { 194 232 use existing <- result.try( 195 - rows(db, "SELECT " <> columns <> " FROM work WHERE id=?", [ 233 + rows(db, "SELECT " <> columns <> " FROM work WHERE cwd=? AND id=?", [ 234 + sqlight.text(cwd), 196 235 sqlight.int(item.id), 197 236 ]), 198 237 ) ··· 204 243 use changed <- result.try( 205 244 rows( 206 245 db, 207 - "UPDATE work SET title=?,notes=?,status=?,session=?,run=?,revision=revision+1,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=? AND revision=? RETURNING " 246 + "UPDATE work SET title=?,notes=?,status=?,session=?,run=?,revision=revision+1,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE cwd=? AND id=? AND revision=? RETURNING " 208 247 <> columns, 209 248 [ 210 249 sqlight.text(item.title), ··· 212 251 sqlight.text(status_name(item.status)), 213 252 sqlight.nullable(sqlight.text, item.session), 214 253 sqlight.nullable(sqlight.text, item.run), 254 + sqlight.text(cwd), 215 255 sqlight.int(item.id), 216 256 sqlight.int(item.revision), 217 257 ], ··· 230 270 231 271 /// Remove one item at the revision the caller last saw. An item with children 232 272 /// stays: its sub-items would lose their parent. 233 - pub fn delete(store: Store, id: Int, revision: Int) -> Result(Item, Error) { 273 + pub fn delete( 274 + store: Store, 275 + cwd: String, 276 + id: Int, 277 + revision: Int, 278 + ) -> Result(Item, Error) { 279 + use _ <- result.try(valid_cwd(cwd)) 234 280 use items <- result.try( 235 281 query(store, fn(db) { 236 282 use existing <- result.try( 237 - rows(db, "SELECT " <> columns <> " FROM work WHERE id=?", [ 283 + rows(db, "SELECT " <> columns <> " FROM work WHERE cwd=? AND id=?", [ 284 + sqlight.text(cwd), 238 285 sqlight.int(id), 239 286 ]), 240 287 ) 241 288 use children <- result.try( 242 - rows(db, "SELECT " <> columns <> " FROM work WHERE parent=? LIMIT 1", [ 243 - sqlight.int(id), 244 - ]), 289 + rows( 290 + db, 291 + "SELECT " <> columns <> " FROM work WHERE cwd=? AND parent=? LIMIT 1", 292 + [ 293 + sqlight.text(cwd), 294 + sqlight.int(id), 295 + ], 296 + ), 245 297 ) 246 298 case existing, children { 247 299 [], _ -> Error(NotFound) ··· 250 302 _, [] -> 251 303 rows( 252 304 db, 253 - "DELETE FROM work WHERE id=? AND revision=? RETURNING " <> columns, 254 - [sqlight.int(id), sqlight.int(revision)], 305 + "DELETE FROM work WHERE cwd=? AND id=? AND revision=? RETURNING " 306 + <> columns, 307 + [sqlight.text(cwd), sqlight.int(id), sqlight.int(revision)], 255 308 ) 256 309 } 257 310 }), ··· 292 345 293 346 pub fn initialise(store: Store) -> Result(Nil, String) { 294 347 storage.query(store, fn(db) { 295 - sqlight.exec(schema, db) |> result.map_error(fn(e) { e.message }) 348 + use _ <- result.try( 349 + sqlight.exec(schema, db) |> result.map_error(fn(e) { e.message }), 350 + ) 351 + use columns <- result.try( 352 + sqlight.query( 353 + "PRAGMA table_info(work)", 354 + db, 355 + [], 356 + decode.field(1, decode.string, decode.success), 357 + ) 358 + |> result.map_error(fn(e) { e.message }), 359 + ) 360 + use _ <- result.try(case list.contains(columns, "cwd") { 361 + True -> Ok(Nil) 362 + False -> 363 + sqlight.exec( 364 + "ALTER TABLE work ADD COLUMN cwd TEXT NOT NULL DEFAULT '__albedo_legacy__'", 365 + db, 366 + ) 367 + |> result.map_error(fn(e) { e.message }) 368 + }) 369 + sqlight.exec("CREATE INDEX IF NOT EXISTS work_cwd_id ON work(cwd,id)", db) 370 + |> result.map_error(fn(e) { e.message }) 296 371 }) 297 372 }
+31
src/albedo/harness/extensions/work/legacy_migration.md
··· 1 + # Recovering legacy work items 2 + 3 + The workspace-scoped ledger keeps rows created by older versions under the reserved 4 + `__albedo_legacy__` scope. No workspace sees these rows automatically: the old 5 + schema did not record which workspace owned them. 6 + 7 + 1. Stop Albedo and back up `$ALBEDO_HOME/albedo.sqlite` before editing it. For 8 + example, use `sqlite3 "$ALBEDO_HOME/albedo.sqlite" ".backup '/safe/path/work.sqlite'"`. 9 + 2. Inspect the old items and their parent relationships: 10 + 11 + ```sql 12 + SELECT id, title, parent FROM work WHERE cwd = '__albedo_legacy__'; 13 + ``` 14 + 15 + 3. Choose an existing workspace's **absolute canonical path**. Move only items 16 + you can attribute to it; move parents and children together. In SQLite, 17 + verify the IDs and the destination before committing: 18 + 19 + ```sql 20 + BEGIN IMMEDIATE; 21 + UPDATE work SET cwd = '/absolute/workspace' WHERE id IN (12, 13); 22 + SELECT id, cwd, parent FROM work WHERE id IN (12, 13); 23 + COMMIT; 24 + ``` 25 + 26 + 4. Restart Albedo and use `/work` in that workspace to verify visibility. 27 + Restore the backup if the mapping was wrong. Leave uncertain rows in the 28 + legacy scope; never assign every old row to every workspace. 29 + 30 + Startup adds the `cwd` column before creating its index on an older database; 31 + restarting alone does not reassign legacy items.
+10 -8
src/albedo/harness/extensions/work/rpc.gleam
··· 6 6 import gleam/option.{None} 7 7 import gleam/result 8 8 9 - pub fn handle(store: work.Store, request: String) -> String { 9 + pub fn handle(store: work.Store, cwd: String, request: String) -> String { 10 10 let decoder = { 11 11 use method <- decode.field("method", decode.string) 12 12 use args <- decode.field("args", decode.dynamic) ··· 17 17 json.parse(request, decoder) 18 18 |> result.replace_error(work.Invalid("invalid host request")), 19 19 ) 20 - dispatch(store, method, args) 20 + dispatch(store, cwd, method, args) 21 21 } 22 22 case answer { 23 23 Ok(value) -> json.object([#("ok", json.bool(True)), #("value", value)]) ··· 46 46 |> result.replace_error(work.Invalid("invalid work arguments")) 47 47 } 48 48 49 - fn dispatch(store, method, args) { 49 + fn dispatch(store, cwd, method, args) { 50 50 case method { 51 51 "work.list" -> { 52 52 let decoder = { ··· 55 55 decode.success(#(after, limit)) 56 56 } 57 57 use #(after, limit) <- result.try(parse(args, decoder)) 58 - work.list(store, after, limit) |> result.map(json.array(_, work.to_json)) 58 + work.list(store, cwd, after, limit) 59 + |> result.map(json.array(_, work.to_json)) 59 60 } 60 61 "work.get" -> { 61 62 use id <- result.try(parse( 62 63 args, 63 64 decode.field("id", decode.int, decode.success), 64 65 )) 65 - work.get(store, id) |> result.map(work.to_json) 66 + work.get(store, cwd, id) |> result.map(work.to_json) 66 67 } 67 68 "work.create" -> { 68 69 let decoder = { ··· 76 77 decode.success(#(title, notes, parent)) 77 78 } 78 79 use #(title, notes, parent) <- result.try(parse(args, decoder)) 79 - work.create(store, title, notes, parent) |> result.map(work.to_json) 80 + work.create(store, cwd, title, notes, parent) |> result.map(work.to_json) 80 81 } 81 82 "work.update" -> { 82 83 use id <- result.try(parse( 83 84 args, 84 85 decode.field("id", decode.int, decode.success), 85 86 )) 86 - use current <- result.try(work.get(store, id)) 87 + use current <- result.try(work.get(store, cwd, id)) 87 88 let decoder = { 88 89 use revision <- decode.field("revision", decode.int) 89 90 use title <- decode.optional_field( ··· 120 121 use status <- result.try(work.parse_status(status)) 121 122 work.update( 122 123 store, 124 + cwd, 123 125 work.Item( 124 126 ..current, 125 127 revision: revision, ··· 139 141 decode.success(#(id, revision)) 140 142 } 141 143 use #(id, revision) <- result.try(parse(args, decoder)) 142 - work.delete(store, id, revision) |> result.map(work.to_json) 144 + work.delete(store, cwd, id, revision) |> result.map(work.to_json) 143 145 } 144 146 _ -> Error(work.Invalid("unknown host operation")) 145 147 }
+18
test/e2e/extensions_test.py
··· 204 204 page = self.command("/work", args={})["result"]["page"] 205 205 self.assertTrue(any(row["text"] == "write the release notes" for row in page["glance"]["rows"])) 206 206 207 + def test_work_command_is_scoped_to_each_workspace(self): 208 + workspaces = [self.app.root / "work-one", self.app.root / "work-two"] 209 + for workspace in workspaces: 210 + workspace.mkdir() 211 + sessions = [self.app.session(workspace) for workspace in workspaces] 212 + 213 + def invoke(session, args): 214 + return self.post(f"/sessions/{session}/commands", {"name": "/work", "args": args}) 215 + 216 + invoke(sessions[0], {"action": "add", "details": "first workspace item"}) 217 + first_page = invoke(sessions[0], {}) 218 + second_page = invoke(sessions[1], {}) 219 + self.assertIn("first workspace item", json.dumps(first_page)) 220 + self.assertNotIn("first workspace item", json.dumps(second_page)) 221 + invoke(sessions[1], {"action": "add", "details": "second workspace item"}) 222 + self.assertNotIn("second workspace item", json.dumps(invoke(sessions[0], {}))) 223 + self.assertIn("second workspace item", json.dumps(invoke(sessions[1], {}))) 224 + 207 225 def test_manual_compaction_keeps_tree_and_reuses_summary(self): 208 226 self.turn("first turn") 209 227 self.turn("second turn")
+33
test/e2e/work_test.py
··· 1 + """Python work routes must isolate ledger reads and writes by session workspace.""" 2 + import json 3 + import unittest 4 + 5 + from harness import Albedo, Provider, python, text 6 + 7 + 8 + class WorkTests(unittest.TestCase): 9 + def test_python_work_is_scoped_to_session_workspace(self): 10 + provider = Provider(lambda request: python( 11 + "import os\n" 12 + "name = os.path.basename(os.getcwd())\n" 13 + "await work.create(title=name)\n" 14 + "print([item['title'] for item in await work.list()])" 15 + ) if request["messages"][-1].get("role") == "user" else text("done")) 16 + self.addCleanup(provider.close) 17 + with Albedo(provider) as app: 18 + outputs = [] 19 + for name in ("work-first", "work-second"): 20 + workspace = app.root / name 21 + workspace.mkdir() 22 + session = app.session(workspace) 23 + app.prompt(session, "use work").close() 24 + app.idle(session) 25 + results = [json.loads(event["result"]) for event in app.events(session) 26 + if event.get("type") == "tool" and event.get("name") == "python"] 27 + self.assertEqual(len(results), 1) 28 + self.assertEqual(results[0]["status"], "ok", results[0]) 29 + outputs.append(results[0]["output"]) 30 + self.assertIn("work-first", outputs[0]) 31 + self.assertNotIn("work-second", outputs[0]) 32 + self.assertIn("work-second", outputs[1]) 33 + self.assertNotIn("work-first", outputs[1])
+81 -4
test/harness/work_test.gleam
··· 1 1 //// Concurrent revision updates must have exactly one winner. 2 2 3 3 import albedo/harness/extensions/work/ledger as work 4 + import gleam/dynamic/decode 4 5 import gleam/erlang/process 5 - import gleam/option.{None} 6 + import gleam/int 7 + import gleam/option.{None, Some} 8 + import sqlight 6 9 7 10 pub fn only_one_concurrent_edit_wins_test() { 8 11 let assert Ok(store) = work.start(":memory:") 9 - let assert Ok(item) = work.create(store, "original", "", None) 12 + let assert Ok(item) = work.create(store, "/cwd", "original", "", None) 10 13 let reply = process.new_subject() 11 14 let _ = 12 15 process.spawn_unlinked(fn() { 13 - process.send(reply, work.update(store, work.Item(..item, title: "human"))) 16 + process.send( 17 + reply, 18 + work.update(store, "/cwd", work.Item(..item, title: "human")), 19 + ) 14 20 }) 15 21 let _ = 16 22 process.spawn_unlinked(fn() { 17 - process.send(reply, work.update(store, work.Item(..item, title: "agent"))) 23 + process.send( 24 + reply, 25 + work.update(store, "/cwd", work.Item(..item, title: "agent")), 26 + ) 18 27 }) 19 28 let assert Ok(a) = process.receive(reply, 5000) 20 29 let assert Ok(b) = process.receive(reply, 5000) ··· 24 33 } 25 34 work.close(store) 26 35 } 36 + 37 + pub fn ledger_items_are_scoped_to_cwd_test() { 38 + let assert Ok(store) = work.start(":memory:") 39 + let assert Ok(first) = work.create(store, "/one", "first", "", None) 40 + let assert Ok(second) = work.create(store, "/two", "second", "", None) 41 + let assert Error(work.NotFound) = work.get(store, "/two", first.id) 42 + let assert Error(work.NotFound) = work.update(store, "/two", first) 43 + let assert Error(work.NotFound) = 44 + work.delete(store, "/two", first.id, first.revision) 45 + let assert Ok([only_first]) = work.list(store, "/one", 0, 10) 46 + let assert True = only_first.id == first.id 47 + let assert Ok([only_second]) = work.list(store, "/two", 0, 10) 48 + let assert True = only_second.id == second.id 49 + let assert Error(work.NotFound) = 50 + work.create(store, "/two", "child", "", Some(first.id)) 51 + let assert Error(work.Invalid(_)) = work.list(store, "", 0, 10) 52 + work.close(store) 53 + } 54 + 55 + @external(erlang, "erlang", "unique_integer") 56 + fn unique_integer() -> Int 57 + 58 + @external(erlang, "albedo_session", "discard") 59 + fn discard(path: String) -> Nil 60 + 61 + pub fn old_schema_migrates_before_cwd_index_test() { 62 + let path = 63 + "/tmp/albedo-work-legacy-" <> int.to_string(unique_integer()) <> ".db" 64 + let assert Ok(db) = sqlight.open(path) 65 + let assert Ok(_) = 66 + sqlight.exec( 67 + "CREATE TABLE work (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open', parent INTEGER, session TEXT, run TEXT, revision INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '')", 68 + db, 69 + ) 70 + let assert Ok(_) = 71 + sqlight.exec("INSERT INTO work(title) VALUES('legacy')", db) 72 + let assert Ok(_) = sqlight.close(db) 73 + let assert Ok(store) = work.start(path) 74 + let assert Ok(_) = work.initialise(store) 75 + work.close(store) 76 + let assert Ok(check) = sqlight.open(path) 77 + let assert Ok([title]) = 78 + sqlight.query( 79 + "SELECT title FROM work", 80 + check, 81 + [], 82 + decode.field(0, decode.string, decode.success), 83 + ) 84 + let assert True = title == "legacy" 85 + let assert Ok([cwd]) = 86 + sqlight.query( 87 + "SELECT cwd FROM work", 88 + check, 89 + [], 90 + decode.field(0, decode.string, decode.success), 91 + ) 92 + let assert True = cwd == "__albedo_legacy__" 93 + let assert Ok([index]) = 94 + sqlight.query( 95 + "SELECT name FROM sqlite_master WHERE type='index' AND name='work_cwd_id'", 96 + check, 97 + [], 98 + decode.field(0, decode.string, decode.success), 99 + ) 100 + let assert True = index == "work_cwd_id" 101 + let assert Ok(_) = sqlight.close(check) 102 + discard(path) 103 + }
+2 -1
test/manual/coding.gleam
··· 58 58 ) 59 59 let start = now() 60 60 let result = run(client, request, host, session, 1, 0) 61 - let ledger = work.list(runtime.ledger(host), 0, 50) |> result.unwrap([]) 61 + let ledger = 62 + work.list(runtime.ledger(host), workspace, 0, 50) |> result.unwrap([]) 62 63 log([ 63 64 #("event", json.string("summary")), 64 65 #("elapsed_ms", json.int(now() - start)),

History

2 rounds 0 comments
Sign up or Login to add to the discussion
2 commits
Expand
Scope work ledger to session workspace
Document safe legacy work-item reassignment
Expand 0 comments
Pull request successfully merged
okami.mom submitted #0
1 commit
Expand
Scope work ledger to session workspace
Expand 0 comments