evals-lab 0.7.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
@@ -513,6 +513,13 @@ def take_record(name, data):
513
513
 
514
514
  WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
515
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"}
522
+
516
523
  # The sign-ins a Source type may be made with, and whether this lab has each:
517
524
  # a type naming one this lab lacks cannot be made.
518
525
  SIGN_INS = {"microsoft": lambda: microsoft_config() is not None}
@@ -908,6 +915,357 @@ class Store:
908
915
  self._put(db, n, None if n in DOC_GLOBAL else ws, version, body, at)
909
916
  return {n: have(n)["version"] + 1 for n in docs}, None
910
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
+
911
1269
 
912
1270
  # A filename, the target filesystem's view rather than the caller's: a
913
1271
  # basename is all that survives, restricted to characters that filesystem
@@ -2449,8 +2807,11 @@ class Datasets:
2449
2807
  newest one's number, which a pin may name, and 1 before any is kept,
2450
2808
  the row's body being version 1 in waiting."""
2451
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.
2452
2812
  out = {"id": r["id"], "name": r["name"], "cases": len(parsed.get("cases") or []),
2453
- "version": r["version"], "versions": versions, "updated": r["updated_at"]}
2813
+ "version": r["version"], "versions": versions, "updated": r["updated_at"],
2814
+ "grader": parsed.get("grader")}
2454
2815
  if body:
2455
2816
  out["body"] = parsed
2456
2817
  return out
@@ -3328,7 +3689,7 @@ PLUGIN_VERSIONS = (1,)
3328
3689
  PLUGIN_CAP = int(os.environ.get("PLUGIN_CAP", str(16 * 1024 ** 2)))
3329
3690
  PLUGIN_VERSION_TEXT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
3330
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)")
3331
- REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes")
3692
+ REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes", "workflowPlatforms", "wizards")
3332
3693
  # evalTypes as a manifest written before pipeline version 11 spells it: a
3333
3694
  # plugin's own file, which the lab cannot upgrade, so it is read for good.
3334
3695
  OLD_REGISTRIES = {"testTypes": "evalTypes"}
@@ -3382,7 +3743,7 @@ def read_plugin(data: bytes):
3382
3743
  reg = m.get("registers") or {}
3383
3744
  if not isinstance(reg, dict) or any(k not in REGISTRIES and k not in OLD_REGISTRIES for k in reg):
3384
3745
  return None, f"a plugin's registers are {', '.join(REGISTRIES)}"
3385
- for k in ("outputKinds", "modifiers", "evalTypes", *OLD_REGISTRIES):
3746
+ for k in ("outputKinds", "modifiers", "evalTypes", "wizards", *OLD_REGISTRIES):
3386
3747
  if not isinstance(reg.get(k, []), list) or not all(isinstance(x, str) and x for x in reg.get(k, [])):
3387
3748
  return None, f"registers.{k} is a list of ids"
3388
3749
  conns = reg.get("connectionTypes", [])
@@ -3397,6 +3758,25 @@ def read_plugin(data: bytes):
3397
3758
  or c.get("auth", "bearer") not in AUTH_WAYS):
