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/CHANGELOG.md +68 -0
- package/bin/run.js +36 -5
- package/lab/VERSION +1 -1
- package/lab/evals-core.mjs +183 -34
- package/lab/metrics/builtin.mjs +76 -20
- package/lab/run-evals.js +8 -4
- package/lab/server.py +588 -12
- package/lab/web/dist/assets/gallery-SnUhXRBn.js +3 -0
- package/lab/web/dist/assets/main-Ca7o-nM0.css +1 -0
- package/lab/web/dist/assets/main-Dru4_P5G.js +20 -0
- package/lab/web/dist/assets/tokens-0az9gfTq.js +58 -0
- package/lab/web/dist/assets/tokens-CqWJKhOx.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-BsbUQC7Q.js +0 -3
- package/lab/web/dist/assets/main-BQL5j5oF.js +0 -20
- package/lab/web/dist/assets/main-Cza2gwQd.css +0 -1
- package/lab/web/dist/assets/tokens-C3kp9sWp.js +0 -61
- package/lab/web/dist/assets/tokens-CjqaFuXm.css +0 -1
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
|
-
|
|
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
|
|
3742
|
-
installed plugin's, rebuilt in place so every
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
3806
|
-
|
|
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()
|