ltcai 3.6.0 → 4.0.1
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/README.md +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +243 -4
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +55 -16
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "
|
|
17
|
+
MULTI_AGENT_VERSION = "4.0.1"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -279,10 +279,13 @@ class AgentRunResult:
|
|
|
279
279
|
retry_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
280
280
|
plan_review: Dict[str, Any] = field(default_factory=dict)
|
|
281
281
|
memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
|
|
282
|
+
# "simulation" = deterministic LLM-free runner; "llm" = model-driven (v4 runtime).
|
|
283
|
+
mode: str = "simulation"
|
|
282
284
|
|
|
283
285
|
def as_dict(self) -> Dict[str, Any]:
|
|
284
286
|
return {
|
|
285
287
|
"agent_id": self.agent_id,
|
|
288
|
+
"mode": self.mode,
|
|
286
289
|
"status": self.status,
|
|
287
290
|
"output": self.output,
|
|
288
291
|
"timeline": self.timeline,
|
|
@@ -403,7 +406,221 @@ def default_role_runner(
|
|
|
403
406
|
ctx.output = ctx.output or f"Released outcome for: {ctx.goal}"
|
|
404
407
|
return {"role": role, "released": True, "summary": ctx.output}
|
|
405
408
|
|
|
406
|
-
return {
|
|
409
|
+
return {
|
|
410
|
+
"role": role,
|
|
411
|
+
"status": "skipped",
|
|
412
|
+
"reason": "this role has no deterministic behaviour (custom agents require a loaded model)",
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return runner
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _extract_json_object(raw: str) -> Dict[str, Any]:
|
|
419
|
+
"""Parse one JSON object out of an LLM response (fences/prose tolerated)."""
|
|
420
|
+
import json as _json
|
|
421
|
+
import re as _re
|
|
422
|
+
|
|
423
|
+
text = str(raw or "").strip()
|
|
424
|
+
fenced = _re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=_re.DOTALL)
|
|
425
|
+
if fenced:
|
|
426
|
+
text = fenced.group(1)
|
|
427
|
+
elif not text.startswith("{"):
|
|
428
|
+
start, end = text.find("{"), text.rfind("}")
|
|
429
|
+
if start >= 0 and end > start:
|
|
430
|
+
text = text[start : end + 1]
|
|
431
|
+
parsed = _json.loads(text)
|
|
432
|
+
if not isinstance(parsed, dict):
|
|
433
|
+
raise ValueError("model returned JSON that is not an object")
|
|
434
|
+
return parsed
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def llm_role_runner(
|
|
438
|
+
*,
|
|
439
|
+
generate: Callable[..., str],
|
|
440
|
+
planner_prompt: str,
|
|
441
|
+
critic_prompt: str,
|
|
442
|
+
context_provider: Optional[Callable[[str], List[str]]] = None,
|
|
443
|
+
workflow_runner: Optional[Callable[..., Any]] = None,
|
|
444
|
+
plugin_runner: Optional[Callable[..., Any]] = None,
|
|
445
|
+
custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
446
|
+
) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
|
|
447
|
+
"""Model-driven role runner — the real Multi-Agent Runtime (T7b).
|
|
448
|
+
|
|
449
|
+
``generate(message, context, max_tokens, temperature) -> str`` is a
|
|
450
|
+
synchronous bridge to the loaded model. Honesty contract (design-review
|
|
451
|
+
amendment): when the model responds but its plan/critique cannot be
|
|
452
|
+
parsed, the RUN FAILS with the raw output preserved in the records —
|
|
453
|
+
it never silently falls back to fabricated deterministic artifacts.
|
|
454
|
+
"""
|
|
455
|
+
base = default_role_runner(
|
|
456
|
+
workflow_runner=workflow_runner,
|
|
457
|
+
plugin_runner=plugin_runner,
|
|
458
|
+
context_provider=context_provider,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _fail(ctx: OrchestrationContext, role: str, reason: str, raw: str) -> Dict[str, Any]:
|
|
462
|
+
ctx.inputs["__llm_failure__"] = {"role": role, "reason": reason, "raw": raw[:2000]}
|
|
463
|
+
ctx.review = {
|
|
464
|
+
"outcome": "reject",
|
|
465
|
+
"verdict": "fail",
|
|
466
|
+
"reason": f"{role}: {reason}",
|
|
467
|
+
"raw_output": raw[:2000],
|
|
468
|
+
"reviewed_at": _now(),
|
|
469
|
+
}
|
|
470
|
+
return {"role": role, "status": "error", "reason": reason, "raw": raw[:2000]}
|
|
471
|
+
|
|
472
|
+
def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
473
|
+
failure = ctx.inputs.get("__llm_failure__")
|
|
474
|
+
|
|
475
|
+
custom = (custom_agents or {}).get(role)
|
|
476
|
+
if custom is not None:
|
|
477
|
+
# Executable registry entry (T7e): the agent's persisted config
|
|
478
|
+
# (system_prompt, max_tokens, temperature) is actually loaded.
|
|
479
|
+
cfg = custom.get("config") or {}
|
|
480
|
+
system = str(
|
|
481
|
+
cfg.get("system_prompt")
|
|
482
|
+
or custom.get("description")
|
|
483
|
+
or f"You are {custom.get('name') or role}."
|
|
484
|
+
)
|
|
485
|
+
try:
|
|
486
|
+
out = str(generate(
|
|
487
|
+
ctx.output or ctx.goal,
|
|
488
|
+
context=system,
|
|
489
|
+
max_tokens=int(cfg.get("max_tokens") or 1024),
|
|
490
|
+
temperature=float(cfg.get("temperature") or 0.2),
|
|
491
|
+
))
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
return _fail(ctx, role, f"custom agent generation failed ({exc})", "")
|
|
494
|
+
ctx.output = out
|
|
495
|
+
return {"role": role, "agent": custom.get("name"), "status": "ok",
|
|
496
|
+
"output": out[:2000]}
|
|
497
|
+
|
|
498
|
+
if role == "planner":
|
|
499
|
+
research = "\n".join(f"- {item}" for item in (ctx.research or [])[:8])
|
|
500
|
+
raw = generate(
|
|
501
|
+
"Produce a JSON execution plan for this goal. Respond with one JSON "
|
|
502
|
+
'object: {"goal": str, "steps": [{"description": str}, ...]} and nothing else.',
|
|
503
|
+
context=f"{planner_prompt}\n\nGoal: {ctx.goal}\n\nKnown context:\n{research}",
|
|
504
|
+
max_tokens=1024,
|
|
505
|
+
temperature=0.1,
|
|
506
|
+
)
|
|
507
|
+
try:
|
|
508
|
+
parsed = _extract_json_object(str(raw))
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
return _fail(ctx, role, f"plan output unparseable ({exc})", str(raw))
|
|
511
|
+
steps = []
|
|
512
|
+
for i, step in enumerate(parsed.get("steps") or []):
|
|
513
|
+
description = step.get("description") if isinstance(step, dict) else str(step)
|
|
514
|
+
steps.append({"index": i, "description": str(description or f"Step {i + 1}"), "status": "planned"})
|
|
515
|
+
if not steps:
|
|
516
|
+
return _fail(ctx, role, "model returned a plan with no steps", str(raw))
|
|
517
|
+
if ctx.inputs.get("workflow"):
|
|
518
|
+
steps[0]["workflow"] = ctx.inputs.get("workflow")
|
|
519
|
+
if ctx.inputs.get("plugin"):
|
|
520
|
+
steps[0]["plugin"] = ctx.inputs.get("plugin")
|
|
521
|
+
ctx.plan = steps
|
|
522
|
+
ctx.plan_id = f"plan-{abs(hash((ctx.goal, len(steps)))) % 10_000_000}"
|
|
523
|
+
ctx.plan_review = {
|
|
524
|
+
"plan_id": ctx.plan_id,
|
|
525
|
+
"outcome": "approve",
|
|
526
|
+
"reason": "model-generated plan parsed and bounded",
|
|
527
|
+
"reviewed_at": _now(),
|
|
528
|
+
}
|
|
529
|
+
return {"role": role, "plan_id": ctx.plan_id, "steps": len(steps), "plan": steps, "plan_review": ctx.plan_review}
|
|
530
|
+
|
|
531
|
+
if role == "executor":
|
|
532
|
+
if failure:
|
|
533
|
+
return {"role": role, "status": "error", "reason": f"skipped — {failure['role']} failed"}
|
|
534
|
+
results = []
|
|
535
|
+
for step in ctx.plan:
|
|
536
|
+
outcome: Dict[str, Any] = {"index": step["index"], "description": step["description"]}
|
|
537
|
+
wf = step.get("workflow")
|
|
538
|
+
pl = step.get("plugin")
|
|
539
|
+
if wf and workflow_runner is not None:
|
|
540
|
+
try:
|
|
541
|
+
outcome["workflow_result"] = workflow_runner(wf, ctx)
|
|
542
|
+
ctx.workflow_outputs.append(outcome["workflow_result"])
|
|
543
|
+
except Exception as exc:
|
|
544
|
+
outcome["workflow_error"] = str(exc)
|
|
545
|
+
if pl and plugin_runner is not None:
|
|
546
|
+
try:
|
|
547
|
+
outcome["plugin_result"] = plugin_runner(pl, ctx)
|
|
548
|
+
ctx.plugin_outputs.append(outcome["plugin_result"])
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
outcome["plugin_error"] = str(exc)
|
|
551
|
+
try:
|
|
552
|
+
outcome["result"] = str(generate(
|
|
553
|
+
f"Execute this step and return the concrete result only.\n"
|
|
554
|
+
f"Goal: {ctx.goal}\nStep: {step['description']}",
|
|
555
|
+
context="",
|
|
556
|
+
max_tokens=1024,
|
|
557
|
+
temperature=0.2,
|
|
558
|
+
))[:4000]
|
|
559
|
+
except Exception as exc:
|
|
560
|
+
outcome["error"] = str(exc)
|
|
561
|
+
if outcome.get("workflow_error") or outcome.get("plugin_error") or outcome.get("error"):
|
|
562
|
+
step["status"] = "failed"
|
|
563
|
+
outcome["status"] = "error"
|
|
564
|
+
else:
|
|
565
|
+
step["status"] = "done"
|
|
566
|
+
outcome["status"] = "done"
|
|
567
|
+
results.append(outcome)
|
|
568
|
+
ctx.executed = results
|
|
569
|
+
done = [r for r in results if r.get("status") == "done"]
|
|
570
|
+
ctx.output = "\n\n".join(str(r.get("result") or "") for r in done).strip() or (
|
|
571
|
+
f"Completed {len(done)}/{len(results)} step(s) for: {ctx.goal}"
|
|
572
|
+
)
|
|
573
|
+
return {"role": role, "executed": len(results), "results": results}
|
|
574
|
+
|
|
575
|
+
if role == "reviewer":
|
|
576
|
+
if failure:
|
|
577
|
+
# Fail-closed: an upstream unparseable model output means this
|
|
578
|
+
# run is failed, with the raw output preserved — never rescued
|
|
579
|
+
# by a rubber-stamp review.
|
|
580
|
+
ctx.review = {
|
|
581
|
+
"outcome": "reject",
|
|
582
|
+
"verdict": "fail",
|
|
583
|
+
"reason": f"{failure['role']} output unparseable",
|
|
584
|
+
"raw_output": failure.get("raw"),
|
|
585
|
+
"reviewed_at": _now(),
|
|
586
|
+
}
|
|
587
|
+
return {"role": role, **ctx.review}
|
|
588
|
+
raw = generate(
|
|
589
|
+
"Review this execution. Respond with one JSON object: "
|
|
590
|
+
'{"approve": bool, "reason": str} and nothing else.',
|
|
591
|
+
context=(
|
|
592
|
+
f"{critic_prompt}\n\nGoal: {ctx.goal}\n\n"
|
|
593
|
+
f"Steps: {[s.get('status') for s in ctx.plan]}\n\nOutput:\n{(ctx.output or '')[:3000]}"
|
|
594
|
+
),
|
|
595
|
+
max_tokens=512,
|
|
596
|
+
temperature=0.1,
|
|
597
|
+
)
|
|
598
|
+
try:
|
|
599
|
+
parsed = _extract_json_object(str(raw))
|
|
600
|
+
approve = bool(parsed.get("approve"))
|
|
601
|
+
reason = str(parsed.get("reason") or "")
|
|
602
|
+
except Exception as exc:
|
|
603
|
+
ctx.review = {
|
|
604
|
+
"outcome": "reject",
|
|
605
|
+
"verdict": "fail",
|
|
606
|
+
"reason": f"critic output unparseable ({exc})",
|
|
607
|
+
"raw_output": str(raw)[:2000],
|
|
608
|
+
"reviewed_at": _now(),
|
|
609
|
+
}
|
|
610
|
+
return {"role": role, **ctx.review}
|
|
611
|
+
ctx.review = {
|
|
612
|
+
"outcome": "approve" if approve else "retry",
|
|
613
|
+
"verdict": "pass" if approve else "retry",
|
|
614
|
+
"reason": reason or ("model approved the result" if approve else "model requested a retry"),
|
|
615
|
+
"confidence": 0.9 if approve else 0.4,
|
|
616
|
+
"notes": [],
|
|
617
|
+
"reviewed_at": _now(),
|
|
618
|
+
}
|
|
619
|
+
return {"role": role, **ctx.review}
|
|
620
|
+
|
|
621
|
+
# researcher / release / anything else: the deterministic behaviour is
|
|
622
|
+
# real work (memory recall, bookkeeping) — reuse it.
|
|
623
|
+
return base(role, ctx)
|
|
407
624
|
|
|
408
625
|
return runner
|
|
409
626
|
|
|
@@ -411,8 +628,21 @@ def default_role_runner(
|
|
|
411
628
|
class MultiAgentOrchestrator:
|
|
412
629
|
"""Drives a role pipeline with handoff, planning, review, and retry."""
|
|
413
630
|
|
|
414
|
-
def __init__(
|
|
631
|
+
def __init__(
|
|
632
|
+
self,
|
|
633
|
+
role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None,
|
|
634
|
+
mode: str = "simulation",
|
|
635
|
+
custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
636
|
+
):
|
|
415
637
|
self.role_runner = role_runner or default_role_runner()
|
|
638
|
+
# Executable registry entries (T7e): a requested role may be a
|
|
639
|
+
# registered custom agent id; its config (system_prompt, …) is
|
|
640
|
+
# actually loaded at run time — registration is no longer a UI illusion.
|
|
641
|
+
self.custom_agents = dict(custom_agents or {})
|
|
642
|
+
# Honest execution-mode label persisted on every run record. The
|
|
643
|
+
# built-in runner never calls a model, so the default is "simulation";
|
|
644
|
+
# an LLM-backed runner must declare mode="llm" explicitly.
|
|
645
|
+
self.mode = mode
|
|
416
646
|
|
|
417
647
|
def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
418
648
|
started = _now()
|
|
@@ -473,7 +703,10 @@ class MultiAgentOrchestrator:
|
|
|
473
703
|
workspace_id=workspace_id,
|
|
474
704
|
inputs=inputs or {},
|
|
475
705
|
)
|
|
476
|
-
pipeline = [
|
|
706
|
+
pipeline = [
|
|
707
|
+
r for r in (roles or list(CORE_PIPELINE))
|
|
708
|
+
if r in AGENT_ROLES or r in self.custom_agents
|
|
709
|
+
]
|
|
477
710
|
if not pipeline:
|
|
478
711
|
pipeline = list(CORE_PIPELINE)
|
|
479
712
|
max_retries = max(0, int(max_retries or 0))
|
|
@@ -558,4 +791,5 @@ class MultiAgentOrchestrator:
|
|
|
558
791
|
retry_history=ctx.retry_history,
|
|
559
792
|
plan_review=ctx.plan_review,
|
|
560
793
|
memory_snapshots=ctx.memory_snapshots,
|
|
794
|
+
mode=self.mode,
|
|
561
795
|
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Enforced community RBAC policy for Lattice AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Iterable, List, Set
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROLE_CAPABILITIES: Dict[str, Set[str]] = {
|
|
9
|
+
"owner": {"all"},
|
|
10
|
+
"admin": {
|
|
11
|
+
"admin:users",
|
|
12
|
+
"admin:roles",
|
|
13
|
+
"admin:policies",
|
|
14
|
+
"admin:audit",
|
|
15
|
+
"admin:security",
|
|
16
|
+
"workspace:read",
|
|
17
|
+
"workspace:write",
|
|
18
|
+
"workspace:manage",
|
|
19
|
+
"workspace:members",
|
|
20
|
+
"chat",
|
|
21
|
+
"search",
|
|
22
|
+
"files",
|
|
23
|
+
"pipeline",
|
|
24
|
+
},
|
|
25
|
+
"member": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
|
|
26
|
+
"user": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
|
|
27
|
+
"viewer": {"workspace:read", "chat", "search"},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_role(role: str) -> str:
|
|
32
|
+
role = str(role or "user").lower()
|
|
33
|
+
return role if role in ROLE_CAPABILITIES else "user"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def capabilities_for_role(role: str) -> List[str]:
|
|
37
|
+
caps = ROLE_CAPABILITIES.get(normalize_role(role), ROLE_CAPABILITIES["user"])
|
|
38
|
+
return sorted(caps)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def role_has_capability(role: str, capability: str) -> bool:
|
|
42
|
+
caps = ROLE_CAPABILITIES.get(normalize_role(role), ROLE_CAPABILITIES["user"])
|
|
43
|
+
return "all" in caps or capability in caps
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_capability(role: str, capability: str) -> None:
|
|
47
|
+
if not role_has_capability(role, capability):
|
|
48
|
+
raise PermissionError(f"role '{normalize_role(role)}' lacks capability '{capability}'")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
|
|
52
|
+
selected = list(roles or ROLE_CAPABILITIES.keys())
|
|
53
|
+
return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
|
|
54
|
+
|
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import asyncio
|
|
30
30
|
import json
|
|
31
|
+
import threading
|
|
31
32
|
from datetime import datetime
|
|
32
33
|
from typing import Any, AsyncIterator, Dict, List, Optional, Set
|
|
33
34
|
|
|
@@ -47,7 +48,7 @@ def sse_format(event: Dict[str, Any]) -> str:
|
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
class _Subscriber:
|
|
50
|
-
__slots__ = ("id", "queue", "workspace_scope", "user", "joined_at")
|
|
51
|
+
__slots__ = ("id", "queue", "workspace_scope", "user", "joined_at", "loop")
|
|
51
52
|
|
|
52
53
|
def __init__(self, sub_id: str, workspace_scope: Optional[Set[str]], user: Optional[str]):
|
|
53
54
|
self.id = sub_id
|
|
@@ -55,6 +56,10 @@ class _Subscriber:
|
|
|
55
56
|
self.workspace_scope = workspace_scope
|
|
56
57
|
self.user = user
|
|
57
58
|
self.joined_at = _now()
|
|
59
|
+
try:
|
|
60
|
+
self.loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
self.loop = None
|
|
58
63
|
|
|
59
64
|
def accepts(self, workspace_id: Optional[str]) -> bool:
|
|
60
65
|
# ``None`` scope = see everything the local user can (personal/unscoped).
|
|
@@ -73,6 +78,7 @@ class RealtimeBus:
|
|
|
73
78
|
self._feed: List[Dict[str, Any]] = []
|
|
74
79
|
self._presence: Dict[str, Dict[str, Any]] = {}
|
|
75
80
|
self._seq = 0
|
|
81
|
+
self._lock = threading.RLock()
|
|
76
82
|
|
|
77
83
|
# ── publishing ────────────────────────────────────────────────────────
|
|
78
84
|
|
|
@@ -82,34 +88,41 @@ class RealtimeBus:
|
|
|
82
88
|
Safe to call from sync code (e.g. the store's timeline hook). Never
|
|
83
89
|
raises and never blocks the caller.
|
|
84
90
|
"""
|
|
85
|
-
self.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
self._feed
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
sub.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sub.queue.get_nowait() # drop oldest
|
|
108
|
-
sub.queue.put_nowait(enriched)
|
|
109
|
-
except Exception:
|
|
110
|
-
pass
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._seq += 1
|
|
93
|
+
enriched = {
|
|
94
|
+
"seq": self._seq,
|
|
95
|
+
"received_at": _now(),
|
|
96
|
+
"area": event.get("area", "workspace"),
|
|
97
|
+
"event_type": event.get("event_type", "event"),
|
|
98
|
+
"workspace_id": event.get("workspace_id"),
|
|
99
|
+
"payload": event.get("payload", {}),
|
|
100
|
+
**{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
|
|
101
|
+
}
|
|
102
|
+
self._feed.append(enriched)
|
|
103
|
+
if len(self._feed) > _FEED_LIMIT:
|
|
104
|
+
self._feed = self._feed[-_FEED_LIMIT:]
|
|
105
|
+
|
|
106
|
+
workspace_id = enriched.get("workspace_id")
|
|
107
|
+
subscribers = [sub for sub in self._subscribers.values() if sub.accepts(workspace_id)]
|
|
108
|
+
for sub in subscribers:
|
|
109
|
+
if sub.loop is not None and sub.loop.is_running():
|
|
110
|
+
sub.loop.call_soon_threadsafe(self._enqueue, sub, enriched)
|
|
111
|
+
else:
|
|
112
|
+
self._enqueue(sub, enriched)
|
|
111
113
|
return enriched
|
|
112
114
|
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _enqueue(sub: _Subscriber, event: Dict[str, Any]) -> None:
|
|
117
|
+
try:
|
|
118
|
+
sub.queue.put_nowait(event)
|
|
119
|
+
except asyncio.QueueFull:
|
|
120
|
+
try:
|
|
121
|
+
sub.queue.get_nowait() # drop oldest
|
|
122
|
+
sub.queue.put_nowait(event)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
113
126
|
# The store calls ``event_sink(event)`` positionally; expose a stable alias.
|
|
114
127
|
def __call__(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
|
115
128
|
return self.publish(event)
|
|
@@ -118,11 +131,13 @@ class RealtimeBus:
|
|
|
118
131
|
|
|
119
132
|
def add_subscriber(self, sub_id: str, *, workspace_scope: Optional[Set[str]] = None, user: Optional[str] = None) -> _Subscriber:
|
|
120
133
|
sub = _Subscriber(sub_id, workspace_scope, user)
|
|
121
|
-
self.
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._subscribers[sub_id] = sub
|
|
122
136
|
return sub
|
|
123
137
|
|
|
124
138
|
def remove_subscriber(self, sub_id: str) -> None:
|
|
125
|
-
self.
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._subscribers.pop(sub_id, None)
|
|
126
141
|
|
|
127
142
|
async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]:
|
|
128
143
|
"""Yield SSE frames for a subscriber until the client disconnects.
|
|
@@ -146,7 +161,8 @@ class RealtimeBus:
|
|
|
146
161
|
# ── feed + presence ─────────────────────────────────────────────────────
|
|
147
162
|
|
|
148
163
|
def recent(self, *, limit: int = 50, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
|
|
149
|
-
|
|
164
|
+
with self._lock:
|
|
165
|
+
events = list(self._feed)
|
|
150
166
|
if workspace_scope is not None:
|
|
151
167
|
events = [e for e in events if e.get("workspace_id") is None or e.get("workspace_id") in workspace_scope]
|
|
152
168
|
return list(reversed(events[-max(1, min(limit, _FEED_LIMIT)):]))
|
|
@@ -159,32 +175,37 @@ class RealtimeBus:
|
|
|
159
175
|
"joined_at": _now(),
|
|
160
176
|
"last_seen": _now(),
|
|
161
177
|
}
|
|
162
|
-
self.
|
|
178
|
+
with self._lock:
|
|
179
|
+
self._presence[client_id] = record
|
|
163
180
|
self.publish({"area": "presence", "event_type": "join", "workspace_id": workspace_id, "payload": {"user": user, "client_id": client_id}})
|
|
164
181
|
return record
|
|
165
182
|
|
|
166
183
|
def heartbeat(self, client_id: str) -> Optional[Dict[str, Any]]:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
record
|
|
170
|
-
|
|
184
|
+
with self._lock:
|
|
185
|
+
record = self._presence.get(client_id)
|
|
186
|
+
if record:
|
|
187
|
+
record["last_seen"] = _now()
|
|
188
|
+
return record
|
|
171
189
|
|
|
172
190
|
def leave(self, client_id: str) -> None:
|
|
173
|
-
|
|
191
|
+
with self._lock:
|
|
192
|
+
record = self._presence.pop(client_id, None)
|
|
174
193
|
if record:
|
|
175
194
|
self.publish({"area": "presence", "event_type": "leave", "workspace_id": record.get("workspace_id"), "payload": {"client_id": client_id}})
|
|
176
195
|
|
|
177
196
|
def presence(self, *, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
|
|
178
|
-
|
|
197
|
+
with self._lock:
|
|
198
|
+
records = list(self._presence.values())
|
|
179
199
|
if workspace_scope is not None:
|
|
180
200
|
records = [r for r in records if r.get("workspace_id") is None or r.get("workspace_id") in workspace_scope]
|
|
181
201
|
return records
|
|
182
202
|
|
|
183
203
|
def stats(self) -> Dict[str, Any]:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
with self._lock:
|
|
205
|
+
return {
|
|
206
|
+
"version": REALTIME_VERSION,
|
|
207
|
+
"subscribers": len(self._subscribers),
|
|
208
|
+
"presence": len(self._presence),
|
|
209
|
+
"feed_size": len(self._feed),
|
|
210
|
+
"transport": "sse",
|
|
211
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
"""File-backed session store with sliding-window TTL.
|
|
1
|
+
"""File-backed session store with sliding-window TTL.
|
|
2
2
|
|
|
3
|
+
v4: bearer tokens are stored **hashed** (sha256) at rest — a process that can
|
|
4
|
+
read ``sessions.json`` must not be able to hijack every session. Pre-v4 files
|
|
5
|
+
holding raw tokens are migrated transparently on load (sessions survive the
|
|
6
|
+
upgrade; the raw token never touches disk again).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
3
10
|
import json
|
|
4
11
|
import logging
|
|
5
12
|
import os
|
|
@@ -13,6 +20,16 @@ SESSION_TTL = 60 * 60 * 24 # 24 hours
|
|
|
13
20
|
SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
14
21
|
_lock = threading.Lock()
|
|
15
22
|
|
|
23
|
+
_HEX64 = frozenset("0123456789abcdef")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hash_token(token: str) -> str:
|
|
27
|
+
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _looks_hashed(key: str) -> bool:
|
|
31
|
+
return len(key) == 64 and set(key) <= _HEX64
|
|
32
|
+
|
|
16
33
|
|
|
17
34
|
def _sessions_file(data_dir: Optional[Path] = None) -> Path:
|
|
18
35
|
d = data_dir or Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
|
|
@@ -25,7 +42,19 @@ def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
|
|
|
25
42
|
f = _sessions_file(data_dir)
|
|
26
43
|
if f.exists():
|
|
27
44
|
raw = json.loads(f.read_text())
|
|
28
|
-
|
|
45
|
+
sessions: Dict[str, tuple] = {}
|
|
46
|
+
migrated = False
|
|
47
|
+
for key, value in raw.items():
|
|
48
|
+
if _looks_hashed(key):
|
|
49
|
+
sessions[key] = tuple(value)
|
|
50
|
+
else:
|
|
51
|
+
# Pre-v4 entry: the key IS the raw bearer token. Re-key it
|
|
52
|
+
# under its hash so the plaintext stops living on disk.
|
|
53
|
+
sessions[_hash_token(key)] = tuple(value)
|
|
54
|
+
migrated = True
|
|
55
|
+
if migrated:
|
|
56
|
+
persist_sessions(sessions, data_dir)
|
|
57
|
+
return sessions
|
|
29
58
|
except Exception as e:
|
|
30
59
|
logging.warning("load_sessions failed (starting empty): %s", e)
|
|
31
60
|
return {}
|
|
@@ -38,35 +67,62 @@ def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None
|
|
|
38
67
|
logging.warning("persist_sessions failed: %s", e)
|
|
39
68
|
|
|
40
69
|
|
|
70
|
+
def _entry_subject(entry: tuple) -> Optional[str]:
|
|
71
|
+
return entry[0] if entry else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _entry_email(entry: tuple) -> Optional[str]:
|
|
75
|
+
if len(entry) >= 3 and entry[2]:
|
|
76
|
+
return entry[2]
|
|
77
|
+
return entry[0] if entry else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _entry_created_at(entry: tuple) -> float:
|
|
81
|
+
if len(entry) >= 2:
|
|
82
|
+
return float(entry[1])
|
|
83
|
+
return 0.0
|
|
84
|
+
|
|
85
|
+
|
|
41
86
|
class SessionStore:
|
|
42
87
|
def __init__(self, data_dir: Optional[Path] = None):
|
|
43
88
|
self._data_dir = data_dir
|
|
44
89
|
self._sessions: Dict[str, tuple] = load_sessions(data_dir)
|
|
45
90
|
|
|
46
|
-
def create(self, email: str) -> str:
|
|
91
|
+
def create(self, subject: str, *, email: Optional[str] = None) -> str:
|
|
47
92
|
token = secrets.token_urlsafe(32)
|
|
48
93
|
with _lock:
|
|
49
|
-
self._sessions[token] = (
|
|
94
|
+
self._sessions[_hash_token(token)] = (subject, time.time(), email or subject)
|
|
50
95
|
persist_sessions(self._sessions, self._data_dir)
|
|
51
96
|
return token
|
|
52
97
|
|
|
53
98
|
def get_email(self, token: str) -> Optional[str]:
|
|
99
|
+
entry = self._get_entry(token)
|
|
100
|
+
return _entry_email(entry) if entry else None
|
|
101
|
+
|
|
102
|
+
def get_subject(self, token: str) -> Optional[str]:
|
|
103
|
+
entry = self._get_entry(token)
|
|
104
|
+
return _entry_subject(entry) if entry else None
|
|
105
|
+
|
|
106
|
+
def _get_entry(self, token: str) -> Optional[tuple]:
|
|
54
107
|
now = time.time()
|
|
108
|
+
key = _hash_token(token)
|
|
55
109
|
with _lock:
|
|
56
|
-
entry = self._sessions.get(
|
|
110
|
+
entry = self._sessions.get(key)
|
|
57
111
|
if entry is None:
|
|
58
112
|
return None
|
|
59
|
-
|
|
113
|
+
created_at = _entry_created_at(entry)
|
|
60
114
|
if now - created_at > SESSION_TTL:
|
|
61
|
-
self._sessions.pop(
|
|
115
|
+
self._sessions.pop(key, None)
|
|
62
116
|
persist_sessions(self._sessions, self._data_dir)
|
|
63
117
|
return None
|
|
64
118
|
if now - created_at > SESSION_REFRESH_THRESHOLD:
|
|
65
|
-
|
|
119
|
+
refreshed = (_entry_subject(entry), now, _entry_email(entry))
|
|
120
|
+
self._sessions[key] = refreshed
|
|
66
121
|
persist_sessions(self._sessions, self._data_dir)
|
|
67
|
-
|
|
122
|
+
return refreshed
|
|
123
|
+
return entry
|
|
68
124
|
|
|
69
125
|
def invalidate(self, token: str) -> None:
|
|
70
126
|
with _lock:
|
|
71
|
-
self._sessions.pop(token, None)
|
|
127
|
+
self._sessions.pop(_hash_token(token), None)
|
|
72
128
|
persist_sessions(self._sessions, self._data_dir)
|