3398
3759
  return None, ("a connection type the plugin registers is { id, settings, chatPath, "
3399
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")
3400
3780
  why = pack_requires_problem({"requires": {"lab": (m.get("requires") or {}).get("lab")}}, set())
3401
3781
  if why:
3402
3782
  return None, why.replace("the pack", "the plugin")
@@ -3406,8 +3786,9 @@ def read_plugin(data: bytes):
3406
3786
  def registered_ids(manifest: dict) -> set:
3407
3787
  """(registry, id) for everything a plugin's manifest says it registers."""
3408
3788
  reg = plugin_registers(manifest)
3409
- out = {(k, x) for k in ("outputKinds", "modifiers", "evalTypes") for x in reg.get(k, [])}
3410
- 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", [])}
3411
3792
 
3412
3793
 
3413
3794
  # ---- Connections: the lab's grants to outside services (#127) --------------
@@ -3738,20 +4119,35 @@ class Plugins:
3738
4119
  return [dict(r) for r in db.execute("SELECT * FROM plugins ORDER BY id")]
3739
4120
 
3740
4121
  def apply(self):
3741
- """The server's connection-type mirror: the built-in types and every
3742
- 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."""
3743
4125
  types, paths, auth = dict(BUILTIN_CONNECTION_TYPES), dict(BUILTIN_CHAT_PATHS), dict(BUILTIN_AUTH)
3744
4126
  local = set(BUILTIN_LOCAL)
4127
+ platforms = {k: dict(v) for k, v in BUILTIN_WORKFLOW_PLATFORMS.items()}
3745
4128
  for r in self._rows():
3746
- 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", []):
3747
4131
  types[c["id"]] = tuple(c.get("settings", []))
3748
4132
  paths[c["id"]] = c.get("chatPath", "/chat/completions")
3749
4133
  auth[c["id"]] = c.get("auth", "bearer")
3750
4134
  if c.get("local") is True:
3751
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")}
3752
4147
  LOCAL_CONNECTIONS.clear()
3753
4148
  LOCAL_CONNECTIONS.update(local)
3754
- 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)):
3755
4151
  table.clear()
3756
4152
  table.update(value)
3757
4153
 
@@ -3801,9 +4197,13 @@ class Plugins:
3801
4197
  if same and same["sha256"] == plugin["sha256"]:
3802
4198
  return {"plugin": m["id"], "installed": False}, None
3803
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")}
3804
4203
  for (reg, x) in mine:
3805
- if reg == "connectionTypes" and x in BUILTIN_CONNECTION_TYPES:
3806
- 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")
3807
4207
  for r in rows:
3808
4208
  if r["id"] == m["id"]:
3809
4209
  continue
@@ -4883,10 +5283,40 @@ def build_bundle(payload):
4883
5283
  return buf.getvalue(), None
4884
5284
 
4885
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
+
4886
5309
  def worker_destinations(run: dict):
4887
5310
  table = run.get("profiles") if isinstance(run, dict) else None
4888
5311
  if not isinstance(table, dict):
4889
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))
4890
5320
  stored = []
4891
5321
  if STORE is not None:
4892
5322
  doc = STORE.all().get("promptlab.profiles") or {}
@@ -5298,6 +5728,7 @@ def run_problems(run):
5298
5728
  # there was a store, and it stays usable without one being asked for.
5299
5729
  if DATA_DIR:
5300
5730
  STORE = Store(Path(DATA_DIR) / "lab.db")
5731
+ WORKSPACES = Workspaces(STORE)
5301
5732
  SOURCES = Sources(STORE)
5302
5733
  PROMPTS = Prompts(STORE)
5303
5734
  DATASETS = Datasets(STORE, PROMPTS)
@@ -5309,7 +5740,7 @@ if DATA_DIR:
5309
5740
  QUEUE = Queue(STORE)
5310
5741
  QUEUE.prompts = PROMPTS
5311
5742
  else:
5312
- STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
5743
+ STORE = WORKSPACES = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
5313
5744
 
5314
5745
 
5315
5746
  class NoRedirects(urllib.request.HTTPRedirectHandler):
@@ -5583,6 +6014,13 @@ class Handler(BaseHTTPRequestHandler):
5583
6014
  if PLUGINS is None:
5584
6015
  return self._send(404, b"not found", "text/plain")
5585
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()})
5586
6024
  if path == "/api/connections":
5587
6025
  if CONNECTIONS is None:
5588
6026
  return self._send(404, b"not found", "text/plain")
@@ -5607,6 +6045,11 @@ class Handler(BaseHTTPRequestHandler):
5607
6045
  if PACKS is None:
5608
6046
  return self._send(404, b"not found", "text/plain")
5609
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()})
5610
6053
  if path.startswith("/api/samples/thumbs/") or path.startswith("/api/samples/zoom/"):
5611
6054
  # The sample-library tiles, store-independent: baked into the
