evals-lab 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lab/server.py CHANGED
@@ -298,23 +298,53 @@ FILE_TYPES = {
298
298
  }
299
299
 
300
300
  # The kinds of Source, mirrored from evals-core.ts's SOURCE_TYPES: Python
301
- # cannot load TypeScript, so the server keeps what it enforces -- the id a
302
- # row may carry and the files a Source of that type takes. A type is a
303
- # registry entry, never a branch on its id.
301
+ # cannot load TypeScript, so the server keeps what it enforces. A kind is a
302
+ # registry entry, never a branch on its id. The row's `type` is the kind; a
303
+ # workflow kind's enforcement comes from the platform its `config.platform`
304
+ # names (WORKFLOW_PLATFORMS below), a kind marked `platforms`.
304
305
  SOURCE_TYPES = {
305
306
  "files": {"label": "File Library", "uploads": FILE_TYPES},
306
- # A Power Automate cloud flow (docs/power-automate.md): no uploads yet --
307
- # its records arrive with a later phase -- and a definition, kept as a
308
- # versioned snapshot beside the row.
309
- #
310
- # `uploads` are the files it takes; `take`, when there is one, checks and
311
- # rewrites each before it is kept; `signIn` is the sign-in it is made with,
312
- # without which the lab cannot make one; `definition` means it keeps one.
313
- "power-automate": {"label": "Power Automate workflow", "definition": True, "signIn": "microsoft",
314
- "uploads": {".json": "application/json; charset=utf-8"}},
307
+ "workflow": {"label": "Workflow", "platforms": True},
315
308
  }
316
309
  DEFAULT_SOURCE_TYPE = "files"
317
310
 
311
+ # The workflow platforms, mirrored from evals-core.ts's WORKFLOW_PLATFORMS: the
312
+ # flow engine a workflow Source speaks to, named in its `config.platform`. A
313
+ # workflow kind's enforcement is the platform's, not the kind's.
314
+ #
315
+ # `uploads` are the files it takes; `take`, when there is one, checks and
316
+ # rewrites each before it is kept; `signIn` is the sign-in it is made with,
317
+ # without which the lab cannot make one; `definition` means it keeps one.
318
+ WORKFLOW_PLATFORMS = {
319
+ "power-automate": {"label": "Power Automate flow", "definition": True, "signIn": "microsoft",
320
+ "uploads": {".json": "application/json; charset=utf-8"}},
321
+ }
322
+
323
+
324
+ def _config_platform(config):
325
+ """The platform a Source's stored config (a JSON string or None) names,
326
+ or None -- what the page labels a workflow by."""
327
+ try:
328
+ cfg = json.loads(config) if config else None
329
+ except ValueError:
330
+ return None
331
+ platform = cfg.get("platform") if isinstance(cfg, dict) else None
332
+ return platform if isinstance(platform, str) else None
333
+
334
+
335
+ def source_entry(ptype, config):
336
+ """The enforcement entry for a Source of type [ptype] and [config] (a
337
+ dict or None): its kind's, or, for a workflow kind, the platform its
338
+ config names. None for an unknown kind, or a workflow whose platform the
339
+ lab does not know. A reader asks the entry, never the id."""
340
+ kind = SOURCE_TYPES.get(ptype)
341
+ if kind is None:
342
+ return None
343
+ if kind.get("platforms"):
344
+ platform = config.get("platform") if isinstance(config, dict) else None
345
+ return WORKFLOW_PLATFORMS.get(platform)
346
+ return kind
347
+
318
348
  # The Entra app the page signs in to Microsoft 365 with (docs/power-automate.md
319
349
  # § Registering the app). A single-page app has no secret, so both are public
320
350
  # and served to the page; the token it gets stays in the browser, and the
@@ -481,7 +511,14 @@ def take_record(name, data):
481
511
  return json.dumps(kept, indent=2).encode("utf-8"), None
482
512
 
483
513
 
484
- SOURCE_TYPES["power-automate"]["take"] = take_record
514
+ WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
515
+
516
+ # The lab's own workflow platforms and wizards, kept apart so the mirror can be
517
+ # rebuilt from them as plugins come and go (Plugins.apply), and so a plugin is
518
+ # refused the id of one the lab ships (#303). Wizards are a page concept; the
519
+ # server holds only their ids, to refuse two plugins claiming one.
520
+ BUILTIN_WORKFLOW_PLATFORMS = {k: dict(v) for k, v in WORKFLOW_PLATFORMS.items()}
521
+ BUILTIN_WIZARD_IDS = {"test-workflow"}
485
522
 
486
523
  # The sign-ins a Source type may be made with, and whether this lab has each:
487
524
  # a type naming one this lab lacks cannot be made.
@@ -602,6 +639,45 @@ def hide_keys(docs: dict) -> dict:
602
639
  return out
603
640
 
604
641
 
642
+ # ---- workspaces (docs/workspaces.md) -------------------------------------
643
+ # A workspace is the lab's first tenant dimension: a row in `workspaces` and a
644
+ # filter on every scopable table. Phase 1 is invisible -- a request that names
645
+ # no workspace resolves to the flagged default, so the existing page and CI
646
+ # keep working. It is isolation, not access control: until multi-user lands, a
647
+ # workspace is a filter, not a permission boundary (AGENTS.md).
648
+ #
649
+ # The workspace the current thread's db work is scoped to is held here, bound
650
+ # per request by the Handler and per run by the queue worker; unset, a scoped
651
+ # read falls back to the store's flagged default, which keeps direct callers
652
+ # (startup, a check's own calls) on the default workspace.
653
+ _WS = threading.local()
654
+
655
+
656
+ def set_workspace(ws):
657
+ """Bind the current thread to workspace `ws` for its db work; None clears
658
+ it, so scoped reads fall back to the store's default workspace."""
659
+ _WS.ws = ws
660
+
661
+
662
+ def workspace_of(store) -> str:
663
+ ws = getattr(_WS, "ws", None)
664
+ return ws if ws else store.default_ws
665
+
666
+
667
+ # The `connection_shares` sentinels (phase 4 fills and enforces the table;
668
+ # phase 1 only creates it empty): every workspace, and future ones.
669
+ SHARE_ALL = "*all"
670
+ SHARE_NEW = "*new"
671
+
672
+ # How the `docs` table scopes by key (docs/workspaces.md): workflows are
673
+ # per-workspace, profiles/tokens global, and promptlab.versions is split --
674
+ # its pipeline/dataset slices per-workspace, its profile slice global, because
675
+ # profiles are global so their Restore must be too. served()/write() route
676
+ # each key by these; any other key is per-workspace.
677
+ DOC_GLOBAL = ("promptlab.profiles", "promptlab.tokens")
678
+ DOC_SPLIT = "promptlab.versions"
679
+
680
+
605
681
  class Store:
606
682
  """
607
683
  Documents by name, each with a version that goes up by one per write.
@@ -618,17 +694,36 @@ class Store:
618
694
  self.path = path
619
695
  self.lock = threading.Lock()
620
696
  with self.lock, closing(sqlite3.connect(path)) as db, db:
621
- db.execute("CREATE TABLE IF NOT EXISTS docs (name TEXT PRIMARY KEY, "
622
- "version INTEGER NOT NULL, body TEXT, updated_at TEXT NOT NULL)")
697
+ # The workspace registry and the share table come first:
698
+ # everything else is scoped to a workspace, and the flagged default
699
+ # must exist before any backfill can assign rows to it.
700
+ db.execute("CREATE TABLE IF NOT EXISTS workspaces ("
701
+ "id TEXT PRIMARY KEY, slug TEXT UNIQUE, name TEXT NOT NULL, "
702
+ "archived INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0, "
703
+ "last_used TEXT, created_at TEXT, trash TEXT, trashed_at REAL)")
704
+ # Shared-by-choice connections (profiles, Accounts): phase 4 fills
705
+ # and enforces this; phase 1 only creates it empty.
706
+ db.execute("CREATE TABLE IF NOT EXISTS connection_shares ("
707
+ "kind TEXT NOT NULL, id TEXT NOT NULL, workspace TEXT NOT NULL, "
708
+ "PRIMARY KEY (kind, id, workspace))")
709
+ self.default_ws = self._ensure_default(db)
623
710
  db.execute("CREATE TABLE IF NOT EXISTS runs (at TEXT PRIMARY KEY, body TEXT NOT NULL)")
624
711
  # The key of a profile a write took out, by id, for TRASH_SECONDS:
625
712
  # Undo puts the profile back holding KEY_HELD, and this is what
626
- # it holds.
713
+ # it holds. Profiles are global, so the ring is too.
627
714
  db.execute("CREATE TABLE IF NOT EXISTS dropped_keys (id TEXT PRIMARY KEY, "
628
715
  "key TEXT NOT NULL, at REAL NOT NULL)")
716
+ # The docs table keys documents by (name, workspace): a store from
717
+ # before workspaces keyed them by name alone and is rebuilt once,
718
+ # the global keys moved under NULL and promptlab.versions split.
719
+ # Idempotent: a later open finds the workspace column and leaves it.
720
+ cols = {r[1] for r in db.execute("PRAGMA table_info(docs)")}
721
+ if not cols:
722
+ self._create_docs(db)
629
723
  # History was a document, capped at what a browser could hold. The
630
724
  # first start with the table moves what that document had into it,
631
- # once, and drops the document so the page stops carrying it.
725
+ # once, and drops the document so the page stops carrying it. It
726
+ # reads name/body, so it runs on either docs schema.
632
727
  old = db.execute("SELECT body FROM docs WHERE name = 'promptlab.runs'").fetchone()
633
728
  if old and not db.execute("SELECT 1 FROM runs LIMIT 1").fetchone():
634
729
  for run in json.loads(old[0] or "null") or []:
@@ -636,20 +731,118 @@ class Store:
636
731
  db.execute("INSERT OR IGNORE INTO runs (at, body) VALUES (?, ?)",
637
732
  (run["at"], json.dumps(run)))
638
733
  db.execute("DELETE FROM docs WHERE name = 'promptlab.runs'")
734
+ # The run history is the queue table now; this `runs` table holds
735
+ # only what the pre-queue history document migrated into it, and is
736
+ # served from nowhere. It carries the workspace column all the same,
737
+ # so the scopable-table set is whole and anything that ever reads it
738
+ # inherits the filter (docs/workspaces.md).
739
+ if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(runs)")}:
740
+ db.execute("ALTER TABLE runs ADD COLUMN workspace TEXT")
741
+ db.execute("UPDATE runs SET workspace = ? WHERE workspace IS NULL", (self.default_ws,))
742
+ if cols and "workspace" not in cols:
743
+ self._rebuild_docs(db)
744
+
745
+ def _ensure_default(self, db) -> str:
746
+ """The flagged default workspace's id, seeding "Default" (/w/default,
747
+ is_default = 1) on a store that has none. Defined by the flag, not its
748
+ name or slug, so a later rename never moves it (docs/workspaces.md)."""
749
+ row = db.execute("SELECT id FROM workspaces WHERE is_default = 1").fetchone()
750
+ if row:
751
+ return row[0]
752
+ wid = secrets.token_hex(6)
753
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
754
+ db.execute("INSERT INTO workspaces (id, slug, name, archived, is_default, last_used, created_at) "
755
+ "VALUES (?, 'default', 'Default', 0, 1, ?, ?)", (wid, now, now))
756
+ return wid
757
+
758
+ def workspace(self, slug: str) -> str:
759
+ """The id of the workspace at /w/<slug>, or None. An unknown slug is
760
+ None; the Handler falls back to the default so a stale address still
761
+ reaches a working lab until the page's Gone state lands (phase 3)."""
762
+ with self.lock, closing(sqlite3.connect(self.path)) as db:
763
+ row = db.execute("SELECT id FROM workspaces WHERE slug = ?", (slug,)).fetchone()
764
+ return row[0] if row else None
639
765
 
640
- def _rows(self, db):
641
- return {name: {"version": version, "body": None if body is None else json.loads(body)}
642
- for name, version, body in db.execute("SELECT name, version, body FROM docs")}
766
+ @staticmethod
767
+ def _create_docs(db):
768
+ db.execute("CREATE TABLE docs (name TEXT NOT NULL, workspace TEXT, "
769
+ "version INTEGER NOT NULL, body TEXT, updated_at TEXT NOT NULL)")
770
+ # NULLs are distinct in a UNIQUE index, so the global keys cannot rely
771
+ # on one PRIMARY KEY for their one-row-per-name rule: two partial
772
+ # indexes, scoped rows keyed by (name, workspace) and global rows by
773
+ # name, give each its own uniqueness and its own upsert target.
774
+ db.execute("CREATE UNIQUE INDEX docs_scoped ON docs(name, workspace) WHERE workspace IS NOT NULL")
775
+ db.execute("CREATE UNIQUE INDEX docs_global ON docs(name) WHERE workspace IS NULL")
776
+
777
+ def _rebuild_docs(self, db):
778
+ rows = db.execute("SELECT name, version, body, updated_at FROM docs").fetchall()
779
+ db.execute("ALTER TABLE docs RENAME TO docs_old")
780
+ self._create_docs(db)
781
+ put = lambda name, ws, version, body, at: db.execute(
782
+ "INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, ?, ?, ?, ?)",
783
+ (name, ws, version, body, at))
784
+ for name, version, body, at in rows:
785
+ if name in DOC_GLOBAL:
786
+ put(name, None, version, body, at)
787
+ elif name == DOC_SPLIT:
788
+ parsed = json.loads(body) if body else {}
789
+ put(name, self.default_ws, version, None if body is None else json.dumps(
790
+ {"pipeline": parsed.get("pipeline", {}), "dataset": parsed.get("dataset", {})}), at)
791
+ put(name, None, version, None if body is None else json.dumps(
792
+ {"profile": parsed.get("profile", {})}), at)
793
+ else:
794
+ put(name, self.default_ws, version, body, at)
795
+ db.execute("DROP TABLE docs_old")
796
+
797
+ def _doc_rows(self, db, ws: str) -> dict:
798
+ """The logical document set a workspace reads: its scoped rows, the
799
+ global rows (profiles, tokens), and promptlab.versions reassembled from
800
+ its per-workspace pipeline/dataset slice and the global profile slice,
801
+ carrying the per-workspace slice's version so the page's one version
802
+ per key still arbitrates pipeline/dataset Restore."""
803
+ parse = lambda b: None if b is None else json.loads(b)
804
+ scoped = {name: (version, body) for name, version, body in db.execute(
805
+ "SELECT name, version, body FROM docs WHERE workspace = ?", (ws,))}
806
+ glob = {name: (version, body) for name, version, body in db.execute(
807
+ "SELECT name, version, body FROM docs WHERE workspace IS NULL")}
808
+ out = {name: {"version": v, "body": parse(b)}
809
+ for name, (v, b) in scoped.items() if name != DOC_SPLIT}
810
+ for name in DOC_GLOBAL:
811
+ if name in glob:
812
+ v, b = glob[name]
813
+ out[name] = {"version": v, "body": parse(b)}
814
+ sv, gv = scoped.get(DOC_SPLIT), glob.get(DOC_SPLIT)
815
+ if sv is not None or gv is not None:
816
+ sver, sbody = sv if sv is not None else (0, None)
817
+ sbody, gbody = parse(sbody) or {}, parse(gv[1]) if gv is not None else {}
818
+ out[DOC_SPLIT] = {"version": sver, "body": {
819
+ "pipeline": sbody.get("pipeline", {}), "dataset": sbody.get("dataset", {}),
820
+ "profile": (gbody or {}).get("profile", {})}}
821
+ return out
643
822
 
644
- def all(self) -> dict:
823
+ def all(self, ws=None) -> dict:
645
824
  with self.lock, closing(sqlite3.connect(self.path)) as db:
646
- return self._rows(db)
647
-
648
- def served(self) -> dict:
649
- """What a browser is handed: the SYNCED documents only, so a retired
650
- key's rows stay in the store without reaching a page again, and no
651
- profile's key (KEY_HELD)."""
652
- return hide_keys({n: d for n, d in self.all().items() if n in SYNCED})
825
+ return self._doc_rows(db, ws or workspace_of(self))
826
+
827
+ def served(self, ws=None) -> dict:
828
+ """What a browser is handed for its workspace: the SYNCED documents
829
+ only, so a retired key's rows stay in the store without reaching a page
830
+ again, and no profile's key (KEY_HELD)."""
831
+ return hide_keys({n: d for n, d in self.all(ws).items() if n in SYNCED})
832
+
833
+ def _put(self, db, name, ws, version, body, at):
834
+ text = None if body is None else json.dumps(body)
835
+ if ws is None:
836
+ db.execute(
837
+ "INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, NULL, ?, ?, ?) "
838
+ "ON CONFLICT(name) WHERE workspace IS NULL DO UPDATE SET version = excluded.version, "
839
+ "body = excluded.body, updated_at = excluded.updated_at", (name, version, text, at))
840
+ else:
841
+ db.execute(
842
+ "INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, ?, ?, ?, ?) "
843
+ "ON CONFLICT(name, workspace) WHERE workspace IS NOT NULL DO UPDATE SET "
844
+ "version = excluded.version, body = excluded.body, updated_at = excluded.updated_at",
845
+ (name, ws, version, text, at))
653
846
 
654
847
  def _keyring(self, db, now: dict) -> dict:
655
848
  """Each profile id's key as the store holds it: its profile's, else a
@@ -668,18 +861,24 @@ class Store:
668
861
  return ring
669
862
 
670
863
  def held_key(self, key: str) -> str:
671
- """The key KEY_HELD stands for, or "" when the store holds none."""
864
+ """The key KEY_HELD stands for, or "" when the store holds none.
865
+ Profiles are global, so the ring is read under the default workspace."""
672
866
  with self.lock, closing(sqlite3.connect(self.path)) as db, db:
673
- return self._keyring(db, self._rows(db)).get(key[len(KEY_HELD):], "")
867
+ return self._keyring(db, self._doc_rows(db, self.default_ws)).get(key[len(KEY_HELD):], "")
674
868
 
675
- def write(self, docs: dict):
869
+ def write(self, docs: dict, ws=None):
676
870
  """
