@jaguilar87/gaia 5.1.0 → 5.1.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/agents/gaia-orchestrator.md +4 -10
- package/bin/README.md +1 -1
- package/bin/cli/approvals.py +0 -144
- package/bin/cli/cleanup.py +1 -30
- package/bin/cli/context.py +379 -6
- package/bin/cli/doctor.py +1 -1
- package/bin/cli/history.py +18 -6
- package/bin/cli/install.py +4 -2
- package/bin/cli/memory.py +86 -10
- package/bin/cli/metrics.py +1216 -275
- package/bin/cli/query.py +89 -0
- package/bin/cli/scan.py +68 -2
- package/bin/cli/uninstall.py +9 -5
- package/bin/pre-publish-validate.js +7 -4
- package/gaia/project.py +49 -20
- package/gaia/store/reader.py +175 -11
- package/gaia/store/schema.sql +180 -1
- package/gaia/store/writer.py +1146 -165
- package/hooks/README.md +2 -2
- package/hooks/adapters/claude_code.py +6 -12
- package/hooks/elicitation_result.py +4 -9
- package/hooks/modules/agents/contract_validator.py +2 -1
- package/hooks/modules/agents/handoff_persister.py +5 -220
- package/hooks/modules/agents/task_info_builder.py +9 -2
- package/hooks/modules/agents/transcript_reader.py +41 -21
- package/hooks/modules/audit/logger.py +8 -3
- package/hooks/modules/context/context_injector.py +10 -3
- package/hooks/modules/core/__init__.py +5 -2
- package/hooks/modules/core/logging_setup.py +60 -0
- package/hooks/modules/core/paths.py +0 -12
- package/hooks/modules/security/approval_grants.py +24 -92
- package/hooks/modules/security/gaia_db_write_guard.py +7 -2
- package/hooks/modules/security/mutative_verbs.py +194 -11
- package/hooks/modules/session/pending_scanner.py +24 -222
- package/hooks/modules/session/session_manifest.py +13 -288
- package/hooks/post_compact.py +4 -9
- package/hooks/post_tool_use.py +4 -9
- package/hooks/pre_compact.py +5 -10
- package/hooks/pre_tool_use.py +4 -11
- package/hooks/session_end_hook.py +4 -9
- package/hooks/session_start.py +18 -21
- package/hooks/stop_hook.py +5 -10
- package/hooks/subagent_start.py +5 -10
- package/hooks/subagent_stop.py +9 -23
- package/hooks/task_completed.py +5 -10
- package/hooks/user_prompt_submit.py +4 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v21_to_v22.sql +80 -0
- package/scripts/migrations/v22_to_v23.sql +20 -0
- package/scripts/migrations/v23_to_v24.sql +30 -0
- package/scripts/migrations/v24_to_v25.sql +77 -0
- package/scripts/migrations/v25_to_v26.sql +116 -0
- package/skills/README.md +8 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -27
- package/skills/agent-approval-protocol/reference.md +20 -12
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/examples.md +17 -9
- package/skills/agent-response/SKILL.md +1 -1
- package/skills/diagram-builder/GLOSSARY.md +77 -0
- package/skills/diagram-builder/SKILL.md +156 -0
- package/skills/diagram-builder/assets/README.md +45 -0
- package/skills/diagram-builder/assets/data/data.generated.js +77 -0
- package/skills/diagram-builder/assets/data/document.yaml +12 -0
- package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
- package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
- package/skills/diagram-builder/assets/engine/engine.js +863 -0
- package/skills/diagram-builder/assets/index.html +638 -0
- package/skills/diagram-builder/assets/package.json +15 -0
- package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
- package/skills/diagram-builder/reference.md +444 -0
- package/skills/execution/SKILL.md +3 -3
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/memory/SKILL.md +49 -0
- package/skills/orchestrator-present-approval/SKILL.md +90 -79
- package/skills/orchestrator-present-approval/reference.md +59 -42
- package/skills/orchestrator-present-approval/template.md +16 -14
- package/skills/pending-approvals/SKILL.md +80 -29
- package/skills/pending-approvals/reference.md +48 -60
- package/skills/security-tiers/SKILL.md +1 -1
- package/skills/subagent-request-approval/SKILL.md +65 -68
- package/skills/subagent-request-approval/reference.md +63 -63
- package/skills/visual-verify/SKILL.md +107 -0
- package/skills/visual-verify/scripts/screenshot.js +210 -0
- package/tools/scan/classify.py +584 -15
- package/tools/scan/migrate_workspace.py +9 -4
- package/tools/scan/store_populator.py +231 -2
- package/tools/scan/tests/conftest.py +31 -0
package/gaia/store/writer.py
CHANGED
|
@@ -46,6 +46,7 @@ _KNOWN_TABLES = {
|
|
|
46
46
|
"libraries",
|
|
47
47
|
"services",
|
|
48
48
|
"features",
|
|
49
|
+
"project_facets",
|
|
49
50
|
"tf_modules",
|
|
50
51
|
"tf_live",
|
|
51
52
|
"releases",
|
|
@@ -59,7 +60,7 @@ _KNOWN_TABLES = {
|
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
# ---------------------------------------------------------------------------
|
|
62
|
-
# Semantic-grant lifetime (
|
|
63
|
+
# Semantic-grant lifetime (approvals redesign, M1)
|
|
63
64
|
# ---------------------------------------------------------------------------
|
|
64
65
|
#
|
|
65
66
|
# APPROVAL_GRANT_TTL_MINUTES is the default lifetime of an ACTIVE semantic grant
|
|
@@ -71,21 +72,23 @@ _KNOWN_TABLES = {
|
|
|
71
72
|
# 24h), which is how long an UNANSWERED approval waits for the user. The two must
|
|
72
73
|
# not be conflated: a 24h pending window lets a human come back the next day,
|
|
73
74
|
# while the grant window is the short, post-approval execution horizon. Collapsing
|
|
74
|
-
# them would either shrink the approval wait to
|
|
75
|
+
# them would either shrink the approval wait to 5m (a regression) or stretch the
|
|
75
76
|
# grant lifetime to 24h (a security weakening). See the regression guards in
|
|
76
77
|
# tests/hooks/test_pending_scanner_cleanup.py::TestTTLConstants.
|
|
77
78
|
#
|
|
78
|
-
# The value
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
79
|
+
# The value is 5 minutes (approvals redesign, M1). The grant is consumed AT THE
|
|
80
|
+
# MATCH (bash_validator flips the row PENDING->CONSUMED when it authorizes the
|
|
81
|
+
# command in PreToolUse, before execution), so this short window only needs to
|
|
82
|
+
# cover the block -> approve -> retry round trip; a grant that is never presented
|
|
83
|
+
# to a matching retry simply expires. Replay protection comes from consume-at-
|
|
84
|
+
# match plus this short TTL, not from a long-lived grant.
|
|
82
85
|
#
|
|
83
86
|
# It lives HERE, in gaia.store.writer, because writer is the dependency leaf of
|
|
84
87
|
# the approval planes: gaia.approvals.store already imports from this module
|
|
85
88
|
# (_connect) and the hooks approval_grants module already imports
|
|
86
89
|
# insert_semantic_grant from here, while writer imports neither -- so any consumer
|
|
87
90
|
# can read this constant without a circular import.
|
|
88
|
-
APPROVAL_GRANT_TTL_MINUTES =
|
|
91
|
+
APPROVAL_GRANT_TTL_MINUTES = 5
|
|
89
92
|
|
|
90
93
|
|
|
91
94
|
# ---------------------------------------------------------------------------
|
|
@@ -171,20 +174,31 @@ def _applied(extra: dict | None = None) -> dict:
|
|
|
171
174
|
# ---------------------------------------------------------------------------
|
|
172
175
|
|
|
173
176
|
def _resolve_identity(workspace: str, workspace_path: Path | None = None) -> str:
|
|
174
|
-
"""Resolve workspace identity.
|
|
177
|
+
"""Resolve workspace identity -- REMOTE-derived, read directly (M2-T7).
|
|
175
178
|
|
|
176
|
-
Rule
|
|
179
|
+
Rule:
|
|
177
180
|
* If ``workspace_path`` is provided AND ``workspace_path / .git`` exists
|
|
178
181
|
(the workspace root is itself a git project), resolve identity from
|
|
179
|
-
the git remote of that directory via
|
|
182
|
+
the git remote of that directory, read DIRECTLY via
|
|
183
|
+
``gaia.project._git_remote_origin`` + ``_normalize_remote``.
|
|
180
184
|
* Otherwise (organizational workspace -- no .git at the root), the
|
|
181
185
|
identity IS the workspace name. We do NOT leak the remote of a child
|
|
182
186
|
project up to the workspace row.
|
|
183
187
|
|
|
184
|
-
This
|
|
185
|
-
|
|
188
|
+
This deliberately does NOT go through ``gaia.project.current()``. As of
|
|
189
|
+
M2-T7 (AC-9) ``current()`` is PATH-based (it answers "which workspace am I
|
|
190
|
+
in" by disk location, not by remote). The ``workspaces.identity`` column,
|
|
191
|
+
however, must remain the normalized git remote (``host/owner/repo``) so two
|
|
192
|
+
clones of the same remote collapse to the same identity row (the B0 design
|
|
193
|
+
in ``tools/scan/store_populator.py``). Reading the remote directly here
|
|
194
|
+
decouples the identity column from ``current()``'s path-first behavior and
|
|
195
|
+
preserves the remote-derived semantic.
|
|
186
196
|
|
|
187
|
-
|
|
197
|
+
This also prevents the historical contamination where a workspace like
|
|
198
|
+
``me`` received the identity of its first scanned child project.
|
|
199
|
+
|
|
200
|
+
Falls back to the workspace string itself when path resolution fails or no
|
|
201
|
+
remote is configured.
|
|
188
202
|
|
|
189
203
|
Args:
|
|
190
204
|
workspace: Workspace name used as the fallback / organizational identity.
|
|
@@ -200,10 +214,12 @@ def _resolve_identity(workspace: str, workspace_path: Path | None = None) -> str
|
|
|
200
214
|
try:
|
|
201
215
|
if not (workspace_path / ".git").is_dir():
|
|
202
216
|
return workspace.lower()
|
|
203
|
-
from gaia.project import
|
|
204
|
-
|
|
205
|
-
if
|
|
206
|
-
|
|
217
|
+
from gaia.project import _git_remote_origin, _normalize_remote
|
|
218
|
+
remote = _git_remote_origin(workspace_path)
|
|
219
|
+
if remote:
|
|
220
|
+
ident = _normalize_remote(remote)
|
|
221
|
+
if ident:
|
|
222
|
+
return ident
|
|
207
223
|
except Exception:
|
|
208
224
|
pass
|
|
209
225
|
return workspace.lower()
|
|
@@ -348,11 +364,185 @@ def mark_workspace_demoted(
|
|
|
348
364
|
con.close()
|
|
349
365
|
|
|
350
366
|
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Column ownership map (coalesce-or-omit + agent-owned protection)
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
#
|
|
371
|
+
# Ported from tools/scan/orchestrator.py's SCANNER_OWNED_TOP_LEVEL /
|
|
372
|
+
# AGENT_ENRICHED_SECTIONS split (the retired project-context.json ownership
|
|
373
|
+
# model) down to the DB write path (workspace-identity brief, M1-T2).
|
|
374
|
+
#
|
|
375
|
+
# Semantics:
|
|
376
|
+
# * Coalesce-or-omit: a column is only written when its key is PRESENT in
|
|
377
|
+
# the caller's `fields` mapping (even when the value is explicitly None,
|
|
378
|
+
# e.g. ``missing_since=None`` to reactivate a project). A key ABSENT from
|
|
379
|
+
# `fields` is left OUT of the INSERT/UPDATE entirely -- the column keeps
|
|
380
|
+
# its current value instead of being forced to NULL just because a given
|
|
381
|
+
# scan run's payload did not mention it. This is the fix for the
|
|
382
|
+
# "columns go NULL when a rescan omits them" clobber.
|
|
383
|
+
# * Agent-owned protection: a column named in the table's `_AGENT_OWNED`
|
|
384
|
+
# set is stripped from `fields` before the coalesce-or-omit step
|
|
385
|
+
# whenever the caller passes ``strip_agent_owned=True`` -- the scan path
|
|
386
|
+
# (bulk_upsert's projects/apps branches, populate_project) always does.
|
|
387
|
+
# A direct caller that does NOT set ``strip_agent_owned`` (tests, or any
|
|
388
|
+
# future agent-driven write) keeps full write access -- the flag gates
|
|
389
|
+
# the SCAN PATH specifically, not the column in the abstract.
|
|
390
|
+
#
|
|
391
|
+
# M3/T9: `description` is agent-owned (schema v23, scripts/migrations/
|
|
392
|
+
# v22_to_v23.sql). The scan path (strip_agent_owned=True) can never write it,
|
|
393
|
+
# regardless of what a caller's `fields` dict happens to contain -- it is
|
|
394
|
+
# stripped by _present_fields before the coalesce-or-omit step, same
|
|
395
|
+
# mechanism already proven for apps.description/status in M1.
|
|
396
|
+
_PROJECTS_AGENT_OWNED: frozenset = frozenset({"description"})
|
|
397
|
+
# NOTE: `role` is NOT agent-owned here (M1-T3): it is auto-detected by
|
|
398
|
+
# tools/scan/role_detector.py and refreshed on every scan, so it belongs to
|
|
399
|
+
# the scanner. See schema.sql's `role` column comment for the same note.
|
|
400
|
+
_APPS_AGENT_OWNED: frozenset = frozenset({"description", "status"})
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _present_fields(
|
|
404
|
+
fields: Mapping[str, Any],
|
|
405
|
+
recognized: Sequence[str],
|
|
406
|
+
*,
|
|
407
|
+
strip: frozenset = frozenset(),
|
|
408
|
+
) -> dict:
|
|
409
|
+
"""Return the subset of `recognized` keys actually supplied in `fields`.
|
|
410
|
+
|
|
411
|
+
Powers coalesce-or-omit: building the INSERT/UPDATE column list from this
|
|
412
|
+
dict's keys means an omitted scanner-owned column is never forced to NULL,
|
|
413
|
+
and (when `strip` names the table's agent-owned columns) the scan path can
|
|
414
|
+
never write agent-owned data regardless of what its payload happens to
|
|
415
|
+
include.
|
|
416
|
+
"""
|
|
417
|
+
return {k: fields[k] for k in recognized if k in fields and k not in strip}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _find_collision_free_name(
|
|
421
|
+
con: sqlite3.Connection,
|
|
422
|
+
workspace: str,
|
|
423
|
+
name: str,
|
|
424
|
+
project_identity: str | None,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Return a `projects.name` guaranteed not to collide with a DIFFERENT
|
|
427
|
+
physical repo already occupying ``(workspace, name)``.
|
|
428
|
+
|
|
429
|
+
Two distinct repos (distinct ``project_identity``) can legitimately share
|
|
430
|
+
a basename under the same workspace (e.g. two "foo" repos nested under
|
|
431
|
+
different containers). Without this guard, upserting the second one would
|
|
432
|
+
silently overwrite the first via the ``(workspace, name)`` UNIQUE
|
|
433
|
+
constraint -- the collision-key defect (workspace-identity brief, AC-2).
|
|
434
|
+
|
|
435
|
+
Read-only (issues no writes) so it is safe to call from a dry-run preview
|
|
436
|
+
as well as from inside upsert_project's write transaction. When the
|
|
437
|
+
existing occupant shares the SAME identity (or the slot is free, or the
|
|
438
|
+
slot's identity is unset/legacy), the name is returned unchanged --
|
|
439
|
+
disambiguation only fires for a CONFIRMED different physical repo.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
con: Open connection (used read-only here).
|
|
443
|
+
workspace: Workspace name.
|
|
444
|
+
name: Candidate project name.
|
|
445
|
+
project_identity: The NEW row's stable identity, or None/empty (in
|
|
446
|
+
which case no collision can be detected and `name` is returned
|
|
447
|
+
unchanged).
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
`name` unchanged, or `name` suffixed with `-2`, `-3`, ... until a free
|
|
451
|
+
(or same-identity) slot is found.
|
|
452
|
+
"""
|
|
453
|
+
if not project_identity:
|
|
454
|
+
return name
|
|
455
|
+
|
|
456
|
+
def _occupied_by_other(candidate: str) -> bool:
|
|
457
|
+
row = con.execute(
|
|
458
|
+
"SELECT project_identity FROM projects WHERE workspace = ? AND name = ?",
|
|
459
|
+
(workspace, candidate),
|
|
460
|
+
).fetchone()
|
|
461
|
+
existing_identity = row["project_identity"] if row else None
|
|
462
|
+
return bool(existing_identity) and existing_identity != project_identity
|
|
463
|
+
|
|
464
|
+
if not _occupied_by_other(name):
|
|
465
|
+
return name
|
|
466
|
+
|
|
467
|
+
suffix = 2
|
|
468
|
+
while True:
|
|
469
|
+
candidate = f"{name}-{suffix}"
|
|
470
|
+
if not _occupied_by_other(candidate):
|
|
471
|
+
return candidate
|
|
472
|
+
suffix += 1
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def preview_project_name(
|
|
476
|
+
workspace: str,
|
|
477
|
+
name: str,
|
|
478
|
+
project_identity: str | None,
|
|
479
|
+
*,
|
|
480
|
+
db_path: Path | None = None,
|
|
481
|
+
extra_claimed: Mapping[str, str] | None = None,
|
|
482
|
+
) -> str:
|
|
483
|
+
"""Read-only preview of the name :func:`upsert_project` would actually use.
|
|
484
|
+
|
|
485
|
+
Lets a dry-run report the REAL, collision-free name without writing
|
|
486
|
+
anything. ``extra_claimed`` lets a caller iterating a batch of repos in
|
|
487
|
+
one pass (e.g. ``tools/scan/classify.py::scan``) fold in names already
|
|
488
|
+
"claimed" earlier in the SAME batch -- names that a real ``apply=True``
|
|
489
|
+
run would already have committed to the DB by the time a later repo in
|
|
490
|
+
the batch is processed (commits are sequential), but that a dry-run,
|
|
491
|
+
which writes nothing, cannot see via the DB alone.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
workspace: Workspace name.
|
|
495
|
+
name: Candidate project name.
|
|
496
|
+
project_identity: The repo's stable identity, or None/empty.
|
|
497
|
+
db_path: Optional explicit DB path (used by tests).
|
|
498
|
+
extra_claimed: Optional ``{name: project_identity}`` map of names
|
|
499
|
+
already claimed earlier in the same in-progress batch.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
The name that would be used, disambiguated if needed.
|
|
503
|
+
"""
|
|
504
|
+
if not project_identity:
|
|
505
|
+
return name
|
|
506
|
+
if extra_claimed and name in extra_claimed:
|
|
507
|
+
if extra_claimed[name] == project_identity:
|
|
508
|
+
return name
|
|
509
|
+
# In-memory collision against an earlier repo in this same batch --
|
|
510
|
+
# resolve purely in-memory first (no DB round trip needed to know
|
|
511
|
+
# this slot is taken), then fall through to the DB-aware resolver
|
|
512
|
+
# starting from the first candidate suffix.
|
|
513
|
+
suffix = 2
|
|
514
|
+
while True:
|
|
515
|
+
candidate = f"{name}-{suffix}"
|
|
516
|
+
claimed_identity = extra_claimed.get(candidate)
|
|
517
|
+
if claimed_identity is None:
|
|
518
|
+
break
|
|
519
|
+
if claimed_identity == project_identity:
|
|
520
|
+
return candidate
|
|
521
|
+
suffix += 1
|
|
522
|
+
name = candidate
|
|
523
|
+
|
|
524
|
+
# Dry-run touches-nothing guarantee: never let a PREVIEW materialize the
|
|
525
|
+
# DB. `_connect()` runs schema.sql when the file is absent, which would
|
|
526
|
+
# create the data dir during a --dry-run scan (regression caught by
|
|
527
|
+
# tests/cli/test_scan.py::test_dry_run_does_not_touch_db). A DB that does
|
|
528
|
+
# not yet exist has zero rows to collide with, so the in-memory
|
|
529
|
+
# `extra_claimed` resolution above is already the complete answer.
|
|
530
|
+
resolved = db_path if db_path is not None else _db_path()
|
|
531
|
+
if not resolved.exists():
|
|
532
|
+
return name
|
|
533
|
+
|
|
534
|
+
con = _connect(resolved)
|
|
535
|
+
try:
|
|
536
|
+
return _find_collision_free_name(con, workspace, name, project_identity)
|
|
537
|
+
finally:
|
|
538
|
+
con.close()
|
|
539
|
+
|
|
540
|
+
|
|
351
541
|
# ---------------------------------------------------------------------------
|
|
352
542
|
# Public API: upsert_project
|
|
353
543
|
# ---------------------------------------------------------------------------
|
|
354
544
|
|
|
355
|
-
_PROJECT_FIELDS = ("role", "remote_url", "platform", "primary_language", "group_name", "path", "status", "missing_since", "project_identity")
|
|
545
|
+
_PROJECT_FIELDS = ("role", "remote_url", "platform", "primary_language", "group_name", "path", "status", "missing_since", "project_identity", "description")
|
|
356
546
|
|
|
357
547
|
|
|
358
548
|
def _projects_has_identity_column(con: sqlite3.Connection) -> bool:
|
|
@@ -376,6 +566,7 @@ def upsert_project(
|
|
|
376
566
|
*,
|
|
377
567
|
db_path: Path | None = None,
|
|
378
568
|
workspace_path: Path | None = None,
|
|
569
|
+
strip_agent_owned: bool = False,
|
|
379
570
|
) -> dict:
|
|
380
571
|
"""Upsert a projects row, enforcing per-agent write permission.
|
|
381
572
|
|
|
@@ -385,26 +576,39 @@ def upsert_project(
|
|
|
385
576
|
fields: Dict of column->value pairs. Recognized keys:
|
|
386
577
|
``role``, ``remote_url``, ``platform``, ``primary_language``,
|
|
387
578
|
``group_name``, ``path``, ``status``, ``missing_since``,
|
|
388
|
-
``project_identity``.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
the
|
|
396
|
-
|
|
397
|
-
|
|
579
|
+
``project_identity``. A key ABSENT from `fields` is coalesce-or-
|
|
580
|
+
omit: the column keeps its current value instead of being forced
|
|
581
|
+
to NULL (see the ownership map above `_PROJECTS_AGENT_OWNED`). A
|
|
582
|
+
key present with value None (e.g. ``missing_since=None``) is an
|
|
583
|
+
explicit write -- this is how the scanner reactivates a
|
|
584
|
+
previously-missing project (pass status='active' and
|
|
585
|
+
missing_since=None together). When ``project_identity`` is
|
|
586
|
+
non-null and the live schema carries the column (v18+), the
|
|
587
|
+
UPSERT collapses on that stable identity: the SAME physical repo
|
|
588
|
+
scanned from different workspaces/roots updates the existing row
|
|
589
|
+
IN PLACE (preserving its original (workspace, name) PK) instead
|
|
590
|
+
of inserting a duplicate. ``status`` defaults to 'active' when
|
|
591
|
+
not provided (or explicitly None).
|
|
398
592
|
agent: Agent name. Must have allow_write=1 for table 'projects' in
|
|
399
593
|
agent_permissions.
|
|
400
|
-
topic_key: Optional dimension key.
|
|
594
|
+
topic_key: Optional dimension key. Coalesced: an explicit value
|
|
595
|
+
overwrites; omitting it (None) preserves the existing value
|
|
596
|
+
instead of nulling it on every rescan.
|
|
401
597
|
db_path: Optional explicit DB path (used by tests).
|
|
402
598
|
workspace_path: Directory whose git remote supplies the workspaces.identity
|
|
403
599
|
value. Pass ``project_path`` from the scanner for correct
|
|
404
600
|
multi-workspace ingestion.
|
|
601
|
+
strip_agent_owned: When True (the scan path -- bulk_upsert's
|
|
602
|
+
projects branch, populate_project), any key in
|
|
603
|
+
``_PROJECTS_AGENT_OWNED`` is dropped from `fields` before the
|
|
604
|
+
coalesce-or-omit step, regardless of what the caller supplied.
|
|
605
|
+
Direct callers (tests, future agent-driven writes) leave this
|
|
606
|
+
False and keep full write access.
|
|
405
607
|
|
|
406
608
|
Returns:
|
|
407
|
-
{"status": "applied"
|
|
609
|
+
{"status": "applied", "name": <final name used, disambiguated if a
|
|
610
|
+
genuine repo-name collision was detected -- see
|
|
611
|
+
:func:`_find_collision_free_name`>} on success.
|
|
408
612
|
{"status": "rejected", "reason": "not_authorized"} if the agent lacks
|
|
409
613
|
write permission for the 'projects' table.
|
|
410
614
|
"""
|
|
@@ -416,13 +620,19 @@ def upsert_project(
|
|
|
416
620
|
con.execute("BEGIN")
|
|
417
621
|
try:
|
|
418
622
|
_ensure_workspace_row(con, workspace, workspace_path)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
623
|
+
|
|
624
|
+
present = _present_fields(
|
|
625
|
+
fields, _PROJECT_FIELDS,
|
|
626
|
+
strip=_PROJECTS_AGENT_OWNED if strip_agent_owned else frozenset(),
|
|
627
|
+
)
|
|
628
|
+
# Default status to 'active' when not explicitly provided (or
|
|
629
|
+
# explicitly None). Newly-inserted rows and re-upserted live
|
|
630
|
+
# projects always carry an explicit status value -- unchanged
|
|
631
|
+
# historical default.
|
|
632
|
+
if present.get("status") is None:
|
|
633
|
+
present["status"] = "active"
|
|
424
634
|
now = _now_iso()
|
|
425
|
-
project_identity =
|
|
635
|
+
project_identity = present.get("project_identity")
|
|
426
636
|
|
|
427
637
|
# Identity-collapse path (M1-T2): when a stable project_identity is
|
|
428
638
|
# supplied AND the live schema carries the column, the SAME physical
|
|
@@ -432,102 +642,74 @@ def upsert_project(
|
|
|
432
642
|
# one) and UPDATE it IN PLACE, preserving its original (workspace,
|
|
433
643
|
# name) PK -- the first-seen vantage wins, later scans only refresh
|
|
434
644
|
# the row's scanner-owned columns. This is what makes the
|
|
435
|
-
# "same repo from two roots -> 0 duplicates" query hold.
|
|
645
|
+
# "same repo from two roots -> 0 duplicates" query hold. Only the
|
|
646
|
+
# columns actually PRESENT in `fields` are updated (coalesce-or-
|
|
647
|
+
# omit); scanner-owned columns this call didn't mention keep their
|
|
648
|
+
# current value instead of being nulled.
|
|
436
649
|
if has_identity_col and project_identity:
|
|
437
650
|
existing = con.execute(
|
|
438
651
|
"SELECT workspace, name FROM projects WHERE project_identity = ?",
|
|
439
652
|
(project_identity,),
|
|
440
653
|
).fetchone()
|
|
441
654
|
if existing is not None:
|
|
655
|
+
set_parts = [f"{c} = ?" for c in present.keys()]
|
|
656
|
+
set_parts += ["scanner_ts = ?", "topic_key = COALESCE(?, topic_key)"]
|
|
657
|
+
params = list(present.values()) + [now, topic_key]
|
|
442
658
|
con.execute(
|
|
443
|
-
""
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
remote_url = ?,
|
|
447
|
-
platform = ?,
|
|
448
|
-
primary_language = ?,
|
|
449
|
-
scanner_ts = ?,
|
|
450
|
-
topic_key = ?,
|
|
451
|
-
group_name = ?,
|
|
452
|
-
path = ?,
|
|
453
|
-
status = ?,
|
|
454
|
-
missing_since = ?,
|
|
455
|
-
project_identity = ?
|
|
456
|
-
WHERE workspace = ? AND name = ?
|
|
457
|
-
""",
|
|
458
|
-
(
|
|
459
|
-
data["role"], data["remote_url"], data["platform"],
|
|
460
|
-
data["primary_language"], now, topic_key,
|
|
461
|
-
data["group_name"], data["path"],
|
|
462
|
-
status_val, data["missing_since"], project_identity,
|
|
463
|
-
existing["workspace"], existing["name"],
|
|
464
|
-
),
|
|
659
|
+
f"UPDATE projects SET {', '.join(set_parts)} "
|
|
660
|
+
f"WHERE workspace = ? AND name = ?",
|
|
661
|
+
(*params, existing["workspace"], existing["name"]),
|
|
465
662
|
)
|
|
466
663
|
con.commit()
|
|
467
|
-
return _applied()
|
|
664
|
+
return _applied({"name": existing["name"]})
|
|
665
|
+
|
|
666
|
+
# No identity match -- this is a NEW row (or a legacy DB with no
|
|
667
|
+
# identity column). Resolve a collision-free name so a DIFFERENT
|
|
668
|
+
# physical repo sharing this basename never silently overwrites
|
|
669
|
+
# an existing, unrelated row (AC-2).
|
|
670
|
+
final_name = name
|
|
671
|
+
if has_identity_col and project_identity:
|
|
672
|
+
final_name = _find_collision_free_name(con, workspace, name, project_identity)
|
|
468
673
|
|
|
469
674
|
if has_identity_col:
|
|
675
|
+
insert_cols = ["workspace", "name"] + list(present.keys()) + ["scanner_ts", "topic_key"]
|
|
676
|
+
insert_vals = [workspace, final_name] + list(present.values()) + [now, topic_key]
|
|
677
|
+
update_clause_parts = [f"{c} = excluded.{c}" for c in present.keys()]
|
|
678
|
+
update_clause_parts += [
|
|
679
|
+
"scanner_ts = excluded.scanner_ts",
|
|
680
|
+
"topic_key = COALESCE(excluded.topic_key, topic_key)",
|
|
681
|
+
]
|
|
470
682
|
con.execute(
|
|
471
|
-
""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
project_identity)
|
|
476
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
477
|
-
ON CONFLICT(workspace, name) DO UPDATE SET
|
|
478
|
-
role = excluded.role,
|
|
479
|
-
remote_url = excluded.remote_url,
|
|
480
|
-
platform = excluded.platform,
|
|
481
|
-
primary_language = excluded.primary_language,
|
|
482
|
-
scanner_ts = excluded.scanner_ts,
|
|
483
|
-
topic_key = excluded.topic_key,
|
|
484
|
-
group_name = excluded.group_name,
|
|
485
|
-
path = excluded.path,
|
|
486
|
-
status = excluded.status,
|
|
487
|
-
missing_since = excluded.missing_since,
|
|
488
|
-
project_identity = excluded.project_identity
|
|
489
|
-
""",
|
|
490
|
-
(
|
|
491
|
-
workspace, name,
|
|
492
|
-
data["role"], data["remote_url"], data["platform"],
|
|
493
|
-
data["primary_language"], now, topic_key,
|
|
494
|
-
data["group_name"], data["path"],
|
|
495
|
-
status_val, data["missing_since"], project_identity,
|
|
496
|
-
),
|
|
683
|
+
f"INSERT INTO projects ({', '.join(insert_cols)}) "
|
|
684
|
+
f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
|
|
685
|
+
f"ON CONFLICT(workspace, name) DO UPDATE SET {', '.join(update_clause_parts)}",
|
|
686
|
+
insert_vals,
|
|
497
687
|
)
|
|
498
688
|
else:
|
|
499
689
|
# Backward-compat: un-migrated DB without project_identity.
|
|
690
|
+
# No collision-free naming is possible without an identity
|
|
691
|
+
# signal -- degrades to the historical (workspace, name) key.
|
|
692
|
+
# Drop `project_identity` from `present`: the legacy schema
|
|
693
|
+
# does not carry that column at all.
|
|
694
|
+
legacy_present = {k: v for k, v in present.items() if k != "project_identity"}
|
|
695
|
+
insert_cols = ["workspace", "name"] + list(legacy_present.keys()) + ["scanner_ts", "topic_key"]
|
|
696
|
+
insert_vals = [workspace, final_name] + list(legacy_present.values()) + [now, topic_key]
|
|
697
|
+
update_clause_parts = [f"{c} = excluded.{c}" for c in legacy_present.keys()]
|
|
698
|
+
update_clause_parts += [
|
|
699
|
+
"scanner_ts = excluded.scanner_ts",
|
|
700
|
+
"topic_key = COALESCE(excluded.topic_key, topic_key)",
|
|
701
|
+
]
|
|
500
702
|
con.execute(
|
|
501
|
-
""
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
506
|
-
ON CONFLICT(workspace, name) DO UPDATE SET
|
|
507
|
-
role = excluded.role,
|
|
508
|
-
remote_url = excluded.remote_url,
|
|
509
|
-
platform = excluded.platform,
|
|
510
|
-
primary_language = excluded.primary_language,
|
|
511
|
-
scanner_ts = excluded.scanner_ts,
|
|
512
|
-
topic_key = excluded.topic_key,
|
|
513
|
-
group_name = excluded.group_name,
|
|
514
|
-
path = excluded.path,
|
|
515
|
-
status = excluded.status,
|
|
516
|
-
missing_since = excluded.missing_since
|
|
517
|
-
""",
|
|
518
|
-
(
|
|
519
|
-
workspace, name,
|
|
520
|
-
data["role"], data["remote_url"], data["platform"],
|
|
521
|
-
data["primary_language"], now, topic_key,
|
|
522
|
-
data["group_name"], data["path"],
|
|
523
|
-
status_val, data["missing_since"],
|
|
524
|
-
),
|
|
703
|
+
f"INSERT INTO projects ({', '.join(insert_cols)}) "
|
|
704
|
+
f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
|
|
705
|
+
f"ON CONFLICT(workspace, name) DO UPDATE SET {', '.join(update_clause_parts)}",
|
|
706
|
+
insert_vals,
|
|
525
707
|
)
|
|
526
708
|
con.commit()
|
|
527
709
|
except Exception:
|
|
528
710
|
con.rollback()
|
|
529
711
|
raise
|
|
530
|
-
return _applied()
|
|
712
|
+
return _applied({"name": final_name})
|
|
531
713
|
finally:
|
|
532
714
|
con.close()
|
|
533
715
|
|
|
@@ -548,6 +730,7 @@ def upsert_app(
|
|
|
548
730
|
topic_key: str | None = None,
|
|
549
731
|
*,
|
|
550
732
|
db_path: Path | None = None,
|
|
733
|
+
strip_agent_owned: bool = False,
|
|
551
734
|
) -> dict:
|
|
552
735
|
"""Upsert an apps row, enforcing per-agent write permission.
|
|
553
736
|
|
|
@@ -557,9 +740,19 @@ def upsert_app(
|
|
|
557
740
|
``projects`` table).
|
|
558
741
|
name: App name.
|
|
559
742
|
fields: Dict with optional keys ``kind``, ``description``, ``status``.
|
|
743
|
+
A key ABSENT from `fields` is coalesce-or-omit: the column keeps
|
|
744
|
+
its current value instead of being forced to NULL. A key present
|
|
745
|
+
(even with value None) is an explicit write.
|
|
560
746
|
agent: Agent name. Requires allow_write=1 for table 'apps'.
|
|
561
|
-
topic_key: Optional dimension key.
|
|
747
|
+
topic_key: Optional dimension key. Coalesced: an explicit value
|
|
748
|
+
overwrites; omitting it (None) preserves the existing value.
|
|
562
749
|
db_path: Optional explicit DB path (used by tests).
|
|
750
|
+
strip_agent_owned: When True (the scan path -- bulk_upsert's apps
|
|
751
|
+
branch), ``description`` and ``status`` (``_APPS_AGENT_OWNED``)
|
|
752
|
+
are dropped from `fields` before the coalesce-or-omit step,
|
|
753
|
+
regardless of what the caller supplied. Direct callers (tests,
|
|
754
|
+
future agent-driven writes) leave this False and keep full
|
|
755
|
+
write access.
|
|
563
756
|
|
|
564
757
|
Returns:
|
|
565
758
|
{"status": "applied"} on success.
|
|
@@ -582,24 +775,23 @@ def upsert_app(
|
|
|
582
775
|
"INSERT INTO projects (workspace, name, scanner_ts) VALUES (?, ?, ?)",
|
|
583
776
|
(workspace, project, _now_iso()),
|
|
584
777
|
)
|
|
585
|
-
|
|
778
|
+
present = _present_fields(
|
|
779
|
+
fields, _APP_FIELDS,
|
|
780
|
+
strip=_APPS_AGENT_OWNED if strip_agent_owned else frozenset(),
|
|
781
|
+
)
|
|
782
|
+
now = _now_iso()
|
|
783
|
+
insert_cols = ["workspace", "project", "name"] + list(present.keys()) + ["topic_key", "scanner_ts"]
|
|
784
|
+
insert_vals = [workspace, project, name] + list(present.values()) + [topic_key, now]
|
|
785
|
+
update_clause_parts = [f"{c} = excluded.{c}" for c in present.keys()]
|
|
786
|
+
update_clause_parts += [
|
|
787
|
+
"topic_key = COALESCE(excluded.topic_key, topic_key)",
|
|
788
|
+
"scanner_ts = excluded.scanner_ts",
|
|
789
|
+
]
|
|
586
790
|
con.execute(
|
|
587
|
-
""
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
ON CONFLICT(workspace, project, name) DO UPDATE SET
|
|
592
|
-
kind = excluded.kind,
|
|
593
|
-
description = excluded.description,
|
|
594
|
-
status = excluded.status,
|
|
595
|
-
topic_key = excluded.topic_key,
|
|
596
|
-
scanner_ts = excluded.scanner_ts
|
|
597
|
-
""",
|
|
598
|
-
(
|
|
599
|
-
workspace, project, name,
|
|
600
|
-
data["kind"], data["description"], data["status"],
|
|
601
|
-
topic_key, _now_iso(),
|
|
602
|
-
),
|
|
791
|
+
f"INSERT INTO apps ({', '.join(insert_cols)}) "
|
|
792
|
+
f"VALUES ({', '.join(['?'] * len(insert_cols))}) "
|
|
793
|
+
f"ON CONFLICT(workspace, project, name) DO UPDATE SET {', '.join(update_clause_parts)}",
|
|
794
|
+
insert_vals,
|
|
603
795
|
)
|
|
604
796
|
con.commit()
|
|
605
797
|
except Exception:
|
|
@@ -653,6 +845,7 @@ def delete_missing_in(
|
|
|
653
845
|
"libraries": ("project", "name"),
|
|
654
846
|
"services": ("project", "name"),
|
|
655
847
|
"features": ("project", "name"),
|
|
848
|
+
"project_facets": ("project", "scope", "key"),
|
|
656
849
|
"tf_modules": ("project", "name"),
|
|
657
850
|
"tf_live": ("project", "name"),
|
|
658
851
|
"releases": ("project", "name"),
|
|
@@ -799,6 +992,10 @@ def bulk_upsert(
|
|
|
799
992
|
applied = 0
|
|
800
993
|
rejected = 0
|
|
801
994
|
if table == "projects":
|
|
995
|
+
# bulk_upsert is exclusively the scan path's batch writer (see the
|
|
996
|
+
# module docstring: "populators NEVER touch agent-owned columns") --
|
|
997
|
+
# strip_agent_owned=True enforces that structurally, regardless of
|
|
998
|
+
# what a row dict happens to include.
|
|
802
999
|
for r in rows_list:
|
|
803
1000
|
res = upsert_project(
|
|
804
1001
|
workspace,
|
|
@@ -807,6 +1004,7 @@ def bulk_upsert(
|
|
|
807
1004
|
agent,
|
|
808
1005
|
topic_key=r.get("topic_key"),
|
|
809
1006
|
db_path=db_path,
|
|
1007
|
+
strip_agent_owned=True,
|
|
810
1008
|
)
|
|
811
1009
|
if res.get("status") == "applied":
|
|
812
1010
|
applied += 1
|
|
@@ -824,6 +1022,7 @@ def bulk_upsert(
|
|
|
824
1022
|
agent,
|
|
825
1023
|
topic_key=r.get("topic_key"),
|
|
826
1024
|
db_path=db_path,
|
|
1025
|
+
strip_agent_owned=True,
|
|
827
1026
|
)
|
|
828
1027
|
if res.get("status") == "applied":
|
|
829
1028
|
applied += 1
|
|
@@ -840,6 +1039,7 @@ def bulk_upsert(
|
|
|
840
1039
|
"libraries": ("project", "name"),
|
|
841
1040
|
"services": ("project", "name"),
|
|
842
1041
|
"features": ("project", "name"),
|
|
1042
|
+
"project_facets": ("project", "scope", "key"),
|
|
843
1043
|
"tf_modules": ("project", "name"),
|
|
844
1044
|
"tf_live": ("project", "name"),
|
|
845
1045
|
"releases": ("project", "name"),
|
|
@@ -1134,6 +1334,74 @@ def _validate_curated_slug(name: str, type: str) -> None:
|
|
|
1134
1334
|
)
|
|
1135
1335
|
|
|
1136
1336
|
|
|
1337
|
+
def resolve_project_ref(
|
|
1338
|
+
workspace: str,
|
|
1339
|
+
project_name: str,
|
|
1340
|
+
*,
|
|
1341
|
+
db_path: Path | None = None,
|
|
1342
|
+
) -> str:
|
|
1343
|
+
"""Resolve a ``projects.name`` within ``workspace`` to its stable
|
|
1344
|
+
``project_identity`` anchor -- the value ``upsert_memory(project_ref=...)``
|
|
1345
|
+
expects (N3 forward-only anchoring).
|
|
1346
|
+
|
|
1347
|
+
Looks up the exact ``(workspace, project_name)`` row -- the same lookup
|
|
1348
|
+
documented as the manual convention in ``skills/memory/SKILL.md`` before
|
|
1349
|
+
this function existed (``SELECT project_identity FROM projects WHERE
|
|
1350
|
+
workspace=? AND name=?``). Never guesses: raises ``ValueError`` with an
|
|
1351
|
+
actionable message when the project does not exist, when more than one
|
|
1352
|
+
row matches (structurally guarded against by the ``(workspace, name)``
|
|
1353
|
+
primary key, but checked defensively), or when the matching row has not
|
|
1354
|
+
yet been assigned a ``project_identity`` (e.g. a legacy pre-v18 row, or a
|
|
1355
|
+
project scanned before the identity column was populated) -- anchoring to
|
|
1356
|
+
an absent identity would be a guess, not a resolution.
|
|
1357
|
+
|
|
1358
|
+
Args:
|
|
1359
|
+
workspace: Workspace name (matches ``projects.workspace``).
|
|
1360
|
+
project_name: Project basename (matches ``projects.name``).
|
|
1361
|
+
db_path: Optional explicit DB path (used by tests).
|
|
1362
|
+
|
|
1363
|
+
Returns:
|
|
1364
|
+
The resolved ``project_identity`` string.
|
|
1365
|
+
|
|
1366
|
+
Raises:
|
|
1367
|
+
ValueError: project not found, ambiguous, or has no project_identity.
|
|
1368
|
+
"""
|
|
1369
|
+
con = _connect(db_path)
|
|
1370
|
+
try:
|
|
1371
|
+
rows = con.execute(
|
|
1372
|
+
"SELECT project_identity FROM projects WHERE workspace = ? AND name = ?",
|
|
1373
|
+
(workspace, project_name),
|
|
1374
|
+
).fetchall()
|
|
1375
|
+
finally:
|
|
1376
|
+
con.close()
|
|
1377
|
+
|
|
1378
|
+
if not rows:
|
|
1379
|
+
raise ValueError(
|
|
1380
|
+
f"project {project_name!r} not found in workspace {workspace!r}; "
|
|
1381
|
+
f"cannot anchor memory to it. Check the name with "
|
|
1382
|
+
f"`gaia context query \"SELECT name FROM projects WHERE "
|
|
1383
|
+
f"workspace='{workspace}'\"`."
|
|
1384
|
+
)
|
|
1385
|
+
if len(rows) > 1:
|
|
1386
|
+
# Structurally unreachable today ((workspace, name) is the projects
|
|
1387
|
+
# PK), kept as a defensive guard against a future schema change that
|
|
1388
|
+
# relaxes that constraint -- "never guess" applies here too.
|
|
1389
|
+
raise ValueError(
|
|
1390
|
+
f"project {project_name!r} is ambiguous in workspace {workspace!r} "
|
|
1391
|
+
f"({len(rows)} matching rows); cannot anchor memory to a single "
|
|
1392
|
+
f"identity without guessing."
|
|
1393
|
+
)
|
|
1394
|
+
identity = rows[0]["project_identity"]
|
|
1395
|
+
if not identity:
|
|
1396
|
+
raise ValueError(
|
|
1397
|
+
f"project {project_name!r} in workspace {workspace!r} has no "
|
|
1398
|
+
f"project_identity yet (legacy row, or not yet scanned); "
|
|
1399
|
+
f"cannot anchor memory to it without guessing. Run `gaia scan` "
|
|
1400
|
+
f"first."
|
|
1401
|
+
)
|
|
1402
|
+
return identity
|
|
1403
|
+
|
|
1404
|
+
|
|
1137
1405
|
def upsert_memory(
|
|
1138
1406
|
workspace: str,
|
|
1139
1407
|
name: str,
|
|
@@ -1142,10 +1410,46 @@ def upsert_memory(
|
|
|
1142
1410
|
body: str,
|
|
1143
1411
|
description: str | None = None,
|
|
1144
1412
|
origin_session_id: str | None = None,
|
|
1413
|
+
project_ref: str | None = None,
|
|
1145
1414
|
db_path: Path | None = None,
|
|
1146
1415
|
workspace_path: Path | None = None,
|
|
1147
1416
|
) -> dict:
|
|
1148
1417
|
"""Upsert a curated-memory row in the ``memory`` table.
|
|
1418
|
+
|
|
1419
|
+
Archive-on-upsert (scan-v2 SV3): when this overwrites an existing row, the
|
|
1420
|
+
``memory_au``... no -- the ``trg_memory_history`` AFTER UPDATE trigger fires
|
|
1421
|
+
on the ON CONFLICT DO UPDATE below and archives the PREVIOUS ``body`` (and
|
|
1422
|
+
workspace/type/description/status/deleted_at) into ``memory_history`` before
|
|
1423
|
+
the new value lands. The prior version is never lost; no explicit archival
|
|
1424
|
+
code is needed here because the guarantee is enforced at the SQL layer for
|
|
1425
|
+
every write path, not just this one.
|
|
1426
|
+
|
|
1427
|
+
Resurrection: re-adding a slug that was soft-deleted clears ``deleted_at``
|
|
1428
|
+
(the row returns to the live set). The clearing is captured by the same
|
|
1429
|
+
history trigger.
|
|
1430
|
+
|
|
1431
|
+
``project_ref`` -- forward-only remote-stable project anchor (N3, scan-v2
|
|
1432
|
+
SV3 follow-up). The v25/v26 columns/migration exist, but the automatic
|
|
1433
|
+
backfill in ``scripts/migrations/v25_to_v26.sql`` (guarded on "workspace
|
|
1434
|
+
hosts exactly one active project") is a one-time, already-applied
|
|
1435
|
+
historical statement that populated 0 rows in practice -- the
|
|
1436
|
+
memory-row-to-project mapping is ambiguous whenever a workspace hosts more
|
|
1437
|
+
than one project, and NEVER guessed. There is no live code that re-runs or
|
|
1438
|
+
depends on that guard; going forward, ``project_ref`` is anchored
|
|
1439
|
+
explicitly, at write time, by whoever calls this function knowing which
|
|
1440
|
+
project a ``project``-type row is about (see ``gaia memory add --project``
|
|
1441
|
+
in ``bin/cli/memory.py``, which resolves a project name to its
|
|
1442
|
+
``projects.project_identity`` via :func:`resolve_project_ref` before
|
|
1443
|
+
calling here).
|
|
1444
|
+
|
|
1445
|
+
Coalesce-or-omit (same discipline as ``topic_key`` elsewhere in this
|
|
1446
|
+
module): ``project_ref=None`` (the default) never touches an existing
|
|
1447
|
+
anchor -- an update that does not mention the project leaves a
|
|
1448
|
+
previously-set anchor intact instead of clobbering it back to NULL. Pass
|
|
1449
|
+
an explicit identity string to set or overwrite it. There is no "clear"
|
|
1450
|
+
sentinel; once anchored, forward-only re-anchoring is the only write path
|
|
1451
|
+
(matches the existing ``topic_key`` COALESCE convention -- no precedent in
|
|
1452
|
+
this module for an explicit-NULL clear on a coalesced column).
|
|
1149
1453
|
"""
|
|
1150
1454
|
_assert_dispatch_can_write_memory()
|
|
1151
1455
|
|
|
@@ -1178,17 +1482,19 @@ def upsert_memory(
|
|
|
1178
1482
|
con.execute(
|
|
1179
1483
|
"""
|
|
1180
1484
|
INSERT INTO memory (workspace, name, type, description, body,
|
|
1181
|
-
origin_session_id, updated_at)
|
|
1182
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1485
|
+
project_ref, origin_session_id, updated_at)
|
|
1486
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1183
1487
|
ON CONFLICT(workspace, name) DO UPDATE SET
|
|
1184
1488
|
type = excluded.type,
|
|
1185
1489
|
description = excluded.description,
|
|
1186
1490
|
body = excluded.body,
|
|
1491
|
+
project_ref = COALESCE(excluded.project_ref, project_ref),
|
|
1187
1492
|
origin_session_id = excluded.origin_session_id,
|
|
1188
|
-
updated_at = excluded.updated_at
|
|
1493
|
+
updated_at = excluded.updated_at,
|
|
1494
|
+
deleted_at = NULL
|
|
1189
1495
|
""",
|
|
1190
1496
|
(workspace, name, type, description, body,
|
|
1191
|
-
origin_session_id, now),
|
|
1497
|
+
project_ref, origin_session_id, now),
|
|
1192
1498
|
)
|
|
1193
1499
|
con.commit()
|
|
1194
1500
|
return {
|
|
@@ -1215,15 +1521,49 @@ def delete_memory(
|
|
|
1215
1521
|
workspace: str,
|
|
1216
1522
|
name: str,
|
|
1217
1523
|
*,
|
|
1524
|
+
hard: bool = False,
|
|
1218
1525
|
db_path: Path | None = None,
|
|
1219
1526
|
) -> bool:
|
|
1220
|
-
"""
|
|
1527
|
+
"""Soft-delete (tombstone) a curated memory row -- scan-v2 SV3.
|
|
1528
|
+
|
|
1529
|
+
By default this is a SOFT delete: the row's ``deleted_at`` column is stamped
|
|
1530
|
+
with the current UTC timestamp instead of the row being physically removed.
|
|
1531
|
+
The row and its ``body`` survive (recoverable, and re-addable via
|
|
1532
|
+
:func:`upsert_memory`, which clears the tombstone). The ``trg_memory_history``
|
|
1533
|
+
trigger records the tombstone transition (before_deleted_at NULL -> after
|
|
1534
|
+
non-NULL). All read paths filter ``deleted_at IS NULL`` so a tombstoned row
|
|
1535
|
+
is invisible to normal queries.
|
|
1536
|
+
|
|
1537
|
+
A tombstone is idempotent: calling delete_memory on an already-tombstoned
|
|
1538
|
+
row is a no-op (the row is not re-stamped and no new history row is written).
|
|
1539
|
+
|
|
1540
|
+
``hard=True`` performs the real physical DELETE. This is the ONLY path that
|
|
1541
|
+
destroys the row and its body, and it exists exclusively for explicit human
|
|
1542
|
+
curation ("never hard-delete curated memory except by explicit human
|
|
1543
|
+
curation" -- decision_scan_v2_memory_loss_vectors). The CLI surfaces it via
|
|
1544
|
+
``gaia memory delete --hard`` behind the existing confirmation prompt.
|
|
1545
|
+
|
|
1546
|
+
Returns True when a row was affected (tombstoned or hard-deleted), False
|
|
1547
|
+
when no live row matched (already tombstoned, or absent).
|
|
1548
|
+
"""
|
|
1221
1549
|
_assert_dispatch_can_write_memory()
|
|
1222
1550
|
con = _connect(db_path)
|
|
1223
1551
|
try:
|
|
1552
|
+
if hard:
|
|
1553
|
+
cur = con.execute(
|
|
1554
|
+
"DELETE FROM memory WHERE workspace = ? AND name = ?",
|
|
1555
|
+
(workspace, name),
|
|
1556
|
+
)
|
|
1557
|
+
con.commit()
|
|
1558
|
+
return cur.rowcount > 0
|
|
1559
|
+
# Soft delete: stamp deleted_at only on a currently-live row. The
|
|
1560
|
+
# `deleted_at IS NULL` guard makes a repeated tombstone a no-op (no
|
|
1561
|
+
# spurious history row, no timestamp churn).
|
|
1562
|
+
now = _now_iso()
|
|
1224
1563
|
cur = con.execute(
|
|
1225
|
-
"
|
|
1226
|
-
|
|
1564
|
+
"UPDATE memory SET deleted_at = ?, updated_at = ? "
|
|
1565
|
+
"WHERE workspace = ? AND name = ? AND deleted_at IS NULL",
|
|
1566
|
+
(now, now, workspace, name),
|
|
1227
1567
|
)
|
|
1228
1568
|
con.commit()
|
|
1229
1569
|
return cur.rowcount > 0
|
|
@@ -1708,6 +2048,7 @@ def search_memory_curated(
|
|
|
1708
2048
|
JOIN memory m ON m.rowid = memory_fts.rowid
|
|
1709
2049
|
WHERE memory_fts MATCH ?
|
|
1710
2050
|
AND m.workspace = ?
|
|
2051
|
+
AND m.deleted_at IS NULL
|
|
1711
2052
|
ORDER BY rank
|
|
1712
2053
|
LIMIT ?
|
|
1713
2054
|
""",
|
|
@@ -1735,17 +2076,26 @@ def get_memory(
|
|
|
1735
2076
|
workspace: str,
|
|
1736
2077
|
name: str,
|
|
1737
2078
|
*,
|
|
2079
|
+
include_deleted: bool = False,
|
|
1738
2080
|
db_path: Path | None = None,
|
|
1739
2081
|
) -> dict | None:
|
|
1740
|
-
"""Return a curated memory row as a dict, or ``None`` when missing.
|
|
2082
|
+
"""Return a curated memory row as a dict, or ``None`` when missing.
|
|
2083
|
+
|
|
2084
|
+
Tombstoned rows (``deleted_at`` non-NULL, scan-v2 SV3) are excluded by
|
|
2085
|
+
default so a soft-deleted memory reads as absent. Pass
|
|
2086
|
+
``include_deleted=True`` to reach a tombstoned row (e.g. for an explicit
|
|
2087
|
+
hard-delete or a recovery inspection).
|
|
2088
|
+
"""
|
|
1741
2089
|
con = _connect(db_path)
|
|
1742
2090
|
try:
|
|
1743
|
-
|
|
1744
|
-
"SELECT workspace, name, type, description, body, "
|
|
1745
|
-
" origin_session_id, updated_at "
|
|
1746
|
-
"FROM memory WHERE workspace = ? AND name = ?"
|
|
1747
|
-
|
|
1748
|
-
|
|
2091
|
+
sql = (
|
|
2092
|
+
"SELECT workspace, name, type, description, body, project_ref, "
|
|
2093
|
+
" origin_session_id, updated_at, deleted_at "
|
|
2094
|
+
"FROM memory WHERE workspace = ? AND name = ?"
|
|
2095
|
+
)
|
|
2096
|
+
if not include_deleted:
|
|
2097
|
+
sql += " AND deleted_at IS NULL"
|
|
2098
|
+
row = con.execute(sql, (workspace, name)).fetchone()
|
|
1749
2099
|
if row is None:
|
|
1750
2100
|
return None
|
|
1751
2101
|
return {k: row[k] for k in row.keys()}
|
|
@@ -1757,23 +2107,28 @@ def list_memory(
|
|
|
1757
2107
|
workspace: str,
|
|
1758
2108
|
*,
|
|
1759
2109
|
type: str | None = None,
|
|
2110
|
+
include_deleted: bool = False,
|
|
1760
2111
|
db_path: Path | None = None,
|
|
1761
2112
|
) -> list[dict]:
|
|
1762
|
-
"""List curated memory rows, optionally filtered by ``type``.
|
|
2113
|
+
"""List curated memory rows, optionally filtered by ``type``.
|
|
2114
|
+
|
|
2115
|
+
Tombstoned rows (``deleted_at`` non-NULL, scan-v2 SV3) are excluded by
|
|
2116
|
+
default; pass ``include_deleted=True`` to include them.
|
|
2117
|
+
"""
|
|
1763
2118
|
con = _connect(db_path)
|
|
1764
2119
|
try:
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
2120
|
+
where = ["workspace = ?"]
|
|
2121
|
+
params: list = [workspace]
|
|
2122
|
+
if type is not None:
|
|
2123
|
+
where.append("type = ?")
|
|
2124
|
+
params.append(type)
|
|
2125
|
+
if not include_deleted:
|
|
2126
|
+
where.append("deleted_at IS NULL")
|
|
2127
|
+
sql = (
|
|
2128
|
+
"SELECT name, type, description, updated_at "
|
|
2129
|
+
"FROM memory WHERE " + " AND ".join(where) + " ORDER BY name"
|
|
2130
|
+
)
|
|
2131
|
+
rows = con.execute(sql, params).fetchall()
|
|
1777
2132
|
return [dict(r) for r in rows]
|
|
1778
2133
|
finally:
|
|
1779
2134
|
con.close()
|
|
@@ -2620,15 +2975,629 @@ def reorder_tasks(
|
|
|
2620
2975
|
# Public API: wipe_workspace
|
|
2621
2976
|
# ---------------------------------------------------------------------------
|
|
2622
2977
|
|
|
2623
|
-
def
|
|
2978
|
+
def _reinsert_row(con: sqlite3.Connection, table: str, row: sqlite3.Row) -> None:
|
|
2979
|
+
"""Re-INSERT a captured ``sqlite3.Row`` back into ``table`` verbatim.
|
|
2980
|
+
|
|
2981
|
+
Column list is derived from the row's own keys, so the helper survives
|
|
2982
|
+
schema evolution without hard-coding column names. Used by
|
|
2983
|
+
:func:`wipe_workspace` to restore memory / memory_links / the workspaces row
|
|
2984
|
+
after a CASCADE wipe.
|
|
2985
|
+
"""
|
|
2986
|
+
cols = list(row.keys())
|
|
2987
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
2988
|
+
col_list = ", ".join(cols)
|
|
2989
|
+
con.execute(
|
|
2990
|
+
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
|
2991
|
+
tuple(row[c] for c in cols),
|
|
2992
|
+
)
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def wipe_workspace(
|
|
2996
|
+
workspace: str,
|
|
2997
|
+
*,
|
|
2998
|
+
preserve_memory: bool = True,
|
|
2999
|
+
db_path: Path | None = None,
|
|
3000
|
+
) -> None:
|
|
2624
3001
|
"""Delete the workspaces row for `workspace`. FK CASCADE removes all
|
|
2625
3002
|
child rows (projects, apps, integrations, etc.) automatically.
|
|
3003
|
+
|
|
3004
|
+
Memory preservation (scan-v2 SV3, Vector 4)
|
|
3005
|
+
-------------------------------------------
|
|
3006
|
+
``memory`` and ``memory_links`` are FK'd to ``workspaces`` with ON DELETE
|
|
3007
|
+
CASCADE, so a naive workspace delete DESTROYS all curated memory for the
|
|
3008
|
+
workspace. That is the loss vector `migrate_workspace.py` triggered on every
|
|
3009
|
+
re-scan. This function now DECOUPLES memory from the CASCADE at the app
|
|
3010
|
+
layer -- the safer of the two options (the alternative, changing the FK to
|
|
3011
|
+
ON DELETE SET NULL / RESTRICT, would require a full ``memory`` table rebuild
|
|
3012
|
+
per the v21->v22 precedent).
|
|
3013
|
+
|
|
3014
|
+
With ``preserve_memory=True`` (the DEFAULT): inside a single transaction the
|
|
3015
|
+
memory rows, memory_links rows, and the workspaces row itself are captured
|
|
3016
|
+
BEFORE the delete; the CASCADE then fires as normal; and the workspaces row
|
|
3017
|
+
(with its identity / created_at / status preserved) plus every memory /
|
|
3018
|
+
memory_links row is re-inserted. Net effect: projects and all scannable
|
|
3019
|
+
children are cleared (what a re-scan wants), while curated memory survives
|
|
3020
|
+
untouched. The memory_ai / memory_links insert triggers keep the FTS mirror
|
|
3021
|
+
consistent.
|
|
3022
|
+
|
|
3023
|
+
``preserve_memory=False`` performs the original full CASCADE (memory
|
|
3024
|
+
destroyed). This exists ONLY for explicit human curation -- e.g.
|
|
3025
|
+
``gaia context wipe --purge-memory`` behind its confirmation prompt --
|
|
3026
|
+
honouring "never hard-delete curated memory except by explicit human
|
|
3027
|
+
curation".
|
|
3028
|
+
"""
|
|
3029
|
+
con = _connect(db_path)
|
|
3030
|
+
try:
|
|
3031
|
+
con.execute("BEGIN")
|
|
3032
|
+
try:
|
|
3033
|
+
if preserve_memory:
|
|
3034
|
+
ws_row = con.execute(
|
|
3035
|
+
"SELECT * FROM workspaces WHERE name = ?", (workspace,)
|
|
3036
|
+
).fetchone()
|
|
3037
|
+
mem_rows = con.execute(
|
|
3038
|
+
"SELECT * FROM memory WHERE workspace = ?", (workspace,)
|
|
3039
|
+
).fetchall()
|
|
3040
|
+
link_rows = con.execute(
|
|
3041
|
+
"SELECT * FROM memory_links WHERE workspace = ?", (workspace,)
|
|
3042
|
+
).fetchall()
|
|
3043
|
+
|
|
3044
|
+
con.execute("DELETE FROM workspaces WHERE name = ?", (workspace,))
|
|
3045
|
+
|
|
3046
|
+
# Restore the workspaces row (only when it existed) so the FK
|
|
3047
|
+
# target for the re-inserted memory is present again, then the
|
|
3048
|
+
# memory + links. If the workspace had no row, there was nothing
|
|
3049
|
+
# to preserve and the delete was a no-op.
|
|
3050
|
+
if ws_row is not None:
|
|
3051
|
+
_reinsert_row(con, "workspaces", ws_row)
|
|
3052
|
+
for r in mem_rows:
|
|
3053
|
+
_reinsert_row(con, "memory", r)
|
|
3054
|
+
for r in link_rows:
|
|
3055
|
+
_reinsert_row(con, "memory_links", r)
|
|
3056
|
+
else:
|
|
3057
|
+
con.execute("DELETE FROM workspaces WHERE name = ?", (workspace,))
|
|
3058
|
+
con.commit()
|
|
3059
|
+
except Exception:
|
|
3060
|
+
con.rollback()
|
|
3061
|
+
raise
|
|
3062
|
+
finally:
|
|
3063
|
+
con.close()
|
|
3064
|
+
|
|
3065
|
+
|
|
3066
|
+
# ---------------------------------------------------------------------------
|
|
3067
|
+
# Surgical reconciliation helpers (workspace-identity brief, M4/T10)
|
|
3068
|
+
# ---------------------------------------------------------------------------
|
|
3069
|
+
#
|
|
3070
|
+
# `gaia scan` reconciles the `projects` table workspace-by-workspace: it
|
|
3071
|
+
# upserts the repos it finds under the SCANNED workspace and prunes
|
|
3072
|
+
# (mark_missing_in / delete_missing_in) only WITHIN that same workspace. Two
|
|
3073
|
+
# consequences make a plain rescan unable to repair historical drift:
|
|
3074
|
+
#
|
|
3075
|
+
# 1. A stale row with project_identity=NULL living under a DIFFERENT
|
|
3076
|
+
# workspace than the one being scanned is never collapsed (the identity-
|
|
3077
|
+
# collapse path in upsert_project only fires for a non-null identity) and
|
|
3078
|
+
# never pruned (prune is scoped to the scanned workspace) -- so a rescan
|
|
3079
|
+
# RESURRECTS the repo as a fresh duplicate row and leaves the stale row
|
|
3080
|
+
# ORPHANED under its old workspace.
|
|
3081
|
+
# 2. project_context_contracts is not touched by scan at all, so a contract
|
|
3082
|
+
# written under the wrong workspace can only be corrected by moving it.
|
|
3083
|
+
#
|
|
3084
|
+
# wipe_workspace is too blunt for a LIVE workspace: it CASCADE-deletes the
|
|
3085
|
+
# workspaces row and EVERY child (projects, memory, briefs, episodes, PCC).
|
|
3086
|
+
# The helper below is the surgical, workspace-preserving primitive that
|
|
3087
|
+
# repairs drift without destroying collateral:
|
|
3088
|
+
#
|
|
3089
|
+
# relocate_contracts -- re-key project_context_contracts rows between
|
|
3090
|
+
# workspaces (the only correction path for mis-keyed PCC).
|
|
3091
|
+
#
|
|
3092
|
+
# NOTE: a `delete_projects` sibling (targeted deletion of `projects` rows)
|
|
3093
|
+
# existed here as a one-time reconciliation tool (workspace-identity brief
|
|
3094
|
+
# M4/T10) and was removed in scan-v2 SV4 -- agents must never hold the power
|
|
3095
|
+
# to hard-delete project rows; `mark_missing_in` (soft-delete, scanner-owned)
|
|
3096
|
+
# and the resolve-move adjudication (re-key + tombstone, see
|
|
3097
|
+
# `resolve_move_candidate` below) are the only sanctioned paths that touch a
|
|
3098
|
+
# project row's lifecycle.
|
|
3099
|
+
# ---------------------------------------------------------------------------
|
|
3100
|
+
|
|
3101
|
+
|
|
3102
|
+
def relocate_contracts(
|
|
3103
|
+
from_workspace: str,
|
|
3104
|
+
to_workspace: str,
|
|
3105
|
+
contracts: Sequence[str],
|
|
3106
|
+
*,
|
|
3107
|
+
on_conflict: str = "error",
|
|
3108
|
+
dry_run: bool = False,
|
|
3109
|
+
db_path: Path | None = None,
|
|
3110
|
+
) -> dict:
|
|
3111
|
+
"""Re-key ``project_context_contracts`` rows from one workspace to another.
|
|
3112
|
+
|
|
3113
|
+
`gaia scan` never touches project_context_contracts, so a contract written
|
|
3114
|
+
under the wrong workspace (e.g. AOS project context mis-keyed to the 'me'
|
|
3115
|
+
workspace) can only be corrected by moving the row. This re-keys the named
|
|
3116
|
+
contracts by UPDATEing the ``workspace`` PK column IN PLACE -- payload,
|
|
3117
|
+
metadata and updated_at are preserved, and the ``trg_pcc_history`` AFTER
|
|
3118
|
+
UPDATE trigger records the move in project_context_contracts_history.
|
|
3119
|
+
|
|
3120
|
+
``to_workspace`` must satisfy the FK to workspaces(name); it is created via
|
|
3121
|
+
:func:`_ensure_workspace_row` when absent. The PK is
|
|
3122
|
+
(workspace, contract_name), so if (to_workspace, contract) ALREADY exists
|
|
3123
|
+
``on_conflict`` decides:
|
|
3124
|
+
|
|
3125
|
+
'error' -- raise ValueError, move nothing (default; never clobber)
|
|
3126
|
+
'skip' -- leave both rows; report the contract under 'skipped'
|
|
3127
|
+
'overwrite' -- delete the target row first, then move the source row
|
|
3128
|
+
|
|
3129
|
+
Idempotent: a contract already absent from ``from_workspace`` is reported
|
|
3130
|
+
under 'missing' and is a no-op, so re-running after a partial apply is safe.
|
|
3131
|
+
|
|
3132
|
+
Args:
|
|
3133
|
+
from_workspace: Source workspace (current, wrong key).
|
|
3134
|
+
to_workspace: Destination workspace (correct key).
|
|
3135
|
+
contracts: Contract names to move (project_context_contracts.contract_name).
|
|
3136
|
+
on_conflict: 'error' | 'skip' | 'overwrite' (see above).
|
|
3137
|
+
dry_run: When True, mutate nothing; report the classification only.
|
|
3138
|
+
db_path: Optional explicit DB path (used by tests).
|
|
3139
|
+
|
|
3140
|
+
Returns:
|
|
3141
|
+
{"status": "applied"|"preview", "from": ..., "to": ...,
|
|
3142
|
+
"moved": [...], "skipped": [...], "missing": [...], "overwritten": [...]}.
|
|
3143
|
+
|
|
3144
|
+
Raises:
|
|
3145
|
+
ValueError: on invalid on_conflict, empty contracts, from==to, or an
|
|
3146
|
+
unresolved PK conflict when on_conflict='error'.
|
|
3147
|
+
"""
|
|
3148
|
+
if on_conflict not in ("error", "skip", "overwrite"):
|
|
3149
|
+
raise ValueError(
|
|
3150
|
+
f"relocate_contracts: invalid on_conflict {on_conflict!r}; "
|
|
3151
|
+
f"must be 'error', 'skip', or 'overwrite'"
|
|
3152
|
+
)
|
|
3153
|
+
contract_list = list(contracts)
|
|
3154
|
+
if not contract_list:
|
|
3155
|
+
raise ValueError("relocate_contracts: at least one contract is required")
|
|
3156
|
+
if from_workspace == to_workspace:
|
|
3157
|
+
raise ValueError(
|
|
3158
|
+
"relocate_contracts: from_workspace and to_workspace are identical"
|
|
3159
|
+
)
|
|
3160
|
+
|
|
3161
|
+
moved: list[str] = []
|
|
3162
|
+
skipped: list[str] = []
|
|
3163
|
+
missing: list[str] = []
|
|
3164
|
+
overwritten: list[str] = []
|
|
3165
|
+
|
|
3166
|
+
con = _connect(db_path)
|
|
3167
|
+
try:
|
|
3168
|
+
con.execute("BEGIN")
|
|
3169
|
+
try:
|
|
3170
|
+
if not dry_run:
|
|
3171
|
+
_ensure_workspace_row(con, to_workspace)
|
|
3172
|
+
|
|
3173
|
+
for name in contract_list:
|
|
3174
|
+
src = con.execute(
|
|
3175
|
+
"SELECT 1 FROM project_context_contracts "
|
|
3176
|
+
"WHERE workspace = ? AND contract_name = ?",
|
|
3177
|
+
(from_workspace, name),
|
|
3178
|
+
).fetchone()
|
|
3179
|
+
if src is None:
|
|
3180
|
+
missing.append(name)
|
|
3181
|
+
continue
|
|
3182
|
+
|
|
3183
|
+
dst = con.execute(
|
|
3184
|
+
"SELECT 1 FROM project_context_contracts "
|
|
3185
|
+
"WHERE workspace = ? AND contract_name = ?",
|
|
3186
|
+
(to_workspace, name),
|
|
3187
|
+
).fetchone()
|
|
3188
|
+
if dst is not None:
|
|
3189
|
+
if on_conflict == "error":
|
|
3190
|
+
raise ValueError(
|
|
3191
|
+
f"relocate_contracts: target already has contract "
|
|
3192
|
+
f"{name!r} under workspace {to_workspace!r} "
|
|
3193
|
+
f"(on_conflict='error')"
|
|
3194
|
+
)
|
|
3195
|
+
if on_conflict == "skip":
|
|
3196
|
+
skipped.append(name)
|
|
3197
|
+
continue
|
|
3198
|
+
# overwrite
|
|
3199
|
+
if not dry_run:
|
|
3200
|
+
con.execute(
|
|
3201
|
+
"DELETE FROM project_context_contracts "
|
|
3202
|
+
"WHERE workspace = ? AND contract_name = ?",
|
|
3203
|
+
(to_workspace, name),
|
|
3204
|
+
)
|
|
3205
|
+
overwritten.append(name)
|
|
3206
|
+
|
|
3207
|
+
if not dry_run:
|
|
3208
|
+
con.execute(
|
|
3209
|
+
"UPDATE project_context_contracts SET workspace = ? "
|
|
3210
|
+
"WHERE workspace = ? AND contract_name = ?",
|
|
3211
|
+
(to_workspace, from_workspace, name),
|
|
3212
|
+
)
|
|
3213
|
+
moved.append(name)
|
|
3214
|
+
|
|
3215
|
+
con.commit()
|
|
3216
|
+
except Exception:
|
|
3217
|
+
con.rollback()
|
|
3218
|
+
raise
|
|
3219
|
+
finally:
|
|
3220
|
+
con.close()
|
|
3221
|
+
|
|
3222
|
+
return {
|
|
3223
|
+
"status": "preview" if dry_run else "applied",
|
|
3224
|
+
"from": from_workspace,
|
|
3225
|
+
"to": to_workspace,
|
|
3226
|
+
"moved": moved,
|
|
3227
|
+
"skipped": skipped,
|
|
3228
|
+
"missing": missing,
|
|
3229
|
+
"overwritten": overwritten,
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
|
|
3233
|
+
def relocate_memory(
|
|
3234
|
+
from_workspace: str,
|
|
3235
|
+
to_workspace: str,
|
|
3236
|
+
names: Sequence[str],
|
|
3237
|
+
*,
|
|
3238
|
+
on_conflict: str = "error",
|
|
3239
|
+
dry_run: bool = False,
|
|
3240
|
+
db_path: Path | None = None,
|
|
3241
|
+
) -> dict:
|
|
3242
|
+
"""Re-key curated ``memory`` rows (and their intra-set ``memory_links``)
|
|
3243
|
+
between workspaces -- the mirror of :func:`relocate_contracts` for the
|
|
3244
|
+
curated memory table.
|
|
3245
|
+
|
|
3246
|
+
`gaia scan` never touches ``memory``, so a memory row written under the
|
|
3247
|
+
wrong workspace (e.g. the 'project_gaia_roadmap' / 'user_blog_articles'
|
|
3248
|
+
notes mis-keyed to the 'rnd' workspace but belonging to 'me') can only be
|
|
3249
|
+
corrected by moving the row. This re-keys the named rows by UPDATEing the
|
|
3250
|
+
``workspace`` PK column IN PLACE: every other column (type, description,
|
|
3251
|
+
body, origin_session_id, updated_at, class, status) is preserved untouched,
|
|
3252
|
+
and the ``memory_au`` AFTER UPDATE trigger keeps the ``memory_fts`` mirror
|
|
3253
|
+
in sync (workspace is an FTS column, so the mirror row is rewritten).
|
|
3254
|
+
|
|
3255
|
+
memory_links follow the notes: a link under ``from_workspace`` whose BOTH
|
|
3256
|
+
endpoints are in the moved set is re-keyed to ``to_workspace`` (the edge
|
|
3257
|
+
travels with the pair). A link with only ONE endpoint in the moved set
|
|
3258
|
+
cannot stay consistent under the single-workspace link model -- scan-v2 SV3
|
|
3259
|
+
DELETES that now-dangling edge (its endpoint left the workspace) and reports
|
|
3260
|
+
it under 'partial_links'. The link is derived graph metadata, not curated
|
|
3261
|
+
memory: both endpoint rows survive untouched; only the broken edge is
|
|
3262
|
+
removed, so nothing is lost silently and no dangling reference is left
|
|
3263
|
+
behind.
|
|
3264
|
+
|
|
3265
|
+
Provenance (scan-v2 SV3): the workspace re-key UPDATE fires the
|
|
3266
|
+
``trg_memory_history`` trigger, which records before_workspace ->
|
|
3267
|
+
after_workspace for each moved row -- the origin of every move is preserved
|
|
3268
|
+
in ``memory_history`` automatically, no explicit trace-write needed.
|
|
3269
|
+
|
|
3270
|
+
``to_workspace`` must satisfy the FK to workspaces(name); it is created via
|
|
3271
|
+
:func:`_ensure_workspace_row` when absent. PK is (workspace, name); on a
|
|
3272
|
+
(to_workspace, name) collision ``on_conflict`` decides:
|
|
3273
|
+
|
|
3274
|
+
'error' -- raise ValueError, move nothing (default; never clobber)
|
|
3275
|
+
'skip' -- leave both rows; report the name under 'skipped'
|
|
3276
|
+
'overwrite' -- delete the target row first, then move the source row
|
|
3277
|
+
|
|
3278
|
+
Idempotent: a name already absent from ``from_workspace`` is reported under
|
|
3279
|
+
'missing' and is a no-op, so re-running after a partial apply is safe.
|
|
3280
|
+
|
|
3281
|
+
Subject to the curated-memory write guard
|
|
3282
|
+
(:func:`_assert_dispatch_can_write_memory`): like every other memory
|
|
3283
|
+
mutator, this refuses writes from a NON-curator subagent dispatch. Run it
|
|
3284
|
+
from a human shell or the orchestrator/operator context.
|
|
3285
|
+
|
|
3286
|
+
Returns:
|
|
3287
|
+
{"status": "applied"|"preview", "from": ..., "to": ...,
|
|
3288
|
+
"moved": [...], "skipped": [...], "missing": [...],
|
|
3289
|
+
"overwritten": [...],
|
|
3290
|
+
"links_moved": [{"src","dst","kind"}...],
|
|
3291
|
+
"partial_links": [{"src","dst","kind"}...]}.
|
|
3292
|
+
|
|
3293
|
+
Raises:
|
|
3294
|
+
ValueError: invalid on_conflict, empty names, from==to, or an
|
|
3295
|
+
unresolved PK conflict when on_conflict='error'.
|
|
3296
|
+
MemoryWriteForbidden: when GAIA_DISPATCH_AGENT names a non-curator.
|
|
3297
|
+
"""
|
|
3298
|
+
_assert_dispatch_can_write_memory()
|
|
3299
|
+
|
|
3300
|
+
if on_conflict not in ("error", "skip", "overwrite"):
|
|
3301
|
+
raise ValueError(
|
|
3302
|
+
f"relocate_memory: invalid on_conflict {on_conflict!r}; "
|
|
3303
|
+
f"must be 'error', 'skip', or 'overwrite'"
|
|
3304
|
+
)
|
|
3305
|
+
name_list = list(names)
|
|
3306
|
+
if not name_list:
|
|
3307
|
+
raise ValueError("relocate_memory: at least one name is required")
|
|
3308
|
+
if from_workspace == to_workspace:
|
|
3309
|
+
raise ValueError(
|
|
3310
|
+
"relocate_memory: from_workspace and to_workspace are identical"
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
moved: list[str] = []
|
|
3314
|
+
skipped: list[str] = []
|
|
3315
|
+
missing: list[str] = []
|
|
3316
|
+
overwritten: list[str] = []
|
|
3317
|
+
links_moved: list[dict] = []
|
|
3318
|
+
partial_links: list[dict] = []
|
|
3319
|
+
|
|
3320
|
+
con = _connect(db_path)
|
|
3321
|
+
try:
|
|
3322
|
+
con.execute("BEGIN")
|
|
3323
|
+
try:
|
|
3324
|
+
if not dry_run:
|
|
3325
|
+
_ensure_workspace_row(con, to_workspace)
|
|
3326
|
+
|
|
3327
|
+
for name in name_list:
|
|
3328
|
+
src = con.execute(
|
|
3329
|
+
"SELECT 1 FROM memory WHERE workspace = ? AND name = ?",
|
|
3330
|
+
(from_workspace, name),
|
|
3331
|
+
).fetchone()
|
|
3332
|
+
if src is None:
|
|
3333
|
+
missing.append(name)
|
|
3334
|
+
continue
|
|
3335
|
+
|
|
3336
|
+
dst = con.execute(
|
|
3337
|
+
"SELECT 1 FROM memory WHERE workspace = ? AND name = ?",
|
|
3338
|
+
(to_workspace, name),
|
|
3339
|
+
).fetchone()
|
|
3340
|
+
if dst is not None:
|
|
3341
|
+
if on_conflict == "error":
|
|
3342
|
+
raise ValueError(
|
|
3343
|
+
f"relocate_memory: target already has memory "
|
|
3344
|
+
f"{name!r} under workspace {to_workspace!r} "
|
|
3345
|
+
f"(on_conflict='error')"
|
|
3346
|
+
)
|
|
3347
|
+
if on_conflict == "skip":
|
|
3348
|
+
skipped.append(name)
|
|
3349
|
+
continue
|
|
3350
|
+
# overwrite: drop the target row first (memory_ad keeps FTS
|
|
3351
|
+
# in sync); its own links under `to` are left as-is.
|
|
3352
|
+
if not dry_run:
|
|
3353
|
+
con.execute(
|
|
3354
|
+
"DELETE FROM memory WHERE workspace = ? AND name = ?",
|
|
3355
|
+
(to_workspace, name),
|
|
3356
|
+
)
|
|
3357
|
+
overwritten.append(name)
|
|
3358
|
+
|
|
3359
|
+
if not dry_run:
|
|
3360
|
+
con.execute(
|
|
3361
|
+
"UPDATE memory SET workspace = ? "
|
|
3362
|
+
"WHERE workspace = ? AND name = ?",
|
|
3363
|
+
(to_workspace, from_workspace, name),
|
|
3364
|
+
)
|
|
3365
|
+
moved.append(name)
|
|
3366
|
+
|
|
3367
|
+
# Re-key links that live entirely within the moved set.
|
|
3368
|
+
moved_set = set(moved)
|
|
3369
|
+
if moved_set:
|
|
3370
|
+
link_rows = con.execute(
|
|
3371
|
+
"SELECT src_name, dst_name, kind FROM memory_links "
|
|
3372
|
+
"WHERE workspace = ?",
|
|
3373
|
+
(from_workspace,),
|
|
3374
|
+
).fetchall()
|
|
3375
|
+
for lr in link_rows:
|
|
3376
|
+
src_in = lr["src_name"] in moved_set
|
|
3377
|
+
dst_in = lr["dst_name"] in moved_set
|
|
3378
|
+
if not (src_in or dst_in):
|
|
3379
|
+
continue
|
|
3380
|
+
entry = {
|
|
3381
|
+
"src": lr["src_name"],
|
|
3382
|
+
"dst": lr["dst_name"],
|
|
3383
|
+
"kind": lr["kind"],
|
|
3384
|
+
}
|
|
3385
|
+
if src_in and dst_in:
|
|
3386
|
+
if not dry_run:
|
|
3387
|
+
con.execute(
|
|
3388
|
+
"UPDATE memory_links SET workspace = ? "
|
|
3389
|
+
"WHERE workspace = ? AND src_name = ? "
|
|
3390
|
+
"AND dst_name = ? AND kind = ?",
|
|
3391
|
+
(to_workspace, from_workspace,
|
|
3392
|
+
lr["src_name"], lr["dst_name"], lr["kind"]),
|
|
3393
|
+
)
|
|
3394
|
+
links_moved.append(entry)
|
|
3395
|
+
else:
|
|
3396
|
+
# Only one endpoint moved. Under the single-workspace
|
|
3397
|
+
# link model this edge is now referentially dangling:
|
|
3398
|
+
# one of its endpoints no longer exists under
|
|
3399
|
+
# ``from_workspace`` and cannot be re-homed to
|
|
3400
|
+
# ``to_workspace`` (the other endpoint stayed). Leaving
|
|
3401
|
+
# it in place is silent corruption -- scan-v2 SV3 removes
|
|
3402
|
+
# the dangling edge and reports it under 'partial_links'
|
|
3403
|
+
# so nothing is lost silently. A link is derived graph
|
|
3404
|
+
# metadata, not curated memory: both endpoint rows (the
|
|
3405
|
+
# data) survive untouched; only the broken edge is
|
|
3406
|
+
# dropped. Never touches memory rows.
|
|
3407
|
+
if not dry_run:
|
|
3408
|
+
con.execute(
|
|
3409
|
+
"DELETE FROM memory_links "
|
|
3410
|
+
"WHERE workspace = ? AND src_name = ? "
|
|
3411
|
+
"AND dst_name = ? AND kind = ?",
|
|
3412
|
+
(from_workspace, lr["src_name"],
|
|
3413
|
+
lr["dst_name"], lr["kind"]),
|
|
3414
|
+
)
|
|
3415
|
+
partial_links.append(entry)
|
|
3416
|
+
|
|
3417
|
+
con.commit()
|
|
3418
|
+
except Exception:
|
|
3419
|
+
con.rollback()
|
|
3420
|
+
raise
|
|
3421
|
+
finally:
|
|
3422
|
+
con.close()
|
|
3423
|
+
|
|
3424
|
+
return {
|
|
3425
|
+
"status": "preview" if dry_run else "applied",
|
|
3426
|
+
"from": from_workspace,
|
|
3427
|
+
"to": to_workspace,
|
|
3428
|
+
"moved": moved,
|
|
3429
|
+
"skipped": skipped,
|
|
3430
|
+
"missing": missing,
|
|
3431
|
+
"overwritten": overwritten,
|
|
3432
|
+
"links_moved": links_moved,
|
|
3433
|
+
"partial_links": partial_links,
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
|
|
3437
|
+
# ---------------------------------------------------------------------------
|
|
3438
|
+
# scan-v2 SV4: move-candidate adjudication (superseded_by write / re-key).
|
|
3439
|
+
#
|
|
3440
|
+
# `gaia scan` (SV2) only DETECTS and REPORTS a move -- it pairs a project that
|
|
3441
|
+
# vanished from one workspace 1:1 (by normalized remote) with a project that
|
|
3442
|
+
# appeared in another, and emits a `move_candidate`. It never mutates the
|
|
3443
|
+
# lineage. A human then adjudicates each candidate; `resolve_move_candidate`
|
|
3444
|
+
# below is the write path that EXECUTES an adjudicated 'movido' decision.
|
|
3445
|
+
#
|
|
3446
|
+
# Post-scan, a detected move leaves TWO rows in `projects`:
|
|
3447
|
+
# * the OLD row (the `from` side): now status='missing' (soft-deleted by the
|
|
3448
|
+
# reconcile pass), still carrying the pre-move project_identity (its
|
|
3449
|
+
# git-common-dir at the old location) and any agent-owned `description`.
|
|
3450
|
+
# * the NEW row (the `to` side): freshly upserted, status='active', carrying
|
|
3451
|
+
# a DIFFERENT project_identity (the git-common-dir changed when the repo
|
|
3452
|
+
# physically moved). This is the successor.
|
|
3453
|
+
#
|
|
3454
|
+
# The 'movido' adjudication links the two WITHOUT ever hard-deleting either:
|
|
3455
|
+
# * When the successor row ALREADY exists (the realistic post-scan state, and
|
|
3456
|
+
# the only state a move_candidate is ever emitted from): the old row is
|
|
3457
|
+
# tombstoned (status='missing') and its `superseded_by` column is set to the
|
|
3458
|
+
# successor's project_identity -- the forward link that records "this row's
|
|
3459
|
+
# project moved to the row bearing identity X". Both rows survive; the
|
|
3460
|
+
# successor stays the active canonical at the new (workspace, name). A
|
|
3461
|
+
# merge/re-key of the old row ONTO the successor slot is impossible without
|
|
3462
|
+
# destroying the successor row (a hard delete), which the no-hard-delete
|
|
3463
|
+
# principle forbids -- so the link, not a key rewrite, is the mechanism.
|
|
3464
|
+
# * When the successor row does NOT exist (defensive path, e.g. adjudicating
|
|
3465
|
+
# from a cross-DB or dry-run report where the new location was never
|
|
3466
|
+
# scanned into its own row): the OLD row is RE-KEYED in place -- its
|
|
3467
|
+
# (workspace, name) is updated to the successor location and status flipped
|
|
3468
|
+
# back to 'active'. The row (identity, description, remote) travels intact;
|
|
3469
|
+
# the re-key preserves the data.
|
|
3470
|
+
#
|
|
3471
|
+
# Agent-authored collateral (curated `memory`, `project_context_contracts`) is
|
|
3472
|
+
# NEVER auto-moved here -- it is only PROPOSED. The human relocates it
|
|
3473
|
+
# deliberately via `gaia context move-memory` / `move-contracts` once the move
|
|
3474
|
+
# is confirmed. This function touches only the `projects` lineage.
|
|
3475
|
+
#
|
|
3476
|
+
# 'duplicado' / 'worktree' decisions are a structural no-op: both rows are
|
|
3477
|
+
# legitimately independent and are left exactly as they are (see the CLI
|
|
3478
|
+
# `--decision` handling; this writer is only invoked for 'movido').
|
|
3479
|
+
# ---------------------------------------------------------------------------
|
|
3480
|
+
|
|
3481
|
+
def resolve_move_candidate(
|
|
3482
|
+
from_workspace: str,
|
|
3483
|
+
from_name: str,
|
|
3484
|
+
to_workspace: str,
|
|
3485
|
+
to_name: str,
|
|
3486
|
+
*,
|
|
3487
|
+
dry_run: bool = False,
|
|
3488
|
+
db_path: Path | None = None,
|
|
3489
|
+
) -> dict:
|
|
3490
|
+
"""Execute a 'movido' adjudication of a scan-v2 move_candidate.
|
|
3491
|
+
|
|
3492
|
+
Links the OLD (``from``) project row to its successor (``to``) without ever
|
|
3493
|
+
hard-deleting a row. Two branches (see the module comment above):
|
|
3494
|
+
|
|
3495
|
+
* successor row EXISTS -> tombstone the old row (status='missing') and
|
|
3496
|
+
write ``superseded_by`` = successor.project_identity on it. Both rows
|
|
3497
|
+
survive; the successor stays the active canonical. action='superseded'.
|
|
3498
|
+
* successor row ABSENT -> re-key the old row in place: update its
|
|
3499
|
+
(workspace, name) to the successor location, flip status back to
|
|
3500
|
+
'active', clear missing_since. The row's data travels intact.
|
|
3501
|
+
action='rekeyed'.
|
|
3502
|
+
|
|
3503
|
+
Curated memory / PCC are NOT moved here -- they are proposed for a separate
|
|
3504
|
+
`move-memory` / `move-contracts` step. This function only touches the
|
|
3505
|
+
`projects` lineage.
|
|
3506
|
+
|
|
3507
|
+
Args:
|
|
3508
|
+
from_workspace: Old row workspace (move_candidate ``from.workspace``).
|
|
3509
|
+
from_name: Old row name (move_candidate ``from.project``).
|
|
3510
|
+
to_workspace: Successor workspace (move_candidate ``to.workspace``).
|
|
3511
|
+
to_name: Successor name (move_candidate ``to.project``).
|
|
3512
|
+
dry_run: When True, mutate nothing; report the branch + successor
|
|
3513
|
+
identity that WOULD be written.
|
|
3514
|
+
db_path: Optional explicit DB path (used by tests).
|
|
3515
|
+
|
|
3516
|
+
Returns:
|
|
3517
|
+
{"status": "applied"|"preview", "action": "superseded"|"rekeyed",
|
|
3518
|
+
"from": {"workspace","name"}, "to": {"workspace","name"},
|
|
3519
|
+
"superseded_by": <successor project_identity or None>,
|
|
3520
|
+
"proposed_relocations": {"memory": <n>, "contracts": <n>}}.
|
|
3521
|
+
|
|
3522
|
+
Raises:
|
|
3523
|
+
ValueError: when the old row does not exist, or from == to.
|
|
2626
3524
|
"""
|
|
3525
|
+
if not from_workspace or not from_name:
|
|
3526
|
+
raise ValueError("resolve_move_candidate: from_workspace and from_name are required")
|
|
3527
|
+
if not to_workspace or not to_name:
|
|
3528
|
+
raise ValueError("resolve_move_candidate: to_workspace and to_name are required")
|
|
3529
|
+
if (from_workspace, from_name) == (to_workspace, to_name):
|
|
3530
|
+
raise ValueError(
|
|
3531
|
+
"resolve_move_candidate: from and to are identical -- nothing to resolve"
|
|
3532
|
+
)
|
|
3533
|
+
|
|
2627
3534
|
con = _connect(db_path)
|
|
2628
3535
|
try:
|
|
2629
3536
|
con.execute("BEGIN")
|
|
2630
3537
|
try:
|
|
2631
|
-
con.execute(
|
|
3538
|
+
old = con.execute(
|
|
3539
|
+
"SELECT workspace, name, project_identity, status "
|
|
3540
|
+
"FROM projects WHERE workspace = ? AND name = ?",
|
|
3541
|
+
(from_workspace, from_name),
|
|
3542
|
+
).fetchone()
|
|
3543
|
+
if old is None:
|
|
3544
|
+
raise ValueError(
|
|
3545
|
+
f"resolve_move_candidate: old row "
|
|
3546
|
+
f"({from_workspace!r}, {from_name!r}) not found"
|
|
3547
|
+
)
|
|
3548
|
+
|
|
3549
|
+
successor = con.execute(
|
|
3550
|
+
"SELECT workspace, name, project_identity, status "
|
|
3551
|
+
"FROM projects WHERE workspace = ? AND name = ?",
|
|
3552
|
+
(to_workspace, to_name),
|
|
3553
|
+
).fetchone()
|
|
3554
|
+
|
|
3555
|
+
# Count agent-authored collateral still keyed to the OLD workspace,
|
|
3556
|
+
# so the caller can PROPOSE (never auto-execute) its relocation.
|
|
3557
|
+
proposed_memory = con.execute(
|
|
3558
|
+
"SELECT COUNT(*) FROM memory WHERE workspace = ? "
|
|
3559
|
+
"AND deleted_at IS NULL",
|
|
3560
|
+
(from_workspace,),
|
|
3561
|
+
).fetchone()[0]
|
|
3562
|
+
proposed_contracts = con.execute(
|
|
3563
|
+
"SELECT COUNT(*) FROM project_context_contracts WHERE workspace = ?",
|
|
3564
|
+
(from_workspace,),
|
|
3565
|
+
).fetchone()[0]
|
|
3566
|
+
|
|
3567
|
+
now = _now_iso()
|
|
3568
|
+
|
|
3569
|
+
if successor is not None:
|
|
3570
|
+
# Realistic post-scan state: two rows. Tombstone the old row and
|
|
3571
|
+
# link it forward to the successor identity. Never hard-delete.
|
|
3572
|
+
action = "superseded"
|
|
3573
|
+
superseded_by = successor["project_identity"]
|
|
3574
|
+
if not dry_run:
|
|
3575
|
+
con.execute(
|
|
3576
|
+
"UPDATE projects SET status = 'missing', "
|
|
3577
|
+
"missing_since = COALESCE(missing_since, ?), "
|
|
3578
|
+
"superseded_by = ? "
|
|
3579
|
+
"WHERE workspace = ? AND name = ?",
|
|
3580
|
+
(now, superseded_by, from_workspace, from_name),
|
|
3581
|
+
)
|
|
3582
|
+
# Ensure the successor is the active canonical row.
|
|
3583
|
+
con.execute(
|
|
3584
|
+
"UPDATE projects SET status = 'active', missing_since = NULL "
|
|
3585
|
+
"WHERE workspace = ? AND name = ?",
|
|
3586
|
+
(to_workspace, to_name),
|
|
3587
|
+
)
|
|
3588
|
+
else:
|
|
3589
|
+
# Successor slot is free: re-key the old row in place. The row's
|
|
3590
|
+
# identity + description + remote travel with it (data preserved).
|
|
3591
|
+
action = "rekeyed"
|
|
3592
|
+
superseded_by = old["project_identity"]
|
|
3593
|
+
if not dry_run:
|
|
3594
|
+
con.execute(
|
|
3595
|
+
"UPDATE projects SET workspace = ?, name = ?, "
|
|
3596
|
+
"status = 'active', missing_since = NULL "
|
|
3597
|
+
"WHERE workspace = ? AND name = ?",
|
|
3598
|
+
(to_workspace, to_name, from_workspace, from_name),
|
|
3599
|
+
)
|
|
3600
|
+
|
|
2632
3601
|
con.commit()
|
|
2633
3602
|
except Exception:
|
|
2634
3603
|
con.rollback()
|
|
@@ -2636,6 +3605,18 @@ def wipe_workspace(workspace: str, *, db_path: Path | None = None) -> None:
|
|
|
2636
3605
|
finally:
|
|
2637
3606
|
con.close()
|
|
2638
3607
|
|
|
3608
|
+
return {
|
|
3609
|
+
"status": "preview" if dry_run else "applied",
|
|
3610
|
+
"action": action,
|
|
3611
|
+
"from": {"workspace": from_workspace, "name": from_name},
|
|
3612
|
+
"to": {"workspace": to_workspace, "name": to_name},
|
|
3613
|
+
"superseded_by": superseded_by,
|
|
3614
|
+
"proposed_relocations": {
|
|
3615
|
+
"memory": proposed_memory,
|
|
3616
|
+
"contracts": proposed_contracts,
|
|
3617
|
+
},
|
|
3618
|
+
}
|
|
3619
|
+
|
|
2639
3620
|
|
|
2640
3621
|
# ---------------------------------------------------------------------------
|
|
2641
3622
|
# Public API: approval_grants (DB-backed command_set grant store, M3)
|
|
@@ -3009,10 +3990,10 @@ def insert_semantic_grant(
|
|
|
3009
3990
|
Retained for audit only -- check_db_semantic_grant() matches
|
|
3010
3991
|
cross-session (Brief 71), so this is NOT used to scope lookup.
|
|
3011
3992
|
ttl_minutes: Grant lifetime in minutes. Defaults to
|
|
3012
|
-
APPROVAL_GRANT_TTL_MINUTES (
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3993
|
+
APPROVAL_GRANT_TTL_MINUTES (5). The grant is consumed AT THE MATCH,
|
|
3994
|
+
so this short window only needs to cover the block -> approve ->
|
|
3995
|
+
retry round trip; a grant never presented to a matching retry simply
|
|
3996
|
+
expires. This is the GRANT window, distinct from the 24h pending
|
|
3016
3997
|
window (DEFAULT_PENDING_TTL_MINUTES).
|
|
3017
3998
|
db_path: Optional explicit DB path (used by tests).
|
|
3018
3999
|
|
|
@@ -3517,9 +4498,9 @@ def consume_db_file_path_grant(
|
|
|
3517
4498
|
# ---------------------------------------------------------------------------
|
|
3518
4499
|
#
|
|
3519
4500
|
# Foundation scaffolding for the grant-lifecycle FS-to-DB migration (v20).
|
|
3520
|
-
#
|
|
3521
|
-
# consume_session_grants
|
|
3522
|
-
#
|
|
4501
|
+
# confirm_db_grant() backs the confirm_grant flow. (The former
|
|
4502
|
+
# consume_session_grants SubagentStop sweep was removed in the approvals
|
|
4503
|
+
# redesign M1 -- grants are consumed at the match and expire on their short TTL.)
|
|
3523
4504
|
#
|
|
3524
4505
|
# confirm_db_grant() -- sets confirmed=1 on a PENDING grant row;
|
|
3525
4506
|
# used when the user explicitly confirms a
|