5612
6055
  # image (or absent on a checkout), and read by the library strip,
@@ -5735,6 +6178,14 @@ class Handler(BaseHTTPRequestHandler):
5735
6178
  if err:
5736
6179
  return self._json(err[0], {"error": err[1]})
5737
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})
5738
6189
  if path.startswith("/api/datasets/"):
5739
6190
  parts = path.split("/")
5740
6191
  if DATASETS is None or len(parts) != 4:
@@ -5809,6 +6260,12 @@ class Handler(BaseHTTPRequestHandler):
5809
6260
  code, message = err
5810
6261
  return self._json(code, {"error": message})
5811
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()})
5812
6269
  return self._send(404, b"not found", "text/plain")
5813
6270
 
5814
6271
  def do_PUT(self):
@@ -5817,6 +6274,16 @@ class Handler(BaseHTTPRequestHandler):
5817
6274
  self._scope()
5818
6275
  self._alias()
5819
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()})
5820
6287
  if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
5821
6288
  return self._prompts_put(parts[3])
5822
6289
  if parts == ["", "api", "google"] and GOOGLE is not None:
@@ -5891,8 +6358,22 @@ class Handler(BaseHTTPRequestHandler):
5891
6358
  return self._datasets_post(path)
5892
6359
  if path == "/api/packs" or path.startswith("/api/packs/"):
5893
6360
  return self._packs_post(path)
6361
+ if path == "/api/workspaces" or path.startswith("/api/workspaces/"):
6362
+ return self._workspaces_post(path)
5894
6363
  if path == "/api/plugins" or path.startswith("/api/plugins/"):
5895
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()})
5896
6377
  if path == "/api/connections/google/start":
5897
6378
  return self._google_start()
5898
6379
  if path == "/api/prompts" or path.startswith("/api/prompts/"):
@@ -5914,6 +6395,51 @@ class Handler(BaseHTTPRequestHandler):
5914
6395
  return self._models_ask(payload)
5915
6396
  return self._send(404, b"not found", "text/plain")
5916
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
+
5917
6443
  def _packs_post(self, path):
5918
6444
  if PACKS is None:
5919
6445
  return self._send(404, b"not found", "text/plain")
@@ -5975,7 +6501,44 @@ class Handler(BaseHTTPRequestHandler):
5975
6501
  return self._json(err[0], {"error": err[1]})
5976
6502
  return self._json(201 if got["installed"] else 200, got)
5977
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
+
5978
6539
  def _models_list(self, payload):
6540
+ if self._refuse_unshared(payload):
6541
+ return
5979
6542
  base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
5980
6543
  key = relay_key(payload)
5981
6544
  ctype = str(payload.get("type") or "")
@@ -6027,6 +6590,8 @@ class Handler(BaseHTTPRequestHandler):
6027
6590
  # (evals-core.ts's connectionRequest); the server decides where it goes
6028
6591
  # and how the key travels, per the type -- the same mirror the models
6029
6592
  # list uses, so a client cannot point the relay at a path of its own.
6593
+ if self._refuse_unshared(payload):
6594
+ return
6030
6595
  base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
6031
6596
  key = relay_key(payload)
6032
6597
  if not header_safe(key):
@@ -6100,6 +6665,14 @@ class Handler(BaseHTTPRequestHandler):
6100
6665
  if revs[name] is None:
6101
6666
  return self._json(400, {"error": f"{name!r} is not in that Source"})
6102
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]})
6103
6676
  _, why = worker_destinations(run)
6104
6677
  if why:
6105
6678
  return self._json(403, {"error": why})
@@ -6582,6 +7155,9 @@ def main():
6582
7155
  missing.append("no ImageMagick")
6583
7156
  runs = "ready" if not missing else "CANNOT RUN: " + ", ".join(missing)
6584
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()
6585
7161
  if DATASETS is not None:
6586
7162
  # A new lab starts with none: the Datasets tab offers New and Import.
6587
7163
  DATASETS.empty_trash()