677
- `docs` is {name: {"version": the version it began from, "body": ...}}.
678
- Returns ({name: new version}, None), or (None, {name: current}) for
679
- every document that has moved on, with nothing written.
871
+ `docs` is {name: {"version": the version it began from, "body": ...}},
872
+ written into workspace `ws` (the current thread's, by default). Each
873
+ key is routed by scope: workflows and the rest per-workspace, profiles
874
+ and tokens global, promptlab.versions split (pipeline/dataset
875
+ per-workspace, profile global). Returns ({name: new version}, None), or
876
+ (None, {name: current}) for every document that has moved on, with
877
+ nothing written.
680
878
  """
879
+ ws = ws or workspace_of(self)
681
880
  with self.lock, closing(sqlite3.connect(self.path)) as db:
682
- now = self._rows(db)
881
+ now = self._doc_rows(db, ws)
683
882
  have = lambda n: now.get(n, {"version": 0, "body": None})
684
883
  stale = {n: have(n) for n, d in docs.items() if d["version"] != have(n)["version"]}
685
884
  if stale:
@@ -701,13 +900,372 @@ class Store:
701
900
  for p in profiles_in("promptlab.profiles", have("promptlab.profiles")["body"])
702
901
  if str(p.get("id") or "") not in kept and isinstance(p.get("key"), str) and p["key"]])
703
902
  for n, d in docs.items():
704
- db.execute(
705
- "INSERT INTO docs (name, version, body, updated_at) VALUES (?, ?, ?, ?) "
706
- "ON CONFLICT(name) DO UPDATE SET version = excluded.version, "
707
- "body = excluded.body, updated_at = excluded.updated_at",
708
- (n, have(n)["version"] + 1, None if d["body"] is None else json.dumps(d["body"]), at))
903
+ version, body = have(n)["version"] + 1, d["body"]
904
+ if n == DOC_SPLIT:
905
+ parsed = body if isinstance(body, dict) else {}
906
+ self._put(db, n, ws, version, None if body is None else {
907
+ "pipeline": parsed.get("pipeline", {}), "dataset": parsed.get("dataset", {})}, at)
908
+ # The profile slice is global, on its own version
909
+ # counter: profiles are shared, so their Restore is too.
910
+ gv = db.execute("SELECT version FROM docs WHERE name = ? AND workspace IS NULL",
911
+ (n,)).fetchone()
912
+ self._put(db, n, None, (gv[0] if gv else 0) + 1,
913
+ None if body is None else {"profile": parsed.get("profile", {})}, at)
914
+ else:
915
+ self._put(db, n, None if n in DOC_GLOBAL else ws, version, body, at)
709
916
  return {n: have(n)["version"] + 1 for n in docs}, None
710
917
 
918
+ # ---- shared-by-choice connections (docs/workspaces.md) ------------------
919
+ # A Target profile or an Account (kind "profile"/"account", id the
920
+ # profile's or the account's) is usable in a workspace by its share set in
921
+ # `connection_shares`: a row for that workspace, or the SHARE_ALL sentinel.
922
+ # No row at all is the migration and new-connection default -- shared with
923
+ # every workspace, so nothing stops running the moment workspaces exist
924
+ # (open question 2, resolved All); narrowing a connection is adding the
925
+ # rows that say where it may be used. SHARE_NEW is never read here: a
926
+ # workspace created after a SHARE_NEW share was set has it materialised into
927
+ # a concrete row at creation (Workspaces.create), so "New workspaces" is
928
+ # exactly the future ones and not the ones that already existed. The server
929
+ # is the only writer and the only enforcer -- the key a share gates is
930
+ # never served (#258), so neither the page nor the worker could enforce it.
931
+
932
+ def shared(self, kind: str, cid: str, ws) -> bool:
933
+ with self.lock, closing(sqlite3.connect(self.path)) as db:
934
+ rows = {r[0] for r in db.execute(
935
+ "SELECT workspace FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))}
936
+ return not rows or SHARE_ALL in rows or ws in rows
937
+
938
+ def shares(self) -> dict:
939
+ """Every connection's share set, for the Connections menus: keyed by
940
+ "<kind>:<id>", the workspace values as stored (workspace ids and the
941
+ sentinels). A connection with no row is absent, which the page reads as
942
+ shared with All. No key is anywhere in this (#258)."""
943
+ out: dict = {}
944
+ with self.lock, closing(sqlite3.connect(self.path)) as db:
945
+ for kind, cid, ws in db.execute("SELECT kind, id, workspace FROM connection_shares"):
946
+ out.setdefault(f"{kind}:{cid}", []).append(ws)
947
+ return out
948
+
949
+ def set_shares(self, kind: str, cid: str, workspaces) -> None:
950
+ """Replace a connection's share set. SHARE_ALL means every workspace,
951
+ so it is kept alone; only the sentinels and live workspace ids are let
952
+ in, so a stale id cannot linger in the table."""
953
+ with self.lock, closing(sqlite3.connect(self.path)) as db, db:
954
+ valid = {r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NULL")}
955
+ chosen = [w for w in dict.fromkeys(workspaces or [])
956
+ if w in (SHARE_ALL, SHARE_NEW) or w in valid]
957
+ if SHARE_ALL in chosen:
958
+ chosen = [SHARE_ALL]
959
+ db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))
960
+ db.executemany("INSERT INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
961
+ [(kind, cid, w) for w in chosen])
962
+
963
+ def share_with(self, kind: str, cid: str, ws, on: bool) -> None:
964
+ """Add or remove one workspace from a connection's share set -- the Run
965
+ bar's Share link and its Undo (docs/workspaces.md). The link is offered
966
+ only where the connection is actually narrowed, so adding a row is what
967
+ grants the blocked workspace its use and Undo takes it back."""
968
+ with self.lock, closing(sqlite3.connect(self.path)) as db, db:
969
+ if on:
970
+ db.execute("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) "
971
+ "VALUES (?, ?, ?)", (kind, cid, ws))
972
+ else:
973
+ db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
974
+ (kind, cid, ws))
975
+
976
+ def workspace_name(self, ws) -> str:
977
+ """A workspace's name, for the refusal sentence the Run bar shows."""
978
+ with self.lock, closing(sqlite3.connect(self.path)) as db:
979
+ row = db.execute("SELECT name FROM workspaces WHERE id = ?", (ws,)).fetchone()
980
+ return row[0] if row else "this workspace"
981
+
982
+ def profile_name(self, pid: str) -> str:
983
+ """A Target profile's display name, by id, from the global profiles
984
+ document -- for the refusal sentence. The id itself when none is held."""
985
+ with self.lock, closing(sqlite3.connect(self.path)) as db:
986
+ row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.profiles' "
987
+ "AND workspace IS NULL").fetchone()
988
+ try:
989
+ for p in (json.loads(row[0]) or {}).get("list", []) if row and row[0] else []:
990
+ if isinstance(p, dict) and str(p.get("id") or "") == pid:
991
+ return p.get("name") or pid
992
+ except (ValueError, TypeError):
993
+ pass
994
+ return pid
995
+
996
+
997
+ # A workspace's name is one line, capped like a dataset's or a prompt's.
998
+ WORKSPACE_NAME_MAX = 80
999
+
1000
+
1001
+ class Workspaces:
1002
+ """
1003
+ The workspace registry (docs/workspaces.md): the lab-wide list the
1004
+ Setup › Workspaces table manages. Phase 2 is the registry and its UI; the
1005
+ active workspace stays the flagged default (switching is phase 3), so a
1006
+ workspace created here is reached by its address only once that lands.
1007
+
1008
+ Create, rename, archive, unarchive, Regenerate (a new slug from the name),
1009
+ and a server trash like the others: delete puts a workspace into the trash
1010
+ with an undo window, restore brings it back, and a lazy sweep purges it
1011
+ after TRASH_SECONDS -- taking its scoped data with it (its pipelines, runs,
1012
+ datasets, prompts, Sources, packs and documents), since that is what a
1013
+ permanent delete means. The child tables co-scope through their parent id.
1014
+
1015
+ The slug is kept through a rename; only Regenerate changes it. The last
1016
+ active workspace cannot be archived, so a flag-holder always exists;
1017
+ archiving or deleting the flagged default moves the flag to the most
1018
+ recently used other active workspace (docs/workspaces.md, decision 7).
1019
+ """
1020
+
1021
+ def __init__(self, store: Store):
1022
+ self.store = store
1023
+ self.dir = store.path.parent
1024
+
1025
+ def _connect(self):
1026
+ return closing(sqlite3.connect(self.store.path))
1027
+
1028
+ @staticmethod
1029
+ def _slugify(raw) -> str:
1030
+ s = re.sub(r"[^a-z0-9]+", "-", str(raw or "").lower()).strip("-")[:SLUG_MAX].strip("-")
1031
+ return s or "workspace"
1032
+
1033
+ @staticmethod
1034
+ def _unique(base: str, taken: set) -> str:
1035
+ if base not in taken:
1036
+ return base
1037
+ n = 2
1038
+ while True:
1039
+ suffix = f"-{n}"
1040
+ cand = (base[:SLUG_MAX - len(suffix)].strip("-") or "workspace") + suffix
1041
+ if cand not in taken:
1042
+ return cand
1043
+ n += 1
1044
+
1045
+ @staticmethod
1046
+ def _row(r, pipes, runs) -> dict:
1047
+ return {"id": r[0], "slug": r[1], "name": r[2], "archived": bool(r[3]),
1048
+ "isDefault": bool(r[4]), "lastUsed": r[5], "createdAt": r[6],
1049
+ "pipelines": pipes.get(r[0], 0), "runs": runs.get(r[0], 0)}
1050
+
1051
+ def list(self) -> list:
1052
+ """Every workspace not in the trash, with how many pipelines and runs
1053
+ it holds -- the two counts the Manage table shows (docs/other-tabs.md).
1054
+ Active first, then archived, each by name."""
1055
+ with self.store.lock, self._connect() as db:
1056
+ rows = db.execute(
1057
+ "SELECT id, slug, name, archived, is_default, last_used, created_at "
1058
+ "FROM workspaces WHERE trash IS NULL "
1059
+ "ORDER BY archived, name COLLATE NOCASE").fetchall()
1060
+ runs = {w: n for w, n in db.execute(
1061
+ "SELECT workspace, COUNT(*) FROM queue GROUP BY workspace")}
1062
+ pipes = {}
1063
+ for w, body in db.execute(
1064
+ "SELECT workspace, body FROM docs WHERE name = 'promptlab.workflows'"):
1065
+ try:
1066
+ pipes[w] = len((json.loads(body) or {}).get("list", [])) if body else 0
1067
+ except (ValueError, TypeError):
1068
+ pipes[w] = 0
1069
+ return [self._row(r, pipes, runs) for r in rows]
1070
+
1071
+ def get(self, wid: str) -> dict:
1072
+ """One workspace by id, with its counts, or None."""
1073
+ return next((w for w in self.list() if w["id"] == wid), None)
1074
+
1075
+ def create(self, name, slug=None, shares=None) -> tuple:
1076
+ """A new, active workspace; its slug is minted from the name (or a slug
1077
+ asked for), unique across every workspace including the trash, since
1078
+ the column is unique. Returns (id, None) or (None, error). The demo
1079
+ pack is the Handler's to install, since that reaches Packs.
1080
+
1081
+ Shared-by-choice connections (docs/workspaces.md): `shares` is the
1082
+ New-workspace dialog's Connections picker -- a list of {kind, id} the
1083
+ workspace may use, each written as a concrete row. With none given
1084
+ (the plain Add workspace, the CLI), every connection shared with New
1085
+ workspaces (SHARE_NEW) is materialised into a concrete row instead, so
1086
+ "New workspaces" resolves for this one though it was created after the
1087
+ share was set."""
1088
+ name = (name or "").strip()
1089
+ if not name:
1090
+ return None, (400, "a workspace needs a name")
1091
+ if len(name) > WORKSPACE_NAME_MAX:
1092
+ return None, (400, f"a name is at most {WORKSPACE_NAME_MAX} characters")
1093
+ wid = secrets.token_hex(6)
1094
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
1095
+ with self.store.lock, self._connect() as db, db:
1096
+ taken = {r[0] for r in db.execute("SELECT slug FROM workspaces")}
1097
+ s = self._unique(self._slugify(slug or name), taken)
1098
+ db.execute("INSERT INTO workspaces (id, slug, name, archived, is_default, last_used, created_at) "
1099
+ "VALUES (?, ?, ?, 0, 0, ?, ?)", (wid, s, name, now, now))
1100
+ grant = ([(c.get("kind"), c.get("id")) for c in shares if isinstance(c, dict)]
1101
+ if isinstance(shares, list)
1102
+ else list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?",
1103
+ (SHARE_NEW,))))
1104
+ db.executemany("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
1105
+ [(k, i, wid) for k, i in grant if k and i])
1106
+ return wid, None
1107
+
1108
+ def rename(self, wid, name) -> tuple:
1109
+ """A new name; the slug is kept, so an old bookmark still resolves
1110
+ (docs/workspaces.md). Returns (id, None) or (None, error)."""
1111
+ name = (name or "").strip()
1112
+ if not name:
1113
+ return None, (400, "a workspace needs a name")
1114
+ if len(name) > WORKSPACE_NAME_MAX:
1115
+ return None, (400, f"a name is at most {WORKSPACE_NAME_MAX} characters")
1116
+ with self.store.lock, self._connect() as db, db:
1117
+ if db.execute("SELECT 1 FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone() is None:
1118
+ return None, (404, "no such workspace")
1119
+ db.execute("UPDATE workspaces SET name = ? WHERE id = ?", (name, wid))
1120
+ return wid, None
1121
+
1122
+ def regenerate(self, wid) -> tuple:
1123
+ """A new slug minted from the current name -- the one write that
1124
+ changes an address (docs/workspaces.md). It does not redirect: that is
1125
+ the warning the dialog carries. Returns (id, None) or (None, error)."""
1126
+ with self.store.lock, self._connect() as db, db:
1127
+ row = db.execute("SELECT name FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone()
1128
+ if row is None:
1129
+ return None, (404, "no such workspace")
1130
+ taken = {r[0] for r in db.execute("SELECT slug FROM workspaces WHERE id != ?", (wid,))}
1131
+ db.execute("UPDATE workspaces SET slug = ? WHERE id = ?",
1132
+ (self._unique(self._slugify(row[0]), taken), wid))
1133
+ return wid, None
1134
+
1135
+ def archive(self, wid) -> tuple:
1136
+ """Into the archive, at once; Undo unarchives it. The last active
1137
+ workspace cannot be archived; archiving the flagged default moves the
1138
+ flag to the most recently used other active workspace, keeping the
1139
+ server's cached default in step. Returns (id, None) or (None, error)."""
1140
+ with self.store.lock, self._connect() as db, db:
1141
+ row = db.execute("SELECT archived, is_default FROM workspaces WHERE id = ? AND trash IS NULL",
1142
+ (wid,)).fetchone()
1143
+ if row is None:
1144
+ return None, (404, "no such workspace")
1145
+ if row[0]:
1146
+ return wid, None
1147
+ others = db.execute(
1148
+ "SELECT id FROM workspaces WHERE archived = 0 AND trash IS NULL AND id != ? "
1149
+ "ORDER BY last_used DESC, created_at DESC", (wid,)).fetchall()
1150
+ if not others:
1151
+ return None, (409, "the last active workspace cannot be archived")
1152
+ db.execute("UPDATE workspaces SET archived = 1 WHERE id = ?", (wid,))
1153
+ if row[1]:
1154
+ db.execute("UPDATE workspaces SET is_default = 0 WHERE id = ?", (wid,))
1155
+ db.execute("UPDATE workspaces SET is_default = 1 WHERE id = ?", (others[0][0],))
1156
+ self.store.default_ws = others[0][0]
1157
+ return wid, None
1158
+
1159
+ def set_default(self, wid) -> tuple:
1160
+ """Make [wid] the flagged default -- the workspace the CLI and an
1161
+ address with no /w/ prefix resolve to (docs/workspaces.md). Only an
1162
+ active workspace can be the default. Returns (id, None) or
1163
+ (None, error)."""
1164
+ with self.store.lock, self._connect() as db, db:
1165
+ row = db.execute("SELECT archived FROM workspaces WHERE id = ? AND trash IS NULL",
1166
+ (wid,)).fetchone()
1167
+ if row is None:
1168
+ return None, (404, "no such workspace")
1169
+ if row[0]:
1170
+ return None, (409, "an archived workspace cannot be the default")
1171
+ db.execute("UPDATE workspaces SET is_default = 0 WHERE is_default = 1")
1172
+ db.execute("UPDATE workspaces SET is_default = 1 WHERE id = ?", (wid,))
1173
+ self.store.default_ws = wid
1174
+ return wid, None
1175
+
1176
+ def unarchive(self, wid) -> tuple:
1177
+ with self.store.lock, self._connect() as db, db:
1178
+ if db.execute("SELECT 1 FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone() is None:
1179
+ return None, (404, "no such workspace")
1180
+ db.execute("UPDATE workspaces SET archived = 0 WHERE id = ?", (wid,))
1181
+ return wid, None
1182
+
1183
+ def remove(self, wid) -> tuple:
1184
+ """Into the trash, at once; Undo restores it. An active workspace is
1185
+ archived first (the Manage menu offers Delete only on an archived one),
1186
+ so a trashed workspace is never the flagged default. Returns
1187
+ (token, None) or (None, error)."""
1188
+ token = secrets.token_hex(6)
1189
+ with self.store.lock, self._connect() as db, db:
1190
+ row = db.execute("SELECT archived FROM workspaces WHERE id = ? AND trash IS NULL",
1191
+ (wid,)).fetchone()
1192
+ if row is None:
1193
+ return None, (404, "no such workspace")
1194
+ if not row[0]:
1195
+ return None, (409, "archive a workspace before deleting it")
1196
+ db.execute("UPDATE workspaces SET trash = ?, trashed_at = ? WHERE id = ?",
1197
+ (token, time.time(), wid))
1198
+ return token, None
1199
+
1200
+ def restore(self, token) -> tuple:
1201
+ """A trashed workspace back, archived as it was. Returns (id, None) or
1202
+ (None, error)."""
1203
+ with self.store.lock, self._connect() as db, db:
1204
+ row = db.execute("SELECT id FROM workspaces WHERE trash = ?", (str(token),)).fetchone()
1205
+ if row is None:
1206
+ return None, (404, "no such trash entry")
1207
+ db.execute("UPDATE workspaces SET trash = NULL, trashed_at = NULL WHERE id = ?", (row[0],))
1208
+ return row[0], None
1209
+
1210
+ def _purge(self, db, ids) -> tuple:
1211
+ """Delete each workspace in `ids` and all its scoped data -- the rows
1212
+ across every scopable table and its documents -- returning the Source
1213
+ and run ids whose on-disk directories the caller then removes. The
1214
+ child tables co-scope through their parent id (docs/workspaces.md)."""
1215
+ sids, rids = [], []
1216
+ for ws in ids:
1217
+ sids += [r[0] for r in db.execute(
1218
+ "SELECT id FROM sources WHERE workspace = ? AND system = 0", (ws,))]
1219
+ rids += [r[0] for r in db.execute("SELECT id FROM queue WHERE workspace = ?", (ws,))]
1220
+ db.execute("DELETE FROM source_files WHERE source IN "
1221
+ "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1222
+ db.execute("DELETE FROM source_definitions WHERE source IN "
1223
+ "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1224
+ db.execute("DELETE FROM sources WHERE workspace = ? AND system = 0", (ws,))
1225
+ db.execute("DELETE FROM eval_group_versions WHERE group_id IN "
1226
+ "(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
1227
+ db.execute("DELETE FROM dataset_rules_archive WHERE dataset_id IN "
1228
+ "(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
1229
+ db.execute("DELETE FROM dataset_body_archive WHERE dataset_id IN "
1230
+ "(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
1231
+ db.execute("DELETE FROM datasets WHERE workspace = ?", (ws,))
1232
+ db.execute("DELETE FROM prompt_uses WHERE prompt_id IN "
1233
+ "(SELECT id FROM prompts WHERE workspace = ?)", (ws,))
1234
+ db.execute("DELETE FROM prompt_versions WHERE prompt_id IN "
1235
+ "(SELECT id FROM prompts WHERE workspace = ?)", (ws,))
1236
+ db.execute("DELETE FROM prompts WHERE workspace = ?", (ws,))
1237
+ db.execute("DELETE FROM pack_items WHERE workspace = ?", (ws,))
1238
+ db.execute("DELETE FROM packs WHERE workspace = ?", (ws,))
1239
+ db.execute("DELETE FROM queue WHERE workspace = ?", (ws,))
1240
+ db.execute("DELETE FROM runs WHERE workspace = ?", (ws,))
1241
+ db.execute("DELETE FROM docs WHERE workspace = ?", (ws,))
1242
+ db.execute("DELETE FROM connection_shares WHERE workspace = ?", (ws,))
1243
+ db.execute("DELETE FROM workspaces WHERE id = ?", (ws,))
1244
+ return sids, rids
1245
+
1246
+ def _rmdirs(self, sids, rids):
1247
+ for sid in sids:
1248
+ shutil.rmtree(self.dir / "sources" / sid, ignore_errors=True)
1249
+ for rid in rids:
1250
+ shutil.rmtree(self.dir / "runs" / rid, ignore_errors=True)
1251
+
1252
+ def lazy_trash(self):
1253
+ """A /api/workspaces request purges what has been trashed longer than
1254
+ TRASH_SECONDS, lazily as the other trashes do."""
1255
+ with self.store.lock, self._connect() as db, db:
1256
+ gone = [r[0] for r in db.execute(
1257
+ "SELECT id FROM workspaces WHERE trash IS NOT NULL AND trashed_at < ?",
1258
+ (time.time() - TRASH_SECONDS,))]
1259
+ sids, rids = self._purge(db, gone)
1260
+ self._rmdirs(sids, rids)
1261
+
1262
+ def empty_trash(self):
1263
+ """The startup sweep: a restart has nothing to undo."""
1264
+ with self.store.lock, self._connect() as db, db:
1265
+ gone = [r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NOT NULL")]
1266
+ sids, rids = self._purge(db, gone)
1267
+ self._rmdirs(sids, rids)
1268
+
711
1269
 
712
1270
  # A filename, the target filesystem's view rather than the caller's: a
713
1271
  # basename is all that survives, restricted to characters that filesystem
@@ -781,6 +1339,34 @@ class Sources:
781
1339
  f"DEFAULT '{DEFAULT_SOURCE_TYPE}'")
782
1340
  if "config" not in have:
783
1341
  db.execute("ALTER TABLE sources ADD COLUMN config TEXT")
1342
+ # The workspace a Source belongs to (docs/workspaces.md). A store
1343
+ # from before workspaces gains the column; every user Source with
1344
+ # none -- a pre-workspace row, or one left unscoped by any edge --
1345
+ # backfills to the default on open, idempotently, while the system
1346
+ # samples row stays workspace-agnostic (NULL) so it shows in every
1347
+ # workspace.
1348
+ if "workspace" not in have:
1349
+ db.execute("ALTER TABLE sources ADD COLUMN workspace TEXT")
1350
+ db.execute("UPDATE sources SET workspace = ? WHERE workspace IS NULL AND system = 0",
1351
+ (store.default_ws,))
1352
+ # A Power Automate Source was its own kind once (type
1353
+ # "power-automate", docs/power-automate.md); it is now the generic
1354
+ # workflow kind, the platform named in its config
1355
+ # (docs/sources-tab.md). Converted here, once and idempotently: a
1356
+ # later open finds none left. remove()/restore_trash keep a row's
1357
+ # type and config, so Undo brings a converted Source back exactly,
1358
+ # still speaking to Power Automate.
1359
+ for sid, config in db.execute(
1360
+ "SELECT id, config FROM sources WHERE type = 'power-automate'").fetchall():
1361
+ try:
1362
+ cfg = json.loads(config) if config else {}
1363
+ except ValueError:
1364
+ cfg = {}
1365
+ if not isinstance(cfg, dict):
1366
+ cfg = {}
1367
+ cfg["platform"] = "power-automate"
1368
+ db.execute("UPDATE sources SET type = 'workflow', config = ? WHERE id = ?",
1369
+ (json.dumps(cfg), sid))
784
1370
  # A flow's definition, one row per version: a snapshot the page
785
1371
  # took and redacted, redacted again here.
786
1372
  db.execute("CREATE TABLE IF NOT EXISTS source_definitions ("
@@ -809,8 +1395,9 @@ class Sources:
809
1395
  out = []
810
1396
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
811
1397
  db.row_factory = sqlite3.Row
812
- for r in db.execute("SELECT id, name, system, bytes, type, created_at FROM sources "
813
- "ORDER BY system DESC, name COLLATE NOCASE"):
1398
+ for r in db.execute("SELECT id, name, system, bytes, type, config, created_at FROM sources "
1399
+ "WHERE workspace = ? OR system = 1 "
1400
+ "ORDER BY system DESC, name COLLATE NOCASE", (workspace_of(self.store),)):
814
1401
  if r["system"]:
815
1402
  files = self._sample_files()
816
1403
  out.append({"id": r["id"], "name": r["name"], "system": True,
@@ -821,17 +1408,24 @@ class Sources:
821
1408
  (r["id"],)).fetchone()
822
1409
  # When it last changed: made, or a file added -- what a
823
1410
  # picker orders its recent Sources by.
824
- out.append({"id": r["id"], "name": r["name"], "system": False,
825
- "type": r["type"], "files": n, "bytes": r["bytes"],
826
- "changed": max(filter(None, (r["created_at"], last)))})
1411
+ row = {"id": r["id"], "name": r["name"], "system": False,
1412
+ "type": r["type"], "files": n, "bytes": r["bytes"],
1413
+ "changed": max(filter(None, (r["created_at"], last)))}
1414
+ # A workflow's platform is how the page labels it; nothing
1415
+ # else of its config rides in the summary.
1416
+ platform = _config_platform(r["config"])
1417
+ if platform is not None:
1418
+ row["platform"] = platform
1419
+ out.append(row)
827
1420
  return out
828
1421
 
829
1422
  def get(self, sid) -> dict:
830
1423
  """One Source with its file list, or None."""
831
1424
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
832
1425
  db.row_factory = sqlite3.Row
833
- r = db.execute("SELECT id, name, system, bytes, type, config FROM sources WHERE id = ?",
834
- (sid,)).fetchone()
1426
+ r = db.execute("SELECT id, name, system, bytes, type, config FROM sources "
1427
+ "WHERE id = ? AND (workspace = ? OR system = 1)",
1428
+ (sid, workspace_of(self.store))).fetchone()
835
1429
  if r is None:
836
1430
  return None
837
1431
  if r["system"]:
@@ -878,14 +1472,18 @@ class Sources:
878
1472
  return None, (400, "that name is too long")
879
1473
  if not isinstance(kind, str) or kind not in SOURCE_TYPES:
880
1474
  return None, (400, f"the lab has no Source type {kind!r}")
1475
+ # A workflow kind needs a platform the lab knows, named in its config.
1476
+ if source_entry(kind, config) is None:
1477
+ return None, (400, "the lab has no such Source platform")
881
1478
  kept, err = self._config(config)
882
1479
  if err:
883
1480
  return None, err
884
1481
  sid = secrets.token_hex(6)
885
1482
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
886
- db.execute("INSERT INTO sources (id, name, system, bytes, created_at, type, config) "
887
- "VALUES (?, ?, 0, 0, ?, ?, ?)",
888
- (sid, name, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), kind, kept))
1483
+ db.execute("INSERT INTO sources (id, name, system, bytes, created_at, type, config, workspace) "
1484
+ "VALUES (?, ?, 0, 0, ?, ?, ?, ?)",
1485
+ (sid, name, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), kind, kept,
1486
+ workspace_of(self.store)))
889
1487
  (self.dir / sid).mkdir(parents=True, exist_ok=True)
890
1488
  return {"id": sid, "name": name, "system": False, "type": kind,
891
1489
  "config": json.loads(kept) if kept else None, "files": [], "bytes": 0}, None
@@ -896,8 +1494,8 @@ class Sources:
896
1494
  """A Source's newest definition and the versions kept, or None for a
897
1495
  Source that is not there or keeps none."""
898
1496
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
899
- r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
900
- if r is None or not (SOURCE_TYPES.get(r[0]) or {}).get("definition"):
1497
+ r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1498
+ if r is None or not (source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}).get("definition"):
901
1499
  return None
902
1500
  rows = db.execute("SELECT version, body, at FROM source_definitions WHERE source = ? "
903
1501
  "ORDER BY version DESC", (sid,)).fetchall()
@@ -923,10 +1521,10 @@ class Sources:
923
1521
  return None, (413, "that definition is over the cap")
924
1522
  now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
925
1523
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
926
- r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
1524
+ r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
927
1525
  if r is None:
928
1526
  return None, (404, "no such source")
929
- if not (SOURCE_TYPES.get(r[0]) or {}).get("definition"):
1527
+ if not (source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}).get("definition"):
930
1528
  return None, (400, "a Source of that type keeps no definition")
931
1529
  current = db.execute("SELECT MAX(version) FROM source_definitions WHERE source = ?",
932
1530
  (sid,)).fetchone()[0] or 0
@@ -956,10 +1554,15 @@ class Sources:
956
1554
  db.execute("DELETE FROM source_definitions WHERE source = ?", (sid,))
957
1555
 
958
1556
  def type_of(self, sid):
959
- """A Source's type's entry in SOURCE_TYPES; None for no such Source."""
1557
+ """A Source's enforcement entry -- its kind's, or its workflow
1558
+ platform's (source_entry); None for no such Source. {} for a known
1559
+ Source whose platform the lab does not know, so a reader asks the
1560
+ entry rather than the id."""
960
1561
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
961
- r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
962
- return None if r is None else (SOURCE_TYPES.get(r[0]) or {})
1562
+ r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1563
+ if r is None:
1564
+ return None
1565
+ return source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}
963
1566
 
964
1567
  def uploads(self, sid):
965
1568
  """The files a Source takes, by extension, as its type declares them;
@@ -975,7 +1578,7 @@ class Sources:
975
1578
  if len(name) > 80:
976
1579
  return None, (400, "that name is too long")
977
1580
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
978
- r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
1581
+ r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
979
1582
  if r is None:
980
1583
  return None, (404, "no such source")
981
1584
  if r[0]:
@@ -988,8 +1591,9 @@ class Sources:
988
1591
  Undo can restore it; the system Source is undeletable. Returns
989
1592
  (token, None), or (None, error)."""
990
1593
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
991
- r = db.execute("SELECT system, name, created_at, type, config FROM sources "
992
- "WHERE id = ?", (sid,)).fetchone()
1594
+ r = db.execute("SELECT system, name, created_at, type, config, workspace FROM sources "
1595
+ "WHERE id = ? AND (workspace = ? OR system = 1)",
1596
+ (sid, workspace_of(self.store))).fetchone()
993
1597
  if r is None:
994
1598
  return None, (404, "no such source")
995
1599
  if r[0]:
@@ -1004,7 +1608,7 @@ class Sources:
1004
1608
  entry.mkdir(parents=True, exist_ok=True)
1005
1609
  (entry / "manifest.json").write_text(json.dumps({
1006
1610
  "kind": "source", "source": sid, "name": r[1], "created": r[2],
1007
- "type": r[3], "config": r[4], "at": time.time(), "files": files}))
1611
+ "type": r[3], "config": r[4], "workspace": r[5], "at": time.time(), "files": files}))
1008
1612
  if (self.dir / sid).is_dir():
1009
1613
  shutil.move(str(self.dir / sid), str(entry / sid))
1010
1614
  return token, None
@@ -1017,7 +1621,7 @@ class Sources:
1017
1621
  return None, (400, "no files named")
1018
1622
  names = list(dict.fromkeys(names))
1019
1623
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
1020
- r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
1624
+ r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1021
1625
  if r is None:
1022
1626
  return None, (404, "no such source")
1023
1627
  if r[0]:
@@ -1072,19 +1676,22 @@ class Sources:
1072
1676
  sid, name = m.get("source"), m.get("name")
1073
1677
  if not isinstance(sid, str) or not isinstance(name, str):
1074
1678
  return None, (404, "no such trash entry")
1679
+ # Back into the workspace it was trashed from (an older trash entry
1680
+ # has none: the default). A name is unique within a workspace.
1681
+ ws = m.get("workspace") or self.store.default_ws
1075
1682
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
1076
1683
  if db.execute("SELECT 1 FROM sources WHERE name = ? COLLATE NOCASE "
1077
- "AND system = 0", (name,)).fetchone():
1684
+ "AND system = 0 AND workspace = ?", (name, ws)).fetchone():
1078
1685
  return None, (409, f"{name!r} has been taken since, so nothing was restored")
1079
1686
  with db:
1080
1687
  kind = m.get("type")
1081
1688
  db.execute("INSERT INTO sources (id, name, system, bytes, created_at, "
1082
- "type, config) VALUES (?, ?, 0, ?, ?, ?, ?)",
1689
+ "type, config, workspace) VALUES (?, ?, 0, ?, ?, ?, ?, ?)",
1083
1690
  (sid, name, sum(f.get("bytes", 0) for f in m["files"]),
1084
1691
  m.get("created", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
1085
1692
  # As it was: a restore puts the row back, not a guess at it.
1086
1693
  kind if isinstance(kind, str) else DEFAULT_SOURCE_TYPE,
1087
- m.get("config") if isinstance(m.get("config"), str) else None))
1694
+ m.get("config") if isinstance(m.get("config"), str) else None, ws))
1088
1695
  for f in m["files"]:
1089
1696
  if isinstance(f, dict) and isinstance(f.get("name"), str):
1090
1697
  db.execute("INSERT INTO source_files (source, name, bytes, at) "
@@ -1100,7 +1707,8 @@ class Sources:
1100
1707
  return None, (404, "no such trash entry")
1101
1708
  total = sum(f.get("bytes", 0) for f in files)
1102
1709
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
1103
- r = db.execute("SELECT system, bytes FROM sources WHERE id = ?", (sid,)).fetchone()
1710
+ r = db.execute("SELECT system, bytes FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
1711
+ (sid, workspace_of(self.store))).fetchone()
1104
1712
  if r is None:
1105
1713
  return None, (409, "the Source that held these files is gone, so nothing was restored")
1106
1714
  if r[0]:
@@ -1171,14 +1779,15 @@ class Sources:
1171
1779
  names = list(dict.fromkeys(names))
1172
1780
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
1173
1781
  db.row_factory = sqlite3.Row
1174
- dest = db.execute("SELECT system, bytes, type FROM sources WHERE id = ?",
1175
- (sid,)).fetchone()
1782
+ ws = workspace_of(self.store)
1783
+ dest = db.execute("SELECT system, bytes, type FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
1784
+ (sid, ws)).fetchone()
1176
1785
  if dest is None:
1177
1786
  return None, (404, "no such source")
1178
1787
  if dest["system"]:
1179
1788
  return None, (403, "the sample library cannot be written to")
1180
- src = db.execute("SELECT system, type FROM sources WHERE id = ?",
1181
- (from_sid,)).fetchone()
1789
+ src = db.execute("SELECT system, type FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
1790
+ (from_sid, ws)).fetchone()
1182
1791
  if src is None:
1183
1792
  return None, (404, "no such source")
1184
1793
  # A file means what its Source's type says it means, so it only
@@ -1267,7 +1876,7 @@ class Sources:
1267
1876
  """Every file of a Source, as (name, path on disk) in its own order,
1268
1877
  or None when there is no such Source."""
1269
1878
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
1270
- r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
1879
+ r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1271
1880
  if r is None:
1272
1881
  return None
1273
1882
  if r[0]:
@@ -1284,7 +1893,7 @@ class Sources:
1284
1893
  or not all(isinstance(n, str) for n in names):
1285
1894
  return None, (400, "the names are needed")
1286
1895
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
1287
- r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
1896
+ r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1288
1897
  if r is None:
1289
1898
  return None, (404, "no such source")
1290
1899
  if r[0]:
@@ -1350,7 +1959,8 @@ class Sources:
1350
1959
  """
1351
1960
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
1352
1961
  db.row_factory = sqlite3.Row
1353
- r = db.execute("SELECT system, bytes FROM sources WHERE id = ?", (sid,)).fetchone()
1962
+ r = db.execute("SELECT system, bytes FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
1963
+ (sid, workspace_of(self.store))).fetchone()
1354
1964
  if r is None:
1355
1965
  return None, (404, "no such source")
1356
1966
  if r["system"]:
@@ -1382,7 +1992,7 @@ class Sources:
1382
1992
  def file_path(self, sid: str, name: str) -> Path:
1383
1993
  """The on-disk path of a stored file, or None. `name` is already clean."""
1384
1994
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
1385
- r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
1995
+ r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
1386
1996
  if r is None:
1387
1997
  return None
1388
1998
  if r[0]:
@@ -1455,22 +2065,23 @@ class Sources:
1455
2065
  # it is left where it is, and nothing reads it.
1456
2066
 
1457
2067
  DATASET_FIELDS = ("version", "source", "scoring", "grader", "every", "run", "cases")
1458
- # A body's own version: evals-core.ts's DATASET_BODY_VERSION. Version 7 is an
2068
+ # A body's own version: evals-core.ts's DATASET_BODY_VERSION. Version 8 reads
2069
+ # the recorded-reply metric ids under their new names (#299); version 7 is an
1459
2070
  # eval group (docs/pipeline-model.md §17), version 6 its cases alone; version
1460
2071
  # 5 was told by its `source` alone, and earlier ones by neither.
1461
- DATASET_BODY_VERSION = 7
2072
+ DATASET_BODY_VERSION = 8
1462
2073
  DATASET_NAME_MAX = 80
1463
2074
  # The file forms Export writes and Import reads. Export writes an eval group
1464
- # at version 7 (docs/pipeline-model.md §17); Import reads that, and a dataset
1465
- # file of versions 1 to 7, upgraded, and refuses anything else, as a pipeline
2075
+ # at version 8 (docs/pipeline-model.md §17); Import reads that, and a dataset
2076
+ # file of versions 1 to 8, upgraded, and refuses anything else, as a pipeline
1466
2077
  # of another version is refused. Versions 1 to 3 carried a prompt, which an
1467
2078
  # import gives to the Prompt library.
1468
2079
  EXPORT_ONE = "evals-lab/eval-group"
1469
2080
  EXPORT_ALL = "evals-lab/eval-groups"
1470
2081
  DATASET_ONE = "evals-lab/dataset"
1471
2082
  DATASET_ALL = "evals-lab/datasets"
1472
- EXPORT_VERSION = 7
1473
- IMPORT_VERSIONS = (1, 2, 3, 4, 5, 6, 7)
2083
+ EXPORT_VERSION = 8
2084
+ IMPORT_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8)
1474
2085
  # Each file form, and the key its one entry or its list sits under.
1475
2086
  EXPORT_KEYS = {EXPORT_ONE: "group", EXPORT_ALL: "groups", DATASET_ONE: "dataset", DATASET_ALL: "datasets"}
1476
2087
  SCORING_MODES = ("all", "weighted")
@@ -1508,7 +2119,7 @@ def _term_in(items, term) -> bool:
1508
2119
  def case_metrics(c: dict) -> list:
1509
2120
  """A version-4 case's expectations as the metrics that say the same:
1510
2121
  evals-core.ts's caseMetrics, in Python, and held to it by proxy-check.py
1511
- through fixtures/dataset-v7.json."""
2122
+ through fixtures/dataset-v8.json."""
1512
2123
  def strs(v):
1513
2124
  return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
1514
2125
  if c.get("discarded") is True:
@@ -1575,7 +2186,7 @@ def case_of_v5(c):
1575
2186
 
1576
2187
 
1577
2188
  def group_of_v6(body: dict) -> dict:
1578
- """A version-6 body as a version-7 eval group: evals-core.ts's
2189
+ """A version-6 body as a version-8 eval group: evals-core.ts's
1579
2190
  groupOfV6. Scored All, the lab's grader, and no metrics of its own for
1580
2191
  every item or the whole run -- what a Metrics eval naming the dataset with
1581
2192
  none of its own graded."""
@@ -1584,8 +2195,43 @@ def group_of_v6(body: dict) -> dict:
1584
2195
  "grader": None, "every": [], "run": [], **rest}
1585
2196
 
1586
2197
 
2198
+ # The recorded-reply metric ids renamed at version 8: evals-core.ts's
2199
+ # RECORDED_IDS. Stored tokens inside eval group bodies and a pipeline's private
2200
+ # group, so they are mapped wherever a body is read (#299).
2201
+ RECORDED_IDS = {
2202
+ "equals-production": "same-as-recorded",
2203
+ "fields-equal-production": "fields-equal-recorded",
2204
+ "same-parse-outcome": "same-parse-as-recorded",
2205
+ }
2206
+
2207
+
2208
+ def _rename_metric(m):
2209
+ return {**m, "type": RECORDED_IDS[m["type"]]} if isinstance(m, dict) and m.get("type") in RECORDED_IDS else m
2210
+
2211
+
2212
+ def _rename_metrics(lst):
2213
+ return [_rename_metric(m) for m in lst] if isinstance(lst, list) else lst
2214
+
2215
+
2216
+ def recorded_ids_v7(body):
2217
+ """A version-7 body (or a freshly made version-8 one) with its
2218
+ recorded-reply metric ids read under their version-8 names, at version 8:
2219
+ evals-core.ts's recordedIdsV7."""
2220
+ out = dict(body)
2221
+ out["version"] = DATASET_BODY_VERSION
2222
+ if isinstance(body.get("every"), list):
2223
+ out["every"] = _rename_metrics(body["every"])
2224
+ if isinstance(body.get("run"), list):
2225
+ out["run"] = _rename_metrics(body["run"])
2226
+ if isinstance(body.get("cases"), list):
2227
+ out["cases"] = [{**c, "metrics": _rename_metrics(c["metrics"])}
2228
+ if isinstance(c, dict) and isinstance(c.get("metrics"), list) else c
2229
+ for c in body["cases"]]
2230
+ return out
2231
+
2232
+
1587
2233
  def upgrade_body(body):
1588
- """An earlier body as today's (version 7): evals-core.ts's
2234
+ """An earlier body as today's (version 8): evals-core.ts's
1589
2235
  upgradeDatasetBody, in Python. Version 1's `imageCases` are `cases`, and
1590
2236
  its `replays` and `conformance` go (fixtures/replays.json holds the
1591
2237
  parser's tests). Version 2's `rules` go -- they clean a job's answer, so
@@ -1596,24 +2242,28 @@ def upgrade_body(body):
1596
2242
  needs the rules or the prompt takes them first (`body_rules`,
1597
2243
  `body_prompt`). A body naming its Source is version 5, whose Contains
1598
2244
  metrics each come to say Ignore case (`case_of_v5`). Version 6 gains a
1599
- group's scoring, grader, Every item and Whole run (`group_of_v6`). A body
1600
- saying it is version 7 comes back as it was; so does anything that is
1601
- not a body."""
2245
+ group's scoring, grader, Every item and Whole run (`group_of_v6`); version
2246
+ 7 reads its recorded-reply metric ids under their version-8 names
2247
+ (`recorded_ids_v7`). A body saying it is version 8 comes back as it was;
2248
+ so does anything that is not a body."""
1602
2249
  if not isinstance(body, dict) or body.get("version") == DATASET_BODY_VERSION:
1603
2250
  return body
1604
- # A body saying any other version is one this lab does not read, and is
1605
- # left for dataset_problem to refuse.
1606
2251
  if "version" in body:
1607
- return group_of_v6(body) if body["version"] == 6 else body
2252
+ # Version 7 reads its recorded-reply metric ids under their version-8
2253
+ # names; version 6 is its cases alone. Any other version is one this
2254
+ # lab does not read, left for dataset_problem to refuse.
2255
+ if body["version"] == 7:
2256
+ return recorded_ids_v7(body)
2257
+ return recorded_ids_v7(group_of_v6(body)) if body["version"] == 6 else body
1608
2258
  if "source" in body:
1609
2259
  up = dict(body)
1610
2260
  if isinstance(body.get("cases"), list):
1611
2261
  up["cases"] = [case_of_v5(c) for c in body["cases"]]
1612
- return group_of_v6(up)
2262
+ return recorded_ids_v7(group_of_v6(up))
1613
2263
  cases = body.get("cases") if isinstance(body.get("cases"), list) else body.get("imageCases")
1614
2264
  if not isinstance(cases, list):
1615
2265
  return body
1616
- return group_of_v6({"source": None, "cases": [case_of_v5(case_of_v4(c)) for c in cases]})
2266
+ return recorded_ids_v7(group_of_v6({"source": None, "cases": [case_of_v5(case_of_v4(c)) for c in cases]}))
1617
2267
 
1618
2268
 
1619
2269
  def body_prompt(body):
@@ -1785,6 +2435,13 @@ class Prompts:
1785
2435
  cols = {r[1] for r in db.execute("PRAGMA table_info(prompt_uses)")}
1786
2436
  if "chain" in cols and "job" not in cols:
1787
2437
  db.execute("ALTER TABLE prompt_uses RENAME COLUMN chain TO job")
2438
+ # The workspace a prompt belongs to (docs/workspaces.md): the
2439
+ # Default is per-workspace, so is_default is scoped too. Children
2440
+ # (versions, uses) co-scope through the prompt id. A store from
2441
+ # before workspaces backfills every prompt to the default.
2442
+ if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(prompts)")}:
2443
+ db.execute("ALTER TABLE prompts ADD COLUMN workspace TEXT")
2444
+ db.execute("UPDATE prompts SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
1788
2445
  # One-off facts about the library: that the runs from before it
1789
2446
  # have been read into it, so a restart does not read them again
1790
2447
  # and bring back a prompt someone has since deleted.
@@ -1799,9 +2456,9 @@ class Prompts:
1799
2456
  db.row_factory = sqlite3.Row
1800
2457
  return closing(db)
1801
2458
 
1802
- @staticmethod
1803
- def _live(db, pid):
1804
- return db.execute("SELECT * FROM prompts WHERE id = ? AND trash IS NULL", (pid,)).fetchone()
2459
+ def _live(self, db, pid):
2460
+ return db.execute("SELECT * FROM prompts WHERE id = ? AND trash IS NULL AND workspace = ?",
2461
+ (pid, workspace_of(self.store))).fetchone()
1805
2462
 
1806
2463
  @staticmethod
1807
2464
  def _head(db, pid):
@@ -1840,7 +2497,8 @@ class Prompts:
1840
2497
  def list(self) -> list:
1841
2498
  with self.store.lock, self._connect() as db:
1842
2499
  return [self._summary(db, r) for r in db.execute(
1843
- "SELECT * FROM prompts WHERE trash IS NULL ORDER BY updated_at DESC, id")]
2500
+ "SELECT * FROM prompts WHERE trash IS NULL AND workspace = ? ORDER BY updated_at DESC, id",
2501
+ (workspace_of(self.store),))]
1844
2502
 
1845
2503
  def get(self, pid):
1846
2504
  with self.store.lock, self._connect() as db:
@@ -1850,7 +2508,8 @@ class Prompts:
1850
2508
  def default(self):
1851
2509
  """The Default's newest version, {id, name, version, text}, or None."""
1852
2510
  with self.store.lock, self._connect() as db:
1853
- r = db.execute("SELECT * FROM prompts WHERE is_default = 1 AND trash IS NULL").fetchone()
2511
+ r = db.execute("SELECT * FROM prompts WHERE is_default = 1 AND trash IS NULL AND workspace = ?",
2512
+ (workspace_of(self.store),)).fetchone()
1854
2513
  if r is None:
1855
2514
  return None
1856
2515
  head = self._head(db, r["id"])
@@ -1862,10 +2521,11 @@ class Prompts:
1862
2521
  def _insert(self, db, name, text, default=False):
1863
2522
  pid = secrets.token_hex(6)
1864
2523
  now = self._now()
2524
+ ws = workspace_of(self.store)
1865
2525
  if default:
1866
- db.execute("UPDATE prompts SET is_default = 0")
1867
- db.execute("INSERT INTO prompts (id, name, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
1868
- (pid, name, 1 if default else 0, now, now))
2526
+ db.execute("UPDATE prompts SET is_default = 0 WHERE workspace = ?", (ws,))
2527
+ db.execute("INSERT INTO prompts (id, name, is_default, created_at, updated_at, workspace) "
2528
+ "VALUES (?, ?, ?, ?, ?, ?)", (pid, name, 1 if default else 0, now, now, ws))
1869
2529
  db.execute("INSERT INTO prompt_versions (prompt_id, version, text, created_at, edited_at) "
1870
2530
  "VALUES (?, 1, ?, ?, ?)", (pid, text, now, time.time()))
1871
2531
  return pid
@@ -1880,7 +2540,8 @@ class Prompts:
1880
2540
  return version
1881
2541
 
1882
2542
  def _has_default(self, db):
1883
- return db.execute("SELECT 1 FROM prompts WHERE is_default = 1 AND trash IS NULL").fetchone() is not None
2543
+ return db.execute("SELECT 1 FROM prompts WHERE is_default = 1 AND trash IS NULL AND workspace = ?",
2544
+ (workspace_of(self.store),)).fetchone() is not None
1884
2545
 
1885
2546
  def adopt(self, db, text, name="", default=False):
1886
2547
  """A prompt reading [text]: the live one that already does, or a new
@@ -1896,13 +2557,13 @@ class Prompts:
1896
2557
  db.execute("UPDATE prompts SET is_default = 1 WHERE id = ?", (pid,))
1897
2558
  return pid
1898
2559
 
1899
- @staticmethod
1900
- def _matching(db, text):
1901
- """The newest version of any live prompt reading exactly [text]."""
2560
+ def _matching(self, db, text):
2561
+ """The newest version of any live prompt in this workspace reading
2562
+ exactly [text]."""
1902
2563
  return db.execute("SELECT v.prompt_id, v.version FROM prompt_versions v "
1903
- "JOIN prompts p ON p.id = v.prompt_id AND p.trash IS NULL "
2564
+ "JOIN prompts p ON p.id = v.prompt_id AND p.trash IS NULL AND p.workspace = ? "
1904
2565
  "WHERE v.text = ? ORDER BY v.created_at DESC, v.version DESC LIMIT 1",
1905
- (text,)).fetchone()
2566
+ (workspace_of(self.store), text)).fetchone()
1906
2567
 
1907
2568
  def record(self, db, rid, run, at):
1908
2569
  """A run's uses, one per scenario per job, linked by the rules in
@@ -2000,7 +2661,7 @@ class Prompts:
2000
2661
  with self.store.lock, self._connect() as db, db:
2001
2662
  if self._live(db, pid) is None:
2002
2663
  return None, (404, "no such prompt")
2003
- db.execute("UPDATE prompts SET is_default = 0")
2664
+ db.execute("UPDATE prompts SET is_default = 0 WHERE workspace = ?", (workspace_of(self.store),))
2004
2665
  db.execute("UPDATE prompts SET is_default = 1 WHERE id = ?", (pid,))
2005
2666
  return self._detail(db, self._live(db, pid)), None
2006
2667
 
@@ -2032,7 +2693,8 @@ class Prompts:
2032
2693
 
2033
2694
  def restore(self, token):
2034
2695
  with self.store.lock, self._connect() as db, db:
2035
- r = db.execute("SELECT * FROM prompts WHERE trash = ?", (str(token),)).fetchone()
2696
+ r = db.execute("SELECT * FROM prompts WHERE trash = ? AND workspace = ?",
2697
+ (str(token), workspace_of(self.store))).fetchone()
2036
2698
  if r is None:
2037
2699
  return None, (404, "no such trash entry")
2038
2700
  db.execute("UPDATE prompts SET trash = NULL, trashed_at = NULL WHERE id = ?", (r["id"],))
@@ -2084,6 +2746,12 @@ class Datasets:
2084
2746
  "group_id TEXT NOT NULL, n INTEGER NOT NULL, body TEXT NOT NULL, "
2085
2747
  "created_at TEXT NOT NULL, edited_at REAL NOT NULL, "
2086
2748
  "ran INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (group_id, n))")
2749
+ # The workspace a dataset belongs to (docs/workspaces.md); its
2750
+ # group versions and archives co-scope through the dataset id. A
2751
+ # store from before workspaces backfills every row to the default.
2752
+ if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(datasets)")}:
2753
+ db.execute("ALTER TABLE datasets ADD COLUMN workspace TEXT")
2754
+ db.execute("UPDATE datasets SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
2087
2755
  # Rows from an earlier version are converted once, in place: a
2088
2756
  # dataset is typed in by hand and costly to re-enter, so it is
2089
2757
  # upgraded rather than hidden (AGENTS.md's one exception). The
@@ -2139,8 +2807,11 @@ class Datasets:
2139
2807
  newest one's number, which a pin may name, and 1 before any is kept,
2140
2808
  the row's body being version 1 in waiting."""
2141
2809
  parsed = upgrade_body(json.loads(r["body"]))
2810
+ # The grader rides in the summary: a run carries the profile each
2811
+ # linked group asks, and the page resolves a run from the list.
2142
2812
  out = {"id": r["id"], "name": r["name"], "cases": len(parsed.get("cases") or []),
2143
- "version": r["version"], "versions": versions, "updated": r["updated_at"]}
2813
+ "version": r["version"], "versions": versions, "updated": r["updated_at"],
2814
+ "grader": parsed.get("grader")}
2144
2815
  if body:
2145
2816
  out["body"] = parsed
2146
2817
  return out
@@ -2158,17 +2829,20 @@ class Datasets:
2158
2829
  return closing(db)
2159
2830
 
2160
2831
  def _live(self, db, did):
2161
- return db.execute("SELECT * FROM datasets WHERE id = ? AND trash IS NULL", (did,)).fetchone()
2832
+ return db.execute("SELECT * FROM datasets WHERE id = ? AND trash IS NULL AND workspace = ?",
2833
+ (did, workspace_of(self.store))).fetchone()
2162
2834
 
2163
2835
  def _names(self, db, but=None):
2164
- return {r[0] for r in db.execute("SELECT name FROM datasets WHERE trash IS NULL AND id IS NOT ?",
2165
- (but,))}
2836
+ return {r[0] for r in db.execute(
2837
+ "SELECT name FROM datasets WHERE trash IS NULL AND id IS NOT ? AND workspace = ?",
2838
+ (but, workspace_of(self.store)))}
2166
2839
 
2167
2840
  def list(self) -> list:
2168
2841
  with self.store.lock, self._connect() as db:
2169
2842
  counts = self._counts(db)
2170
2843
  return [self._doc(r, False, counts.get(r["id"], 1)) for r in db.execute(
2171
- "SELECT * FROM datasets WHERE trash IS NULL ORDER BY name COLLATE NOCASE, id")]
2844
+ "SELECT * FROM datasets WHERE trash IS NULL AND workspace = ? ORDER BY name COLLATE NOCASE, id",
2845
+ (workspace_of(self.store),))]
2172
2846
 
2173
2847
  def get(self, did):
2174
2848
  with self.store.lock, self._connect() as db:
@@ -2198,11 +2872,11 @@ class Datasets:
2198
2872
  "VALUES (?, 1, ?, ?, ?)", (r["id"], r["body"], r["updated_at"], edited))
2199
2873
  return self._head(db, r["id"])
2200
2874
 
2201
- @staticmethod
2202
- def _pins(db) -> set:
2875
+ def _pins(self, db) -> set:
2203
2876
  """Every (group, version) a stored pipeline pins, read in the caller's
2204
- transaction from the docs table the page writes them to."""
2205
- row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'").fetchone()
2877
+ transaction from this workspace's workflows document."""
2878
+ row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows' AND workspace = ?",
2879
+ (workspace_of(self.store),)).fetchone()
2206
2880
  return pins_in(json.loads(row[0])) if row and row[0] else set()
2207
2881
 
2208
2882
  def _cut(self, db, did, text):
@@ -2316,8 +2990,9 @@ class Datasets:
2316
2990
  def _insert(self, db, name, body):
2317
2991
  did = secrets.token_hex(6)
2318
2992
  now = self._now()
2319
- db.execute("INSERT INTO datasets (id, name, version, body, created_at, updated_at) "
2320
- "VALUES (?, ?, 1, ?, ?, ?)", (did, name, json.dumps(body), now, now))
2993
+ db.execute("INSERT INTO datasets (id, name, version, body, created_at, updated_at, workspace) "
2994
+ "VALUES (?, ?, 1, ?, ?, ?, ?)",
2995
+ (did, name, json.dumps(body), now, now, workspace_of(self.store)))
2321
2996
  return did
2322
2997
 
2323
2998
  def create(self, name, body=None):
@@ -2387,7 +3062,8 @@ class Datasets:
2387
3062
  """A trashed dataset back, under a new ` (2)` name if its own has been
2388
3063
  taken since. Returns (DatasetDoc, None)."""
2389
3064
  with self.store.lock, self._connect() as db, db:
2390
- r = db.execute("SELECT * FROM datasets WHERE trash = ?", (str(token),)).fetchone()
3065
+ r = db.execute("SELECT * FROM datasets WHERE trash = ? AND workspace = ?",
3066
+ (str(token), workspace_of(self.store))).fetchone()
2391
3067
  if r is None:
2392
3068
  return None, (404, "no such trash entry")
2393
3069
  name = unique_dataset_name(r["name"], self._names(db, but=r["id"]))
@@ -2423,8 +3099,8 @@ class Datasets:
2423
3099
 
2424
3100
  def export_all(self):
2425
3101
  with self.store.lock, self._connect() as db:
2426
- rows = db.execute("SELECT * FROM datasets WHERE trash IS NULL "
2427
- "ORDER BY name COLLATE NOCASE, id").fetchall()
3102
+ rows = db.execute("SELECT * FROM datasets WHERE trash IS NULL AND workspace = ? "
3103
+ "ORDER BY name COLLATE NOCASE, id", (workspace_of(self.store),)).fetchall()
2428
3104
  return {"format": EXPORT_ALL, "version": EXPORT_VERSION,
2429
3105
  "groups": [{"name": r["name"], "body": upgrade_body(json.loads(r["body"]))} for r in rows]}
2430
3106
 
@@ -2682,11 +3358,40 @@ class Packs:
2682
3358
  self.trash = {}
2683
3359
  with store.lock, closing(sqlite3.connect(store.path)) as db, db:
2684
3360
  db.execute("CREATE TABLE IF NOT EXISTS packs ("
2685
- "id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, "
2686
- "manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL)")
3361
+ "id TEXT NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL, "
3362
+ "manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL, "
3363
+ "workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (id, workspace))")
2687
3364
  db.execute("CREATE TABLE IF NOT EXISTS pack_items ("
2688
3365
  "pack TEXT NOT NULL, kind TEXT NOT NULL, key TEXT NOT NULL, item TEXT NOT NULL, "
2689
- "PRIMARY KEY (pack, kind, key))")
3366
+ "workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (pack, workspace, kind, key))")
3367
+ # A pack's id is its manifest's, not globally unique, so two
3368
+ # workspaces can hold the same pack: the workspace is part of both
3369
+ # keys (docs/workspaces.md). A store from before workspaces keyed
3370
+ # packs by id alone; it is rebuilt once, every row to the default.
3371
+ if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(packs)")}:
3372
+ self._rebuild(db, store.default_ws, "packs",
3373
+ "id, name, version, manifest, presets, installed_at",
3374
+ "id TEXT NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL, "
3375
+ "manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL, "
3376
+ "workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (id, workspace)")
3377
+ if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(pack_items)")}:
3378
+ self._rebuild(db, store.default_ws, "pack_items", "pack, kind, key, item",
3379
+ "pack TEXT NOT NULL, kind TEXT NOT NULL, key TEXT NOT NULL, item TEXT NOT NULL, "
3380
+ "workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (pack, workspace, kind, key)")
3381
+
3382
+ @staticmethod
3383
+ def _rebuild(db, ws, table, cols, schema):
3384
+ """A table keyed without a workspace, rebuilt with one its old rows
3385
+ take: SQLite cannot add to a PRIMARY KEY, so the rows move through a
3386
+ fresh table (the sources `type`/`config` migration's shape)."""
3387
+ rows = db.execute(f"SELECT {cols} FROM {table}").fetchall()
3388
+ db.execute(f"ALTER TABLE {table} RENAME TO {table}_old")
3389
+ db.execute(f"CREATE TABLE {table} ({schema})")
3390
+ names = cols.split(", ")
3391
+ ph = ", ".join("?" * (len(names) + 1))
3392
+ for r in rows:
3393
+ db.execute(f"INSERT INTO {table} ({cols}, workspace) VALUES ({ph})", (*r, ws))
3394
+ db.execute(f"DROP TABLE {table}_old")
2690
3395
 
2691
3396
  def _connect(self):
2692
3397
  db = sqlite3.connect(self.store.path)
@@ -2696,12 +3401,15 @@ class Packs:
2696
3401
  def _items(self, pid):
2697
3402
  with self.store.lock, self._connect() as db:
2698
3403
  return {(r["kind"], r["key"]): r["item"]
2699
- for r in db.execute("SELECT kind, key, item FROM pack_items WHERE pack = ?", (pid,))}
3404
+ for r in db.execute("SELECT kind, key, item FROM pack_items WHERE pack = ? AND workspace = ?",
3405
+ (pid, workspace_of(self.store)))}
2700
3406
 
2701
3407
  def list(self) -> list:
2702
3408
  with self.store.lock, self._connect() as db:
2703
- packs = db.execute("SELECT * FROM packs ORDER BY name COLLATE NOCASE").fetchall()
2704
- items = db.execute("SELECT pack, kind, item FROM pack_items").fetchall()
3409
+ ws = workspace_of(self.store)
3410
+ packs = db.execute("SELECT * FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
3411
+ (ws,)).fetchall()
3412
+ items = db.execute("SELECT pack, kind, item FROM pack_items WHERE workspace = ?", (ws,)).fetchall()
2705
3413
  workflows = {w.get("id"): w.get("name") for w in self._doc("promptlab.workflows")[1].get("list", [])}
2706
3414
  out = []
2707
3415
  for p in packs:
@@ -2721,7 +3429,8 @@ class Packs:
2721
3429
  def presets(self) -> list:
2722
3430
  """Every installed pack's Setup presets, each marked with its pack."""
2723
3431
  with self.store.lock, self._connect() as db:
2724
- rows = db.execute("SELECT id, presets FROM packs ORDER BY name COLLATE NOCASE").fetchall()
3432
+ rows = db.execute("SELECT id, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
3433
+ (workspace_of(self.store),)).fetchall()
2725
3434
  return [{**p, "pack": r["id"]} for r in rows for p in json.loads(r["presets"])]
2726
3435
 
2727
3436
  # The page's documents are the server's to write here too, through the
@@ -2760,9 +3469,11 @@ class Packs:
2760
3469
  why = pack_requires_problem(m, set(plugins))
2761
3470
  if why:
2762
3471
  return None, (400, why)
3472
+ ws = workspace_of(self.store)
2763
3473
  with self.lock:
2764
3474
  with self.store.lock, self._connect() as db:
2765
- have = db.execute("SELECT version FROM packs WHERE id = ?", (m["id"],)).fetchone()
3475
+ have = db.execute("SELECT version FROM packs WHERE id = ? AND workspace = ?",
3476
+ (m["id"], ws)).fetchone()
2766
3477
  if have and have["version"] == m["packVersion"]:
2767
3478
  return {"pack": m["id"], "installed": False}, None
2768
3479
  owned = self._items(m["id"])
@@ -2857,22 +3568,23 @@ class Packs:
2857
3568
  self._write_doc("promptlab.versions", keep)
2858
3569
  now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2859
3570
  with self.store.lock, self._connect() as db, db:
2860
- db.execute("INSERT INTO packs (id, name, version, manifest, presets, installed_at) "
2861
- "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, "
2862
- "version = excluded.version, manifest = excluded.manifest, "
3571
+ db.execute("INSERT INTO packs (id, name, version, manifest, presets, installed_at, workspace) "
3572
+ "VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id, workspace) DO UPDATE SET "
3573
+ "name = excluded.name, version = excluded.version, manifest = excluded.manifest, "
2863
3574
  "presets = excluded.presets, installed_at = excluded.installed_at",
2864
3575
  (m["id"], m["name"].strip(), m["packVersion"], json.dumps(m),
2865
- json.dumps(pack["presets"]), now))
3576
+ json.dumps(pack["presets"]), now, ws))
2866
3577
  for (kind, key), item in made.items():
2867
- db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item) VALUES (?, ?, ?, ?)",
2868
- (m["id"], kind, key, item))
3578
+ db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item, workspace) "
3579
+ "VALUES (?, ?, ?, ?, ?)", (m["id"], kind, key, item, ws))
2869
3580
  return {"pack": m["id"], "installed": True}, None
2870
3581
 
2871
3582
  def remove(self, pid):
2872
3583
  """What the pack made, into the trash, at once. Returns (token, None)."""
3584
+ ws = workspace_of(self.store)
2873
3585
  with self.lock:
2874
3586
  with self.store.lock, self._connect() as db:
2875
- row = db.execute("SELECT * FROM packs WHERE id = ?", (pid,)).fetchone()
3587
+ row = db.execute("SELECT * FROM packs WHERE id = ? AND workspace = ?", (pid, ws)).fetchone()
2876
3588
  if row is None:
2877
3589
  return None, (404, "no such pack")
2878
3590
  items = self._items(pid)
@@ -2897,8 +3609,8 @@ class Packs:
2897
3609
  if gone:
2898
3610
  self._write_doc("promptlab.workflows", drop)
2899
3611
  with self.store.lock, self._connect() as db, db:
2900
- db.execute("DELETE FROM pack_items WHERE pack = ?", (pid,))
2901
- db.execute("DELETE FROM packs WHERE id = ?", (pid,))
3612
+ db.execute("DELETE FROM pack_items WHERE pack = ? AND workspace = ?", (pid, ws))
3613
+ db.execute("DELETE FROM packs WHERE id = ? AND workspace = ?", (pid, ws))
2902
3614
  token = secrets.token_hex(6)
2903
3615
  self.trash[token] = entry
2904
3616
  return token, None
@@ -2918,13 +3630,14 @@ class Packs:
2918
3630
  self._write_doc("promptlab.workflows",
2919
3631
  lambda body: {**body, "list": [*body.get("list", []), *entry["pipelines"]]})
2920
3632
  r = entry["row"]
3633
+ ws = r.get("workspace", self.store.default_ws)
2921
3634
  with self.store.lock, self._connect() as db, db:
2922
- db.execute("INSERT OR REPLACE INTO packs (id, name, version, manifest, presets, installed_at) "
2923
- "VALUES (?, ?, ?, ?, ?, ?)",
2924
- (r["id"], r["name"], r["version"], r["manifest"], r["presets"], r["installed_at"]))
3635
+ db.execute("INSERT OR REPLACE INTO packs (id, name, version, manifest, presets, installed_at, workspace) "
3636
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
3637
+ (r["id"], r["name"], r["version"], r["manifest"], r["presets"], r["installed_at"], ws))
2925
3638
  for kind, key, item in entry["items"]:
2926
- db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item) VALUES (?, ?, ?, ?)",
2927
- (r["id"], kind, key, item))
3639
+ db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item, workspace) "
3640
+ "VALUES (?, ?, ?, ?, ?)", (r["id"], kind, key, item, ws))
2928
3641
  del self.trash[token]
2929
3642
  return {"pack": r["id"]}, None
2930
3643
 
@@ -2935,7 +3648,8 @@ class Packs:
2935
3648
  removed it and has datasets of its own is left alone. Returns what
2936
3649
  happened, or None where nothing was tried."""
2937
3650
  with self.store.lock, self._connect() as db:
2938
- have = db.execute("SELECT version FROM packs WHERE id = 'demo'").fetchone()
3651
+ have = db.execute("SELECT version FROM packs WHERE id = 'demo' AND workspace = ?",
3652
+ (workspace_of(self.store),)).fetchone()
2939
3653
  if have is None and DATASETS.list():
2940
3654
  return None
2941
3655
  got, err = self.install(demo_pack())
@@ -2975,7 +3689,7 @@ PLUGIN_VERSIONS = (1,)
2975
3689
  PLUGIN_CAP = int(os.environ.get("PLUGIN_CAP", str(16 * 1024 ** 2)))
2976
3690
  PLUGIN_VERSION_TEXT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
2977
3691
  PLUGIN_FILE = re.compile(r"(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*[A-Za-z0-9_-][A-Za-z0-9._-]*\.(?:js|mjs|json|map)")
2978
- REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes")
3692
+ REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes", "workflowPlatforms", "wizards")
2979
3693
  # evalTypes as a manifest written before pipeline version 11 spells it: a
2980
3694
  # plugin's own file, which the lab cannot upgrade, so it is read for good.
2981
3695
  OLD_REGISTRIES = {"testTypes": "evalTypes"}
@@ -3029,7 +3743,7 @@ def read_plugin(data: bytes):
3029
3743
  reg = m.get("registers") or {}
3030
3744
  if not isinstance(reg, dict) or any(k not in REGISTRIES and k not in OLD_REGISTRIES for k in reg):
3031
3745
  return None, f"a plugin's registers are {', '.join(REGISTRIES)}"
3032
- for k in ("outputKinds", "modifiers", "evalTypes", *OLD_REGISTRIES):
3746
+ for k in ("outputKinds", "modifiers", "evalTypes", "wizards", *OLD_REGISTRIES):
3033
3747
  if not isinstance(reg.get(k, []), list) or not all(isinstance(x, str) and x for x in reg.get(k, [])):
3034
3748
  return None, f"registers.{k} is a list of ids"
3035
3749
  conns = reg.get("connectionTypes", [])
@@ -3044,6 +3758,25 @@ def read_plugin(data: bytes):
3044
3758
  or c.get("auth", "bearer") not in AUTH_WAYS):
3045
3759
  return None, ("a connection type the plugin registers is { id, settings, chatPath, "
3046
3760
  f"auth }}, auth one of {', '.join(AUTH_WAYS)}")
3761
+ # A workflow platform (#303): the generic `workflow` kind drives it, and the
3762
+ # server reads only the data it enforces -- the files it takes, whether it
3763
+ # keeps a definition, and the sign-in it is made with. The sign-in must be
3764
+ # one this lab already has (or none): a platform needing a new server-held
3765
+ # OAuth grant is server code and a secret store a plugin cannot ship, so it
3766
+ # stays lab-only (docs/workflow-sources.md phase 7).
3767
+ platforms = reg.get("workflowPlatforms", [])
3768
+ if not isinstance(platforms, list):
3769
+ return None, "registers.workflowPlatforms is a list"
3770
+ for p in platforms:
3771
+ uploads = p.get("uploads", {}) if isinstance(p, dict) else None
3772
+ if (not isinstance(p, dict) or not isinstance(p.get("id"), str) or not p["id"]
3773
+ or not isinstance(uploads, dict)
3774
+ or not all(isinstance(e, str) and e.startswith(".") and isinstance(t, str)
3775
+ for e, t in uploads.items())
3776
+ or not isinstance(p.get("keepsDefinition", False), bool)
3777
+ or (p.get("signIn") is not None and p.get("signIn") not in SIGN_INS)):
3778
+ return None, ("a workflow platform the plugin registers is { id, uploads, "
3779
+ "keepsDefinition, signIn }, signIn null or a sign-in the lab has")
3047
3780
  why = pack_requires_problem({"requires": {"lab": (m.get("requires") or {}).get("lab")}}, set())
3048
3781
  if why:
3049
3782
  return None, why.replace("the pack", "the plugin")
@@ -3053,8 +3786,9 @@ def read_plugin(data: bytes):
3053
3786
  def registered_ids(manifest: dict) -> set:
3054
3787
  """(registry, id) for everything a plugin's manifest says it registers."""
3055
3788
  reg = plugin_registers(manifest)
3056
- out = {(k, x) for k in ("outputKinds", "modifiers", "evalTypes") for x in reg.get(k, [])}
3057
- return out | {("connectionTypes", c["id"]) for c in reg.get("connectionTypes", [])}
3789
+ out = {(k, x) for k in ("outputKinds", "modifiers", "evalTypes", "wizards") for x in reg.get(k, [])}
3790
+ out |= {("connectionTypes", c["id"]) for c in reg.get("connectionTypes", [])}
3791
+ return out | {("workflowPlatforms", p["id"]) for p in reg.get("workflowPlatforms", [])}
3058
3792
 
3059
3793
 
3060
3794
  # ---- Connections: the lab's grants to outside services (#127) --------------
@@ -3385,20 +4119,35 @@ class Plugins:
3385
4119
  return [dict(r) for r in db.execute("SELECT * FROM plugins ORDER BY id")]
3386
4120
 
3387
4121
  def apply(self):
3388
- """The server's connection-type mirror: the built-in types and every
3389
- installed plugin's, rebuilt in place so every reader sees the same."""
4122
+ """The server's connection-type and workflow-platform mirrors: the
4123
+ built-in entries and every installed plugin's, rebuilt in place so every
4124
+ reader sees the same."""
3390
4125
  types, paths, auth = dict(BUILTIN_CONNECTION_TYPES), dict(BUILTIN_CHAT_PATHS), dict(BUILTIN_AUTH)
3391
4126
  local = set(BUILTIN_LOCAL)
4127
+ platforms = {k: dict(v) for k, v in BUILTIN_WORKFLOW_PLATFORMS.items()}
3392
4128
  for r in self._rows():
3393
- for c in (json.loads(r["manifest"]).get("registers") or {}).get("connectionTypes", []):
4129
+ reg = json.loads(r["manifest"]).get("registers") or {}
4130
+ for c in reg.get("connectionTypes", []):
3394
4131
  types[c["id"]] = tuple(c.get("settings", []))
3395
4132
  paths[c["id"]] = c.get("chatPath", "/chat/completions")
3396
4133
  auth[c["id"]] = c.get("auth", "bearer")
3397
4134
  if c.get("local") is True:
3398
4135
  local.add(c["id"])
4136
+ # A plugin platform's enforcement data, read from the manifest: the
4137
+ # files it takes, checked and redacted by the generic record
4138
+ # redactor like any flow's (take_record), whether it keeps a
4139
+ # definition, and the sign-in (none, or one the lab has) a Source of
4140
+ # it is made with. Its code -- api, stepsOf, evaluate … -- is the
4141
+ # page's and the runner's; this server never runs it.
4142
+ for p in reg.get("workflowPlatforms", []):
4143
+ platforms[p["id"]] = {"label": p.get("label", p["id"]), "take": take_record,
4144
+ "uploads": p.get("uploads") or {},
4145
+ "definition": bool(p.get("keepsDefinition")),
4146
+ "signIn": p.get("signIn")}
3399
4147
  LOCAL_CONNECTIONS.clear()
3400
4148
  LOCAL_CONNECTIONS.update(local)
3401
- for table, value in ((CONNECTION_TYPES, types), (CONNECTION_CHAT_PATHS, paths), (CONNECTION_AUTH, auth)):
4149
+ for table, value in ((CONNECTION_TYPES, types), (CONNECTION_CHAT_PATHS, paths),
4150
+ (CONNECTION_AUTH, auth), (WORKFLOW_PLATFORMS, platforms)):
3402
4151
  table.clear()
3403
4152
  table.update(value)
3404
4153
 
@@ -3448,9 +4197,13 @@ class Plugins:
3448
4197
  if same and same["sha256"] == plugin["sha256"]:
3449
4198
  return {"plugin": m["id"], "installed": False}, None
3450
4199
  mine = registered_ids(m)
4200
+ builtin = {"connectionTypes": (BUILTIN_CONNECTION_TYPES, "connection type"),
4201
+ "workflowPlatforms": (BUILTIN_WORKFLOW_PLATFORMS, "workflow platform"),
4202
+ "wizards": (BUILTIN_WIZARD_IDS, "wizard")}
3451
4203
  for (reg, x) in mine:
3452
- if reg == "connectionTypes" and x in BUILTIN_CONNECTION_TYPES:
3453
- return None, (400, f"the plugin registers the connection type {x}, which the lab has already")
4204
+ have, what = builtin.get(reg, (None, None))
4205
+ if have is not None and x in have:
4206
+ return None, (400, f"the plugin registers the {what} {x}, which the lab has already")
3454
4207
  for r in rows:
3455
4208
  if r["id"] == m["id"]:
3456
4209
  continue
@@ -3704,6 +4457,14 @@ class Queue:
3704
4457
  # `dataset` column, read as its one group.
3705
4458
  if "groups" not in cols:
3706
4459
  db.execute("ALTER TABLE queue ADD COLUMN groups TEXT")
4460
+ # The workspace a run belongs to (docs/workspaces.md): History is
4461
+ # per-workspace, so the list and every id-keyed read filter by it.
4462
+ # The worker loop is the one global reader -- it grades every
4463
+ # workspace's runs by id. A store from before workspaces backfills
4464
+ # to the default.
4465
+ if "workspace" not in cols:
4466
+ db.execute("ALTER TABLE queue ADD COLUMN workspace TEXT")
4467
+ db.execute("UPDATE queue SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
3707
4468
 
3708
4469
  # ---- rows -----------------------------------------------------------
3709
4470
 
@@ -3713,7 +4474,7 @@ class Queue:
3713
4474
  # the order ALTER TABLE added them in is no order _row can count on.
3714
4475
  SELECT = ("SELECT q.id, q.status, q.cancel, q.submitted_at, q.started_at, "
3715
4476
  "q.finished_at, q.snapshot, q.results, q.progress, q.totals, q.error, "
3716
- "q.rerun_of, q.verdict, q.verdicts, o.submitted_at FROM queue q "
4477
+ "q.rerun_of, q.verdict, q.verdicts, o.submitted_at, q.workspace FROM queue q "
3717
4478
  "LEFT JOIN queue o ON o.id = q.rerun_of")
3718
4479
 
3719
4480
  @staticmethod
@@ -3729,6 +4490,7 @@ class Queue:
3729
4490
  "rerunOf": r[11], "rerunOfAt": r[14],
3730
4491
  "verdict": r[12],
3731
4492
  "verdicts": json.loads(r[13]) if r[13] else None,
4493
+ "workspace": r[15],
3732
4494
  }
3733
4495
 
3734
4496
  # A row from before run documents has no version, and nothing here can
@@ -3741,14 +4503,20 @@ class Queue:
3741
4503
  def _readable(row):
3742
4504
  return row is not None and (row["snapshot"] or {}).get("version") in READABLE_VERSIONS
3743
4505
 
3744
- def _all(self, db):
3745
- return [row for row in (self._row(r) for r in db.execute(self.SELECT))
4506
+ def _all(self, db, ws=None):
4507
+ """Every readable run; a workspace's when `ws` is given, else the lot --
4508
+ the worker and startup's prompt backfill read across every workspace."""
4509
+ sql = self.SELECT + (" WHERE q.workspace = ?" if ws else "")
4510
+ return [row for row in (self._row(r) for r in db.execute(sql, (ws,) if ws else ()))
3746
4511
  if self._readable(row)]
3747
4512
 
3748
- def get(self, rid):
4513
+ def get(self, rid, ws=None):
4514
+ """One run by id, or None. `ws` scopes the lookup so a workspace cannot
4515
+ read another's run by id (docs/workspaces.md); the worker reads
4516
+ unscoped, by the globally unique id."""
4517
+ sql = self.SELECT + " WHERE q.id = ?" + (" AND q.workspace = ?" if ws else "")
3749
4518
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
3750
- row = self._row(db.execute(self.SELECT + " WHERE q.id = ?",
3751
- (rid,)).fetchone())
4519
+ row = self._row(db.execute(sql, (rid, ws) if ws else (rid,)).fetchone())
3752
4520
  return row if self._readable(row) else None
3753
4521
 
3754
4522
  def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False):
@@ -3760,7 +4528,7 @@ class Queue:
3760
4528
  second (#238). `before` alone stops at the second. Each row is
3761
4529
  brief_row's unless `full` asks for the whole of it."""
3762
4530
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
3763
- rows = self._all(db)
4531
+ rows = self._all(db, workspace_of(self.store))
3764
4532
  key = lambda r: (r["submittedAt"], r["id"])
3765
4533
  rows = [r for r in rows if before is None or key(r) < (before, before_id or "")]
3766
4534
  rows.sort(key=key, reverse=True)
@@ -3791,13 +4559,13 @@ class Queue:
3791
4559
  total = len(run_items(run))
3792
4560
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
3793
4561
  db.execute("INSERT INTO queue (id, status, cancel, submitted_at, "
3794
- "snapshot, results, progress, totals, dataset, rerun_of, groups) "
3795
- "VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)",
4562
+ "snapshot, results, progress, totals, dataset, rerun_of, groups, workspace) "
4563
+ "VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
3796
4564
  (rid, "queued", now, json.dumps(run), "[]",
3797
4565
  json.dumps({"current": None, "n": 0, "total": total}),
3798
4566
  json.dumps({"ran": 0, "passed": 0, "found": 0, "of": 0}),
3799
4567
  None if dataset is None else json.dumps(dataset), rerun_of,
3800
- None if groups is None else json.dumps(groups)))
4568
+ None if groups is None else json.dumps(groups), workspace_of(self.store)))
3801
4569
  # Its prompts' uses, in the same transaction: a run is in the
3802
4570
  # library the moment it is queued, or not queued at all.
3803
4571
  if self.prompts is not None:
@@ -3963,12 +4731,15 @@ class Queue:
3963
4731
  return self.get(rid), None
3964
4732
 
3965
4733
  def clear(self):
3966
- """Empty the queue: every run, in every browser. The materialised
3967
- item files go too, so a cleared run leaves nothing behind to be
3968
- re-dequeued by a stray worker."""
4734
+ """Empty this workspace's History: its runs, in every browser. The
4735
+ materialised item files go too, so a cleared run leaves nothing behind
4736
+ to be re-dequeued by a stray worker. Another workspace's runs stay."""
4737
+ ws = workspace_of(self.store)
3969
4738
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
3970
- n = db.execute("DELETE FROM queue").rowcount
3971
- for d in self.dir.iterdir():
4739
+ gone = [r[0] for r in db.execute("SELECT id FROM queue WHERE workspace = ?", (ws,))]
4740
+ n = db.execute("DELETE FROM queue WHERE workspace = ?", (ws,)).rowcount
4741
+ for rid in gone:
4742
+ d = self.dir / rid
3972
4743
  if d.is_dir():
3973
4744
  (d / "cancel").unlink(missing_ok=True)
3974
4745
  shutil.rmtree(d, ignore_errors=True)
@@ -4364,12 +5135,19 @@ class Queue:
4364
5135
  if run is None:
4365
5136
  self._stop.wait(QUEUE_WAIT)
4366
5137
  continue
5138
+ # The worker grades every workspace's runs; while it grades this
5139
+ # one, the thread is bound to the run's workspace, so any dataset
5140
+ # or Source it resolves (a legacy row's pinned body) reads from
5141
+ # there, not the default (docs/workspaces.md).
5142
+ set_workspace(run.get("workspace"))
4367
5143
  try:
4368
5144
  self._execute(run)
4369
5145
  except Exception as e:
4370
5146
  # The type, never the message -- the relay's rule, for the
4371
5147
  # relay's reason: an exception message can carry a key.
4372
5148
  self._finish(run["id"], "failed", error=f"the worker failed: {type(e).__name__}")
5149
+ finally:
5150
+ set_workspace(None)
4373
5151
 
4374
5152
  def _dequeue(self):
4375
5153
  """The oldest queued run this server can read. One it cannot is never
@@ -4505,10 +5283,40 @@ def build_bundle(payload):
4505
5283
  return buf.getvalue(), None
4506
5284
 
4507
5285
 
5286
+ # Shared-by-choice enforcement (docs/workspaces.md): the one sentence the Run
5287
+ # bar turns into a Share link, and the profiles that earn it -- the run's
5288
+ # Target profiles not shared with workspace `ws`. A local profile reaches
5289
+ # nothing, so sharing does not gate it. Keyed by the run's profiles table,
5290
+ # whose keys are the profile ids the share set is written against.
5291
+ def not_shared_sentence(names, wsname: str) -> str:
5292
+ verb = "is" if len(names) == 1 else "are"
5293
+ return f"{', '.join(names)} {verb} not shared with {wsname}."
5294
+
5295
+
5296
+ def unshared_profiles(run: dict, ws):
5297
+ table = run.get("profiles") if isinstance(run, dict) else None
5298
+ if not isinstance(table, dict) or STORE is None:
5299
+ return []
5300
+ out = []
5301
+ for pid, conn in table.items():
5302
+ if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
5303
+ continue
5304
+ if not STORE.shared("profile", pid, ws):
5305
+ out.append({"id": pid, "name": (isinstance(conn, dict) and conn.get("name")) or pid})
5306
+ return out
5307
+
5308
+
4508
5309
  def worker_destinations(run: dict):
4509
5310
  table = run.get("profiles") if isinstance(run, dict) else None
4510
5311
  if not isinstance(table, dict):
4511
5312
  return None, "a run document carries its profiles as an object of id → connection"
5313
+ # The run grades in the workspace the thread is bound to -- the request's
5314
+ # at submit, the run's at dequeue/re-run (docs/workspaces.md). A profile
5315
+ # not shared with it is refused here, the seam every run path passes.
5316
+ ws = workspace_of(STORE) if STORE is not None else None
5317
+ bad = unshared_profiles(run, ws)
5318
+ if bad:
5319
+ return None, not_shared_sentence([b["name"] for b in bad], STORE.workspace_name(ws))
4512
5320
  stored = []
4513
5321
  if STORE is not None:
4514
5322
  doc = STORE.all().get("promptlab.profiles") or {}
@@ -4920,6 +5728,7 @@ def run_problems(run):
4920
5728
  # there was a store, and it stays usable without one being asked for.
4921
5729
  if DATA_DIR:
4922
5730
  STORE = Store(Path(DATA_DIR) / "lab.db")
5731
+ WORKSPACES = Workspaces(STORE)
4923
5732
  SOURCES = Sources(STORE)
4924
5733
  PROMPTS = Prompts(STORE)
4925
5734
  DATASETS = Datasets(STORE, PROMPTS)
@@ -4931,7 +5740,7 @@ if DATA_DIR:
4931
5740
  QUEUE = Queue(STORE)
4932
5741
  QUEUE.prompts = PROMPTS
4933
5742
  else:
4934
- STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
5743
+ STORE = WORKSPACES = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
4935
5744
 
4936
5745
 
4937
5746
  class NoRedirects(urllib.request.HTTPRedirectHandler):
@@ -5058,7 +5867,7 @@ class Handler(BaseHTTPRequestHandler):
5058
5867
  # Runs now execute on the server and nowhere else, so the page
5059
5868
  # says what this one is missing, instead of a request failing
5060
5869
  # oddly mid-run.
5061
- head += carried("labstate", {"docs": STORE.served(),
5870
+ head += carried("labstate", {"docs": STORE.served(self.ws),
5062
5871
  "runs": {"queue": QUEUE is not None, "node": NODE is not None,
5063
5872
  "convert": CONVERT is not None}})
5064
5873
  # The page marks where the carried data goes (web/index.html); the
@@ -5082,9 +5891,39 @@ class Handler(BaseHTTPRequestHandler):
5082
5891
  if self.grouped:
5083
5892
  self.path = "/api/datasets" + self.path[len(self.GROUPS_ROUTE):]
5084
5893
 
5894
+ # The workspace a request is in (docs/workspaces.md), the default when it
5895
+ # names none. Set it to the store's flagged default even with no store, so
5896
+ # the None-store guards below read a harmless value.
5897
+ ws = None
5898
+
5899
+ def _scope(self):
5900
+ """Resolve and bind the request's workspace: the `/w/<slug>` path
5901
+ prefix, else the `X-Workspace` header, else the flagged default. The
5902
+ prefix is stripped from self.path so every route below is addressed the
5903
+ same way inside a workspace or out of it -- `/w/<slug>` becomes `/`,
5904
+ which serves the SPA shell, and `/w/<slug>/api/...` becomes `/api/...`.
5905
+ An unknown slug falls back to the default; the page's Gone state for a
5906
+ vanished workspace is phase 3. The slug is a bookmarkable address, not a
5907
+ secret: isolation here is a filter, not access control."""
5908
+ raw = self.path
5909
+ qpos = raw.find("?")
5910
+ path, query = (raw[:qpos], raw[qpos:]) if qpos >= 0 else (raw, "")
5911
+ slug = None
5912
+ if path == "/w" or path.startswith("/w/"):
5913
+ slug, sep, tail = path[3:].partition("/")
5914
+ self.path = ("/" + tail if sep or tail else "/") + query
5915
+ ws = None
5916
+ if STORE is not None:
5917
+ named = slug or self.headers.get("X-Workspace")
5918
+ ws = STORE.workspace(named) if named else None
5919
+ ws = ws or STORE.default_ws
5920
+ self.ws = ws
5921
+ set_workspace(ws)
5922
+
5085
5923
  def do_GET(self):
5086
5924
  if not self._authorised():
5087
5925
  return
5926
+ self._scope()
5088
5927
  self._alias()
5089
5928
  path = self.path.split("?", 1)[0]
5090
5929
  # The lab is one page: a Connection and an Input make a scenario,
@@ -5120,7 +5959,7 @@ class Handler(BaseHTTPRequestHandler):
5120
5959
  if path == "/api/state":
5121
5960
  if STORE is None:
5122
5961
  return self._send(404, b"not found", "text/plain")
5123
- return self._json(200, {"docs": STORE.served()})
5962
+ return self._json(200, {"docs": STORE.served(self.ws)})
5124
5963
  # The run queue (#530): a run is a row the server owns, and History
5125
5964
  # reads the server. One run, or the list.
5126
5965
  if path == "/api/queue":
@@ -5139,7 +5978,7 @@ class Handler(BaseHTTPRequestHandler):
5139
5978
  # The bodies of the eval groups a run grades with, by `<id>@<n>`
5140
5979
  # (§17); null for a run that kept none, as /dataset answers.
5141
5980
  run_id = path.split("/")[3]
5142
- if QUEUE is None or QUEUE.get(run_id) is None:
5981
+ if QUEUE is None or QUEUE.get(run_id, self.ws) is None:
5143
5982
  return self._send(404, b"not found", "text/plain")
5144
5983
  return self._json(200, QUEUE.groups(run_id))
5145
5984
  if path.startswith("/api/queue/") and path.endswith("/dataset") and path.count("/") == 4:
@@ -5153,13 +5992,13 @@ class Handler(BaseHTTPRequestHandler):
5153
5992
  # time such a run was opened, which is the console people are
5154
5993
  # told to watch for real faults. A run that is not there is 404.
5155
5994
  run_id = path.split("/")[3]
5156
- if QUEUE is None or QUEUE.get(run_id) is None:
5995
+ if QUEUE is None or QUEUE.get(run_id, self.ws) is None:
5157
5996
  return self._send(404, b"not found", "text/plain")
5158
5997
  return self._json(200, QUEUE.dataset(run_id))
5159
5998
  if path.startswith("/api/queue/"):
5160
5999
  if QUEUE is None:
5161
6000
  return self._send(404, b"not found", "text/plain")
5162
- run = QUEUE.get(path[len("/api/queue/"):])
6001
+ run = QUEUE.get(path[len("/api/queue/"):], self.ws)
5163
6002
  if run is None:
5164
6003
  return self._send(404, b"not found", "text/plain")
5165
6004
  # How far back in the queue this submission sits, for the form's
@@ -5175,6 +6014,13 @@ class Handler(BaseHTTPRequestHandler):
5175
6014
  if PLUGINS is None:
5176
6015
  return self._send(404, b"not found", "text/plain")
5177
6016
  return self._json(200, {"plugins": PLUGINS.list(), "cap": PLUGIN_CAP})
6017
+ if path == "/api/connections/shares":
6018
+ # Which workspaces may use each Target profile and Account
6019
+ # (docs/workspaces.md): the share sets the Connections menus read.
6020
+ # Keyed by "<kind>:<id>", workspace ids and the sentinels, no key.
6021
+ if STORE is None:
6022
+ return self._send(404, b"not found", "text/plain")
6023
+ return self._json(200, {"shares": STORE.shares()})
5178
6024
  if path == "/api/connections":
5179
6025
  if CONNECTIONS is None:
5180
6026
  return self._send(404, b"not found", "text/plain")
@@ -5199,6 +6045,11 @@ class Handler(BaseHTTPRequestHandler):
5199
6045
  if PACKS is None:
5200
6046
  return self._send(404, b"not found", "text/plain")
5201
6047
  return self._json(200, {"packs": PACKS.list(), "cap": PACK_CAP})
6048
+ if path == "/api/workspaces":
6049
+ if WORKSPACES is None:
6050
+ return self._send(404, b"not found", "text/plain")
6051
+ WORKSPACES.lazy_trash()
6052
+ return self._json(200, {"workspaces": WORKSPACES.list()})
5202
6053
  if path.startswith("/api/samples/thumbs/") or path.startswith("/api/samples/zoom/"):
5203
6054
  # The sample-library tiles, store-independent: baked into the
5204
6055
  # image (or absent on a checkout), and read by the library strip,
@@ -5303,6 +6154,7 @@ class Handler(BaseHTTPRequestHandler):
5303
6154
  def do_DELETE(self):
5304
6155
  if not self._authorised() or not self._from_this_page():
5305
6156
  return
6157
+ self._scope()
5306
6158
  self._alias()
5307
6159
  path = self.path.split("?", 1)[0]
5308
6160
  if path == "/api/connections/google":
@@ -5326,6 +6178,14 @@ class Handler(BaseHTTPRequestHandler):
5326
6178
  if err:
5327
6179
  return self._json(err[0], {"error": err[1]})
5328
6180
  return self._json(200, {"trash": token})
6181
+ if path.startswith("/api/workspaces/"):
6182
+ parts = path.split("/")
6183
+ if WORKSPACES is None or len(parts) != 4:
6184
+ return self._send(404, b"not found", "text/plain")
6185
+ token, err = WORKSPACES.remove(parts[3])
6186
+ if err:
6187
+ return self._json(err[0], {"error": err[1]})
6188
+ return self._json(200, {"trash": token})
5329
6189
  if path.startswith("/api/datasets/"):
5330
6190
  parts = path.split("/")
5331
6191
  if DATASETS is None or len(parts) != 4:
@@ -5371,6 +6231,7 @@ class Handler(BaseHTTPRequestHandler):
5371
6231
  def do_PATCH(self):
5372
6232
  if not self._authorised() or not self._from_this_page():
5373
6233
  return
6234
+ self._scope()
5374
6235
  self._alias()
5375
6236
  parts = self.path.split("?", 1)[0].split("/")
5376
6237
  if len(parts) != 4 or parts[1] != "api":
@@ -5392,18 +6253,37 @@ class Handler(BaseHTTPRequestHandler):
5392
6253
  return self._json(200, source)
5393
6254
  if parts[2] == "queue" and QUEUE is not None:
5394
6255
  payload = self._payload() or {}
6256
+ if QUEUE.get(parts[3], self.ws) is None:
6257
+ return self._send(404, b"not found", "text/plain")
5395
6258
  run, err = QUEUE.set_comment(parts[3], payload.get("comment"))
5396
6259
  if err:
5397
6260
  code, message = err
5398
6261
  return self._json(code, {"error": message})
5399
6262
  return self._json(200, run)
6263
+ if parts[2] == "workspaces" and WORKSPACES is not None:
6264
+ payload = self._payload() or {}
6265
+ wid, err = WORKSPACES.rename(parts[3], payload.get("name"))
6266
+ if err:
6267
+ return self._json(err[0], {"error": err[1]})
6268
+ return self._json(200, {"workspace": WORKSPACES.get(wid), "workspaces": WORKSPACES.list()})
5400
6269
  return self._send(404, b"not found", "text/plain")
5401
6270
 
5402
6271
  def do_PUT(self):
5403
6272
  if not self._authorised() or not self._from_this_page():
5404
6273
  return
6274
+ self._scope()
5405
6275
  self._alias()
5406
6276
  parts = self.path.split("?", 1)[0].split("/")
6277
+ if parts == ["", "api", "connections", "shares"] and STORE is not None:
6278
+ # A connection's whole share set, replaced (docs/workspaces.md): the
6279
+ # Connections checkbox menu's All / list / New workspaces. The reply
6280
+ # is the whole map, so every menu redraws from one response.
6281
+ payload = self._payload() or {}
6282
+ target = self._share_target(payload)
6283
+ if target is None:
6284
+ return self._json(400, {"error": "a share names a kind (profile or account) and an id"})
6285
+ STORE.set_shares(target[0], target[1], payload.get("workspaces") or [])
6286
+ return self._json(200, {"shares": STORE.shares()})
5407
6287
  if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
5408
6288
  return self._prompts_put(parts[3])
5409
6289
  if parts == ["", "api", "google"] and GOOGLE is not None:
@@ -5465,6 +6345,7 @@ class Handler(BaseHTTPRequestHandler):
5465
6345
  return self._json(500, {"error": f"the relay failed: {type(e).__name__}"})
5466
6346
 
5467
6347
  def _post(self):
6348
+ self._scope()
5468
6349
  self._alias()
5469
6350
  path = self.path.split("?", 1)[0]
5470
6351
  if path == "/api/state":
@@ -5477,8 +6358,22 @@ class Handler(BaseHTTPRequestHandler):
5477
6358
  return self._datasets_post(path)
5478
6359
  if path == "/api/packs" or path.startswith("/api/packs/"):
5479
6360
  return self._packs_post(path)
6361
+ if path == "/api/workspaces" or path.startswith("/api/workspaces/"):
6362
+ return self._workspaces_post(path)
5480
6363
  if path == "/api/plugins" or path.startswith("/api/plugins/"):
5481
6364
  return self._plugins_post(path)
6365
+ if path == "/api/connections/share":
6366
+ # The Run bar's Share link, and its Undo (docs/workspaces.md): the
6367
+ # current workspace into (or, undo, out of) a connection's share
6368
+ # set, at once. The reply is the whole map, as the menu's PUT is.
6369
+ if STORE is None:
6370
+ return self._send(404, b"not found", "text/plain")
6371
+ payload = self._payload() or {}
6372
+ target = self._share_target(payload)
6373
+ if target is None:
6374
+ return self._json(400, {"error": "a share names a kind (profile or account) and an id"})
6375
+ STORE.share_with(target[0], target[1], self.ws, payload.get("undo") is not True)
6376
+ return self._json(200, {"shares": STORE.shares()})
5482
6377
  if path == "/api/connections/google/start":
5483
6378
  return self._google_start()
5484
6379
  if path == "/api/prompts" or path.startswith("/api/prompts/"):
@@ -5500,6 +6395,51 @@ class Handler(BaseHTTPRequestHandler):
5500
6395
  return self._models_ask(payload)
5501
6396
  return self._send(404, b"not found", "text/plain")
5502
6397
 
6398
+ def _workspaces_post(self, path):
6399
+ """Create, archive, unarchive, Regenerate, set the default and restore,
6400
+ as the registry API (docs/other-tabs.md). Each mutation answers with the affected
6401
+ workspace and the whole list, so the Manage table and its counts
6402
+ redraw from one response; delete is a DELETE and answers with a trash
6403
+ token for Undo."""
6404
+ if WORKSPACES is None:
6405
+ return self._send(404, b"not found", "text/plain")
6406
+ parts = path.split("/")
6407
+ said = lambda wid: self._json(200, {"workspace": WORKSPACES.get(wid), "workspaces": WORKSPACES.list()})
6408
+ if len(parts) == 3:
6409
+ payload = self._payload() or {}
6410
+ # The New-workspace dialog's Connections picker (docs/workspaces.md):
6411
+ # the connections the new workspace may use, each written a concrete
6412
+ # share row; with none named, the SHARE_NEW set is materialised.
6413
+ shares = payload.get("connections") if isinstance(payload.get("connections"), list) else None
6414
+ wid, err = WORKSPACES.create(payload.get("name"), payload.get("slug"), shares)
6415
+ if err:
6416
+ return self._json(err[0], {"error": err[1]})
6417
+ # Starting content (docs/workspaces.md): the demo pack lands in the
6418
+ # new workspace, not the one the request is in, so bind the thread
6419
+ # to it for the install and bind it back after.
6420
+ if payload.get("content") == "demo" and PACKS is not None:
6421
+ set_workspace(wid)
6422
+ try:
6423
+ PACKS.install(demo_pack())
6424
+ finally:
6425
+ set_workspace(self.ws)
6426
+ return said(wid)
6427
+ if len(parts) == 6 and parts[3] == "trash" and parts[5] == "restore":
6428
+ self._payload()
6429
+ wid, err = WORKSPACES.restore(parts[4])
6430
+ if err:
6431
+ return self._json(err[0], {"error": err[1]})
6432
+ return said(wid)
6433
+ if len(parts) == 5 and parts[4] in ("archive", "unarchive", "regenerate", "default"):
6434
+ self._payload()
6435
+ act = {"archive": WORKSPACES.archive, "unarchive": WORKSPACES.unarchive,
6436
+ "regenerate": WORKSPACES.regenerate, "default": WORKSPACES.set_default}[parts[4]]
6437
+ wid, err = act(parts[3])
6438
+ if err:
6439
+ return self._json(err[0], {"error": err[1]})
6440
+ return said(wid)
6441
+ return self._send(404, b"not found", "text/plain")
6442
+
5503
6443
  def _packs_post(self, path):
5504
6444
  if PACKS is None:
5505
6445
  return self._send(404, b"not found", "text/plain")
@@ -5561,7 +6501,44 @@ class Handler(BaseHTTPRequestHandler):
5561
6501
  return self._json(err[0], {"error": err[1]})
5562
6502
  return self._json(201 if got["installed"] else 200, got)
5563
6503
 
6504
+ def _share_target(self, payload):
6505
+ """(kind, id) when `payload` names a shareable connection (kind profile
6506
+ or account, a non-empty id), else None -- the caller answers 400. The
6507
+ two kinds are the ones that carry a Workspaces field (docs/workspaces.md)."""
6508
+ kind, cid = payload.get("kind"), payload.get("id")
6509
+ return (kind, cid) if kind in ("profile", "account") and isinstance(cid, str) and cid else None
6510
+
6511
+ def _unshared(self, payload):
6512
+ """A saved Target profile a relayed request names by its held key, not
6513
+ shared with this request's workspace (docs/workspaces.md): (id, name),
6514
+ else None. Only a held key (KEY_HELD<id>) names a stored profile; a key
6515
+ typed into the Setup form is a connection not yet saved, so nothing
6516
+ gates it. This is the relay/Test seam, beside worker_destinations' run
6517
+ seam -- the two places a connection resolves to a key, so the two
6518
+ places sharing is enforced. The caller writes the 403: a helper that
6519
+ answers here would send the body and still fall through to the proxy."""
6520
+ if STORE is None:
6521
+ return None
6522
+ raw = str((payload or {}).get("key") or "")
6523
+ if not held(raw):
6524
+ return None
6525
+ pid = raw[len(KEY_HELD):]
6526
+ if STORE.shared("profile", pid, self.ws):
6527
+ return None
6528
+ return pid, STORE.profile_name(pid)
6529
+
6530
+ def _refuse_unshared(self, payload):
6531
+ """The 403 a relay seam answers when `payload` names an unshared
6532
+ profile, else None (nothing written)."""
6533
+ u = self._unshared(payload)
6534
+ if u is None:
6535
+ return False
6536
+ return self._json(403, {"error": not_shared_sentence([u[1]], STORE.workspace_name(self.ws)),
6537
+ "unshared": [{"kind": "profile", "id": u[0], "name": u[1]}]}) or True
6538
+
5564
6539
  def _models_list(self, payload):
6540
+ if self._refuse_unshared(payload):
6541
+ return
5565
6542
  base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
5566
6543
  key = relay_key(payload)
5567
6544
  ctype = str(payload.get("type") or "")
@@ -5613,6 +6590,8 @@ class Handler(BaseHTTPRequestHandler):
5613
6590
  # (evals-core.ts's connectionRequest); the server decides where it goes
5614
6591
  # and how the key travels, per the type -- the same mirror the models
5615
6592
  # list uses, so a client cannot point the relay at a path of its own.
6593
+ if self._refuse_unshared(payload):
6594
+ return
5616
6595
  base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
5617
6596
  key = relay_key(payload)
5618
6597
  if not header_safe(key):
@@ -5647,7 +6626,7 @@ class Handler(BaseHTTPRequestHandler):
5647
6626
  if len(json.dumps(d.get("body"))) > MAX_DOC:
5648
6627
  return self._json(413, {"error": f"{name} is too large"})
5649
6628
  versions, stale = STORE.write({n: {"version": d["version"], "body": d.get("body")}
5650
- for n, d in docs.items()})
6629
+ for n, d in docs.items()}, self.ws)
5651
6630
  if stale is not None:
5652
6631
  return self._json(409, {"stale": stale})
5653
6632
  # A pin names a group's version, so the group keeps that version from
@@ -5686,6 +6665,14 @@ class Handler(BaseHTTPRequestHandler):
5686
6665
  if revs[name] is None:
5687
6666
  return self._json(400, {"error": f"{name!r} is not in that Source"})
5688
6667
  content["revs"] = revs
6668
+ # A profile not shared with this workspace blocks the run with the
6669
+ # sentence the Run bar shows and the connections it names, so its Share
6670
+ # link can grant them at once (docs/workspaces.md).
6671
+ ws = workspace_of(STORE) if STORE is not None else None
6672
+ bad = unshared_profiles(run, ws)
6673
+ if bad:
6674
+ return self._json(403, {"error": not_shared_sentence([b["name"] for b in bad], STORE.workspace_name(ws)),
6675
+ "unshared": [{"kind": "profile", **b} for b in bad]})
5689
6676
  _, why = worker_destinations(run)
5690
6677
  if why:
5691
6678
  return self._json(403, {"error": why})
@@ -5887,6 +6874,10 @@ class Handler(BaseHTTPRequestHandler):
5887
6874
  parts = path.split("/")
5888
6875
  rid = parts[3]
5889
6876
  action = parts[4] if len(parts) > 4 else ""
6877
+ # A run is reachable only from its own workspace: an id from another is
6878
+ # a run this workspace does not have (docs/workspaces.md).
6879
+ if QUEUE.get(rid, self.ws) is None:
6880
+ return self._send(404, b"not found", "text/plain")
5890
6881
  if action == "cancel":
5891
6882
  run, err = QUEUE.cancel(rid)
5892
6883
  elif action == "resume":
@@ -6011,9 +7002,10 @@ class Handler(BaseHTTPRequestHandler):
6011
7002
  if len(parts) == 3:
6012
7003
  payload = self._payload() or {}
6013
7004
  kind = payload.get("type", DEFAULT_SOURCE_TYPE)
6014
- # A type made with a sign-in (a flow, with Microsoft's) cannot be
6015
- # made in a lab without that sign-in.
6016
- sign_in = (SOURCE_TYPES.get(kind) or {}).get("signIn")
7005
+ # A platform made with a sign-in (Power Automate, with Microsoft's)
7006
+ # cannot be made in a lab without that sign-in; an unknown kind or
7007
+ # platform is refused by create() below with its own sentence.
7008
+ sign_in = (source_entry(kind, payload.get("config")) or {}).get("signIn")
6017
7009
  if sign_in and not SIGN_INS.get(sign_in, lambda: False)():
6018
7010
  return self._json(400, {"error": "this lab has no Microsoft app to sign in with (Setup › Connections)"
6019
7011
  if sign_in == "microsoft" else f"this lab has no {sign_in} sign-in"})
@@ -6163,6 +7155,9 @@ def main():
6163
7155
  missing.append("no ImageMagick")
6164
7156
  runs = "ready" if not missing else "CANNOT RUN: " + ", ".join(missing)
6165
7157
  print(f"runs: {runs}", flush=True)
7158
+ if WORKSPACES is not None:
7159
+ # The workspace trash's undo window is per-session, like the others.
7160
+ WORKSPACES.empty_trash()
6166
7161
  if DATASETS is not None:
6167
7162
  # A new lab starts with none: the Datasets tab offers New and Import.
6168
7163
  DATASETS.empty_trash()