diff --git a/.env.example b/.env.example index 1ec19b7bb..06cb82d91 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,11 @@ LLM_GATEWAY_EMBEDDING_MODEL= LLM_API_GATEWAY= LLM_API_KEY= CALDAV_BASE_URL= +# Optional Naruon calendar projection consume (ADR 0203 step 2 / #336). +# Empty keeps observed events fail-closed. Never put an end-user bearer here. +# CALDAV_BASE_URL is not a fallback for this audience. +NARUON_CALENDAR_BASE_URL= +NARUON_CALENDAR_SERVICE_TOKEN= RANKWEAVE_DISABLED= # Optional process HMAC for GET /api/ontology/neighborhood source continuation. diff --git a/CHANGELOG.d/2.17.0-naruon-calendar-buyer-wiring.md b/CHANGELOG.d/2.17.0-naruon-calendar-buyer-wiring.md new file mode 100644 index 000000000..f3850d286 --- /dev/null +++ b/CHANGELOG.d/2.17.0-naruon-calendar-buyer-wiring.md @@ -0,0 +1,21 @@ +# 2.17.0 — Wire the Naruon calendar projection into the Buyer Calendar + +## Added + +- `GET /api/calendar` now consumes the Naruon calendar projection (ADR 0203 + step 2 / #336) beside post-grounded commitments. Observed occurrences stay + non-clickable evidence; a commitment still opens that post. +- `WorkspaceCalendar` is the 달력 destination and Storybook catalog entry. + Fail-closed copy remains `이 범위의 일정을 아직 받을 수 없습니다`. + +## Changed + +- Product copy no longer names a custom JSON feed as CalDAV. Missing or + malformed `NARUON_CALENDAR_BASE_URL` / `NARUON_CALENDAR_SERVICE_TOKEN` + keeps events empty and commitments available. `CALDAV_BASE_URL` is not a + fallback. + +## Security + +- The end-user bearer token is never forwarded to Naruon. Observed events + carry no `post_id` or `issue_ticket_id` and are never promoted into tickets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f3c45c6..d6ce21cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project are documented here. Format follows ### Added +- Buyer Calendar now consumes the Naruon calendar projection beside + post-grounded commitments (ADR 0203 step 2 / #336). Observed occurrences + stay evidence-only; a commitment still opens that post. The 달력 + destination fail-closes with `이 범위의 일정을 아직 받을 수 없습니다` + when the Naruon audience is missing. `CALDAV_BASE_URL` is not a fallback. + - Registered the `analysis_run_topic_lineage` analysis-run kind (migrations 0131/0132, ADR 0132), the LineageWeave-side consumption boundary for TEPP's Temporal Relational Shared-Latent Topic Measurement (TRSL-TM, diff --git a/backend/app/config.py b/backend/app/config.py index d229096ec..13219230f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -61,6 +61,8 @@ class Settings: tepp_transport_url: str tepp_api_key: str caldav_base_url: str + naruon_calendar_base_url: str + naruon_calendar_service_token: str rankweave_disabled: bool ontology_source_cursor_secret: str @@ -172,6 +174,10 @@ def load_settings() -> Settings: tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), tepp_api_key=os.environ.get("TEPP_API_KEY", ""), caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(), + naruon_calendar_base_url=os.environ.get("NARUON_CALENDAR_BASE_URL", "").strip(), + naruon_calendar_service_token=os.environ.get( + "NARUON_CALENDAR_SERVICE_TOKEN", "" + ).strip(), rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "") .strip() .lower() diff --git a/backend/app/main.py b/backend/app/main.py index 0e98548af..a4370a55b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ import logging from contextlib import asynccontextmanager from dataclasses import asdict -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Literal from uuid import UUID @@ -42,10 +42,6 @@ ContextualOrchestratorCommitmentExtractionClient, NullCommitmentExtractionClient, ) -from lineageweave.caldav_client import ( - CALDAV_UNAVAILABLE_NEXT_ACTION, - build_caldav_client, -) from lineageweave.entity_relationship_classification import ( ContextualOrchestratorEntityRelationshipClient, NullEntityRelationshipClient, @@ -93,6 +89,11 @@ from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints from lineageweave.ontology import LW from lineageweave.rankweave_client import build_rankweave_client +from lineageweave.naruon_calendar_workspace import ( + build_workspace_naruon_client, + default_calendar_window, + load_observed_calendar_events, +) from backend.app.analysis_run_ingestion import ( AnalysisRunCreateError, @@ -3371,25 +3372,33 @@ async def read_analysis_run( async def read_calendar( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + window_start: str | None = Query(default=None), + window_end: str | None = Query(default=None), ) -> dict[str, Any]: - """Return independent CalDAV events alongside authorized commitments. + """Return Naruon observed events beside authorized commitments. - An unavailable optional CalDAV source never hides the internal to-do - projection and never creates a synthetic event. + A missing or malformed Naruon audience never hides the internal to-do + projection and never creates a synthetic event. The end-user bearer + token is not forwarded. """ _require_post_read(account) - caldav = build_caldav_client(load_settings().caldav_base_url) - events = [] - caldav_available = caldav.available - caldav_next_action = None - if caldav.available: - try: - events = [asdict(event) for event in caldav.list_events()] - except (HttpClientError, OSError, ValueError): - caldav_available = False - caldav_next_action = CALDAV_UNAVAILABLE_NEXT_ACTION - else: - caldav_next_action = CALDAV_UNAVAILABLE_NEXT_ACTION + if (window_start is None) ^ (window_end is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "window_start and window_end must be supplied together", + ) + settings = load_settings() + if window_start is None or window_end is None: + window_start, window_end = default_calendar_window(datetime.now(timezone.utc)) + naruon = load_observed_calendar_events( + build_workspace_naruon_client( + settings.naruon_calendar_base_url, + settings.naruon_calendar_service_token, + ), + window_start, + window_end, + ) + events = [asdict(event) for event in naruon.events] async with pool.acquire() as conn: commitments = await fetch_upcoming_commitments(conn) demo_entity_ids: set[str] = set() @@ -3408,8 +3417,8 @@ async def read_calendar( "events": events, "commitments": visible, "calendar_sources": { - "caldav_available": caldav_available, - "caldav_next_action": caldav_next_action, + "naruon_available": naruon.available, + "naruon_next_action": naruon.next_action, }, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index dee21f626..c2e0e4f62 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4691,7 +4691,36 @@ def test_derive_commitment_requires_post_admin(client, demo_analyst_token, seede def test_calendar_is_empty_before_any_commitment(client, demo_analyst_token, seeded_db) -> None: response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 - assert response.json()["commitments"] == [] + payload = response.json() + assert payload["commitments"] == [] + assert payload["events"] == [] + assert payload["calendar_sources"]["naruon_available"] is False + assert "Connect the Naruon calendar projection" in payload["calendar_sources"]["naruon_next_action"] + assert "caldav_available" not in payload["calendar_sources"] + + +def test_calendar_window_requires_both_bounds(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + "/api/calendar", + params={"window_start": "2026-08-25T00:00:00Z"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 422 + assert "together" in response.json()["detail"] + + +def test_calendar_does_not_treat_caldav_url_as_naruon( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + monkeypatch.setenv("CALDAV_BASE_URL", "https://calendar.example/caldav/") + monkeypatch.delenv("NARUON_CALENDAR_BASE_URL", raising=False) + monkeypatch.delenv("NARUON_CALENDAR_SERVICE_TOKEN", raising=False) + response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + payload = response.json() + assert payload["events"] == [] + assert payload["calendar_sources"]["naruon_available"] is False + assert "caldav_available" not in payload["calendar_sources"] def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index ad7d9b1e1..96bb5941b 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -82,6 +82,23 @@ def test_local_keycloak_discovery_uses_backend_reachable_base_url(monkeypatch) - ) +def test_naruon_calendar_audience_defaults_empty_and_is_not_caldav(monkeypatch) -> None: + """Missing Naruon settings keep the observed-event channel dropped.""" + monkeypatch.delenv("NARUON_CALENDAR_BASE_URL", raising=False) + monkeypatch.delenv("NARUON_CALENDAR_SERVICE_TOKEN", raising=False) + monkeypatch.setenv("CALDAV_BASE_URL", "https://calendar.example/caldav/") + settings = load_settings() + assert settings.naruon_calendar_base_url == "" + assert settings.naruon_calendar_service_token == "" + assert settings.caldav_base_url == "https://calendar.example/caldav/" + monkeypatch.setenv("NARUON_CALENDAR_BASE_URL", "https://naruon.example/projection") + monkeypatch.setenv("NARUON_CALENDAR_SERVICE_TOKEN", "service-secret") + wired = load_settings() + assert wired.naruon_calendar_base_url == "https://naruon.example/projection" + assert wired.naruon_calendar_service_token == "service-secret" + assert wired.naruon_calendar_service_token != wired.caldav_base_url + + def test_rankweave_disabled_defaults_off(monkeypatch) -> None: monkeypatch.delenv("RANKWEAVE_DISABLED", raising=False) assert load_settings().rankweave_disabled is False diff --git a/docker-compose.yml b/docker-compose.yml index ea2f963a8..dc5f6823a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -180,6 +180,8 @@ services: TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} TEPP_API_KEY: ${TEPP_API_KEY:-} CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} + NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-} + NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-} RANKWEAVE_DISABLED: ${RANKWEAVE_DISABLED:-} # Process HMAC for ontology source-window continuation. Empty keeps the # truncated-without-cursor contract. Never reuse an OIDC or orchestrator secret. diff --git a/docs/adr/0183-gnb-four-korean-chrome.md b/docs/adr/0183-gnb-four-korean-chrome.md index 8d004137a..bd634d591 100644 --- a/docs/adr/0183-gnb-four-korean-chrome.md +++ b/docs/adr/0183-gnb-four-korean-chrome.md @@ -10,8 +10,9 @@ Main still shipped `BuyerNav` with English analyst tabs (Board / Customer master / Calendar / Ask Agent / Admin). LineageWeave is an analyst workspace, not a storefront, so "Buyer" and "Cubee" are not product names. Calendar is -CalendarWeave / Naruon CalDAV consume only (issue #336); this slice must not -add a calendar kernel. +CalendarWeave / Naruon consume only (issue #336); this slice must not +add a calendar kernel. v2.17.0 wires the 달력 destination to the Naruon +projection consume helper and keeps the same fail-closed copy. ## Decision @@ -29,16 +30,18 @@ add a calendar kernel. 3. Operator Admin remains a non-GNB destination. It is not a fifth analyst tab. 4. Weekly VOC remains a board filter. Weekly/monthly newspaper remains a scheduled board post. Neither is a GNB item. -5. The 달력 destination fail-closes when CalendarWeave / Naruon consume is +5. The 달력 destination fail-closes when Naruon calendar projection consume is unwired or missing, with the exact copy `이 범위의 일정을 아직 받을 수 없습니다`. Existing advanced-review commitment projection is not a calendar kernel and is not this GNB surface. + Observed Naruon occurrences stay separate from post-grounded commitments. ## Consequences - Analyst chrome no longer shows Buyer, Cubee, Board, or Customer master. - CalendarWeave wiring stays a later consume-only slice. This PR does not - import naruon mailbox, ThreadWeave, or Keyverse dumps. + import naruon mailbox, ThreadWeave, or Keyverse dumps. v2.17.0 only + activates the published calendar projection consume path. - Historical ADRs keep the wording of their time. ## References diff --git a/docs/adr/0203-naruon-calendar-projection-boundary.md b/docs/adr/0203-naruon-calendar-projection-boundary.md index ef2764e74..ddee2b769 100644 --- a/docs/adr/0203-naruon-calendar-projection-boundary.md +++ b/docs/adr/0203-naruon-calendar-projection-boundary.md @@ -117,6 +117,16 @@ Until Naruon ships the matching read endpoint and service-audience contract, LineageWeave runtime wiring remains disabled and fail-closed. Existing internal commitments remain available even when the external event channel is absent. +LineageWeave v2.17.0 implements activation gate step 2: `GET /api/calendar` +and the 달력 destination consume `NARUON_CALENDAR_BASE_URL` / +`NARUON_CALENDAR_SERVICE_TOKEN` through +`lineageweave.naruon_calendar_workspace`. A missing audience, malformed +token, transport failure, or contract rejection returns `events: []` with +`naruon_available: false` and never invents an occurrence. Steps 3–5 +(provider/consumer fixtures against a released Naruon artifact, degraded +behavior, and protected merge) remain open. Do not treat this wiring as a +completed Naruon connector. + ## Consequences ### Positive diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 20c0cf5a0..598baeb39 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -250,7 +250,7 @@ this file per §3.5 of the prior snapshot). | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | | #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 | -| #336 | Replace pseudo-CalDAV feed with a Naruon-owned calendar projection | #355 | +| #336 | Replace pseudo-CalDAV feed with a Naruon-owned calendar projection | Contract on `main` (#355); Buyer consume wiring in `feat/naruon-calendar-buyer-wiring-v2170` | | #338 | Evidence-bounded email/project lineage contract for Naruon consumption | #355 | | #341 | Heterogeneous ontology and provenance explorer separate from Event Lineage | #349 | | #358 | Batch reauthorize persisted post-Ask evidence without N+1 queries | Ask stack | @@ -273,7 +273,7 @@ this file per §3.5 of the prior snapshot). | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | | Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage are present on #490, not protected `main` | Deliver the token repair through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | -| Calendar / Naruon | #355 delivered the Naruon-owned projection contract and conformance fixture to protected `main`; live consumer acceptance is not yet evidenced | Verify Naruon consumption against the published schema and issues #336/#338 without invented events | +| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires Buyer consume without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | | SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound | | Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | | Scientific measurement | Durable accepted TEPP receipts and fail-closed production weighting are protected (`main`); #468 binds fast-mlsirm/Keyverse/orchestrator/TEPP integration tests and now fails closed on upstream probability-axis drift. #387 removes inferred/default persistence weights and converts its 3/4-channel evidence tests to fast-mlsirm estimates, but several older reconstruction tests still pass hand-authored numeric weight dictionaries; those constants are not estimator evidence | Continue replacing remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures; tests unrelated to fusion must bypass weighting entirely, as #484 does. Land #387/#468/#417 through the standard gate and retain true-parameter RMSE recovery as the acceptance bar | @@ -387,7 +387,7 @@ review latency are never blockers — keep working while they settle. - Orchestrator / paper-grounded models: ADR 0015, ADR 0076 (Fugu, TRINITY, Conductor) - Ontology / PROV-O / SKOS: ADR 0004, ADR 0011, issue #372 - Analysis runs / TEPP: ADR 0013–0023, issue #79 / #277 -- Calendar / Naruon: issues #336 / #338, PR #355 +- Calendar / Naruon: issues #336 / #338, PR #355, Buyer consume v2.17.0 - Ask Agent: issues #269–#272, #358–#363 Citations in doctoring and ADRs use APA 7th. Do not invent a heuristic where diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 17f98a2dd..4dbaad58b 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -9,7 +9,7 @@ buyer-facing control you can click before changing product CSS. | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | -| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index 2a95c3a22..6477ac570 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.15.1", + "version": "2.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e0579a65a..907a77ee9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -773,6 +773,7 @@ describe("App, authenticated", () => { if (url.endsWith("/api/calendar")) { return Promise.resolve( jsonResponse({ + events: [], commitments: options?.calendarCommitments ?? [ { @@ -802,6 +803,11 @@ describe("App, authenticated", () => { post_title: "Specification revision requested", }, ], + calendar_sources: { + naruon_available: false, + naruon_next_action: + "Connect the Naruon calendar projection. Open a commitment below to read that post.", + }, }), ); } @@ -4086,13 +4092,22 @@ describe("App, authenticated", () => { expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument(); }); - it("fails closed on the calendar destination when CalendarWeave consume is unwired", async () => { + it("fails closed on the calendar destination when Naruon consume is unwired", async () => { stubBackend(); render(); await userEvent.click(await screen.findByRole("button", { name: "달력" })); expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument(); expect(screen.getByText("이 범위의 일정을 아직 받을 수 없습니다")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Observed calendar events" })).toBeInTheDocument(); + expect(screen.queryByText(/CalDAV/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Buyer|Cubee/i)).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: /open commitment for: public post/i }), + ); + expect(await screen.findByRole("button", { name: "게시판" })).toHaveAttribute( + "aria-current", + "page", + ); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 640262baf..e64ad33c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { AdminPanel } from "./components/AdminPanel"; import { LeftoverPairList } from "./components/LeftoverPairList"; +import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; @@ -93,7 +94,6 @@ import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; -import { CALENDAR_CONSUME_UNAVAILABLE } from "./gnbChrome"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; @@ -3348,9 +3348,13 @@ function RankingsPanel({ function CalendarPanel({ accessToken, onSelectPost, + headingId = "lab-calendar-heading", + heading, }: { accessToken: string; onSelectPost: (postId: string) => void; + headingId?: string; + heading?: string; }) { const [calendar, setCalendar] = useState(null); const [error, setError] = useState(null); @@ -3364,65 +3368,13 @@ function CalendarPanel({ if (error) return

{error}

; if (calendar === null) return

{t("Loading calendar...")}

; - const events = calendar.events ?? []; - const commitments = calendar.commitments ?? []; - const caldavAvailable = calendar.calendar_sources?.caldav_available ?? false; - const caldavNextAction = calendar.calendar_sources?.caldav_next_action; - return ( -
-

{t("Calendar")}

-
-

{t("CalDAV events")}

- {events.length === 0 ? ( -

- {caldavAvailable - ? t("No CalDAV events are available.") - : caldavNextAction ?? t("CalDAV is not connected.")} -

- ) : ( -
    - {events.map((event) => ( -
  • -
    - {event.summary} - {event.starts_at} -
    -
  • - ))} -
- )} -
-
-

{t("Upcoming commitments")}

- {commitments.length === 0 ? ( -

- {t("No upcoming commitments. Derive one from a post, or create a ticket with a due date.")} -

- ) : ( -
    - {commitments.map((entry) => ( -
  • - -
  • - ))} -
- )} -
-
+ ); } @@ -5025,8 +4977,15 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean ) : null} {destination === "calendar" ? (
-

달력

-

{CALENDAR_CONSUME_UNAVAILABLE}

+ { + setPostToOpen(postId); + setDestination("board"); + }} + />
) : null} {destination === "ask" ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8ee90eeb1..bd15e003b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -346,18 +346,28 @@ export interface CalendarEntry extends IssueTicket { post_title: string; } -export interface CalDavEvent { - event_id: string; - summary: string; +export interface NaruonCalendarEvent { + occurrence_reference: string; + event_reference: string; + source_reference: string; + display_text: string; starts_at: string; + ends_at: string; + all_day: boolean; + time_zone: string; + status_code: string; + disclosure_code: string; + truth_status_code: string; + observed_at: string; + provider_revision: string; } export interface CalendarResponse { - events: CalDavEvent[]; + events: NaruonCalendarEvent[]; commitments: CalendarEntry[]; calendar_sources: { - caldav_available: boolean; - caldav_next_action: string | null; + naruon_available: boolean; + naruon_next_action: string | null; }; } diff --git a/frontend/src/components/WorkspaceCalendar.stories.tsx b/frontend/src/components/WorkspaceCalendar.stories.tsx new file mode 100644 index 000000000..60f864a5f --- /dev/null +++ b/frontend/src/components/WorkspaceCalendar.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WorkspaceCalendar } from "./WorkspaceCalendar"; +import type { CalendarResponse } from "../api"; + +const unavailable: CalendarResponse = { + events: [], + commitments: [ + { + issue_ticket_id: "ticket-a100", + post_id: "post-demo-public", + ticket_status_code: "open", + ticket_status_label: "Open", + ticket_title: "Send Northridge Grid the revised quote", + assigned_account_id: null, + due_date: "2026-01-12", + commitment_summary: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + post_title: "Public post", + }, + ], + calendar_sources: { + naruon_available: false, + naruon_next_action: + "Connect the Naruon calendar projection. Open a commitment below to read that post.", + }, +}; + +const observed: CalendarResponse = { + events: [ + { + occurrence_reference: "occ_001", + event_reference: "evt_001", + source_reference: "src_001", + display_text: "Customer review", + starts_at: "2026-08-24T09:00:00+09:00", + ends_at: "2026-08-24T10:00:00+09:00", + all_day: false, + time_zone: "Asia/Seoul", + status_code: "confirmed", + disclosure_code: "summary_visible", + truth_status_code: "observed", + observed_at: "2026-08-21T00:00:00Z", + provider_revision: 'W/"revision-7"', + }, + ], + commitments: unavailable.commitments, + calendar_sources: { naruon_available: true, naruon_next_action: null }, +}; + +const meta = { + title: "Workspace/WorkspaceCalendar", + component: WorkspaceCalendar, + args: { + calendar: unavailable, + onSelectPost: () => undefined, + headingId: "calendar-heading", + heading: "달력", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const NaruonUnavailable: Story = {}; + +export const ObservedOccurrence: Story = { + args: { calendar: observed }, +}; diff --git a/frontend/src/components/WorkspaceCalendar.test.tsx b/frontend/src/components/WorkspaceCalendar.test.tsx new file mode 100644 index 000000000..3b7a5eca0 --- /dev/null +++ b/frontend/src/components/WorkspaceCalendar.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { WorkspaceCalendar } from "./WorkspaceCalendar"; +import { CALENDAR_CONSUME_UNAVAILABLE } from "../gnbChrome"; +import type { CalendarResponse } from "../api"; + +const commitment = { + issue_ticket_id: "ticket-a100", + post_id: "post-demo-public", + ticket_status_code: "open", + ticket_status_label: "Open", + ticket_title: "Send Northridge Grid the revised quote", + assigned_account_id: null, + due_date: "2026-01-12", + commitment_summary: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + post_title: "Public post", +}; + +const unavailable: CalendarResponse = { + events: [], + commitments: [commitment], + calendar_sources: { + naruon_available: false, + naruon_next_action: + "Connect the Naruon calendar projection. Open a commitment below to read that post.", + }, +}; + +describe("WorkspaceCalendar", () => { + it("fails closed on observed events and still opens a commitment", async () => { + const onSelectPost = vi.fn(); + render( + , + ); + + expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument(); + expect(screen.getByText(CALENDAR_CONSUME_UNAVAILABLE)).toBeInTheDocument(); + expect(screen.queryByText(/CalDAV/i)).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: /open commitment for: public post/i }), + ); + expect(onSelectPost).toHaveBeenCalledWith("post-demo-public"); + }); + + it("does not turn an observed occurrence into a commitment button", () => { + render( + undefined} + headingId="calendar-heading" + heading="달력" + />, + ); + + expect(screen.getByText("Customer review")).toBeInTheDocument(); + expect( + screen.getByText("Open this observed occurrence. It is not a LineageWeave commitment."), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /customer review/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open commitment for: public post/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WorkspaceCalendar.tsx b/frontend/src/components/WorkspaceCalendar.tsx new file mode 100644 index 000000000..5f2631f39 --- /dev/null +++ b/frontend/src/components/WorkspaceCalendar.tsx @@ -0,0 +1,102 @@ +import { EvidenceStatusMark } from "./EvidenceStatusMark"; +import { CALENDAR_CONSUME_UNAVAILABLE } from "../gnbChrome"; +import type { CalendarResponse, NaruonCalendarEvent } from "../api"; +import { t } from "../i18n"; + +export type WorkspaceCalendarProps = { + calendar: CalendarResponse; + onSelectPost: (postId: string) => void; + headingId: string; + heading: string; + failClosedCopy?: string; +}; + +/** + * Buyer Calendar: observed Naruon occurrences stay separate from + * post-grounded commitments. Click a commitment to open that post. + */ +export function WorkspaceCalendar({ + calendar, + onSelectPost, + headingId, + heading, + failClosedCopy = CALENDAR_CONSUME_UNAVAILABLE, +}: WorkspaceCalendarProps) { + const events = calendar.events ?? []; + const commitments = calendar.commitments ?? []; + const naruonAvailable = calendar.calendar_sources?.naruon_available ?? false; + const naruonNextAction = calendar.calendar_sources?.naruon_next_action; + + return ( +
+

{heading}

+
+

{t("Observed calendar events")}

+ {events.length === 0 ? ( +

+ {naruonAvailable + ? t("No observed calendar events are available.") + : failClosedCopy} +

+ ) : ( +
    + {events.map((event) => ( + + ))} +
+ )} + {!naruonAvailable && naruonNextAction ? ( +

{naruonNextAction}

+ ) : null} +
+
+

{t("Upcoming commitments")}

+ {commitments.length === 0 ? ( +

+ {t("No upcoming commitments. Derive one from a post, or create a ticket with a due date.")} +

+ ) : ( +
    + {commitments.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + +function ObservedEventRow({ event }: { event: NaruonCalendarEvent }) { + return ( +
  • +
    + + {event.display_text} + {event.starts_at} + {event.disclosure_code} + + {t("Open this observed occurrence. It is not a LineageWeave commitment.")} + +
    +
  • + ); +} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 476f60683..c894f0275 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -71,6 +71,9 @@ describe("i18n", () => { "Title overlap", "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", "Workspace navigation", + "Observed calendar events", + "No observed calendar events are available.", + "Open this observed occurrence. It is not a LineageWeave commitment.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bebdb9df3..36e5878f9 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -50,9 +50,10 @@ const TRANSLATIONS: Partial>> = { "Ranking evidence for {title}": "{title}의 순위 근거", "{label} rank {rank}, contribution {contribution}": "{label} 순위 {rank}, 기여 {contribution}", - "CalDAV events": "CalDAV 이벤트", - "No CalDAV events are available.": "사용할 수 있는 CalDAV 이벤트가 없습니다.", - "CalDAV is not connected.": "CalDAV가 연결되지 않았습니다.", + "Observed calendar events": "관측된 달력 일정", + "No observed calendar events are available.": "관측된 달력 일정이 없습니다.", + "Open this observed occurrence. It is not a LineageWeave commitment.": + "이 관측 일정을 확인하세요. LineageWeave 약속이 아닙니다.", "Upcoming commitments": "예정된 약속", "No upcoming commitments.": "예정된 약속이 없습니다.", "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": @@ -528,9 +529,10 @@ const TRANSLATIONS: Partial>> = { "Ranking evidence for {title}": "{title} 的排名证据", "{label} rank {rank}, contribution {contribution}": "{label} 排名 {rank},贡献 {contribution}", - "CalDAV events": "CalDAV 事件", - "No CalDAV events are available.": "没有可用的 CalDAV 事件。", - "CalDAV is not connected.": "CalDAV 尚未连接。", + "Observed calendar events": "已观察的日历事件", + "No observed calendar events are available.": "没有可用的已观察日历事件。", + "Open this observed occurrence. It is not a LineageWeave commitment.": + "打开此已观察事件。它不是 LineageWeave 承诺。", "Upcoming commitments": "即将到来的承诺", "No upcoming commitments.": "没有即将到来的承诺。", "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": @@ -1028,9 +1030,10 @@ const TRANSLATIONS: Partial>> = { "Ranking evidence for {title}": "{title} の順位根拠", "{label} rank {rank}, contribution {contribution}": "{label} 順位 {rank}、寄与 {contribution}", - "CalDAV events": "CalDAV イベント", - "No CalDAV events are available.": "利用できる CalDAV イベントはありません。", - "CalDAV is not connected.": "CalDAV が接続されていません。", + "Observed calendar events": "観測されたカレンダー予定", + "No observed calendar events are available.": "利用できる観測カレンダー予定はありません。", + "Open this observed occurrence. It is not a LineageWeave commitment.": + "この観測予定を開きます。LineageWeave のコミットメントではありません。", "Upcoming commitments": "今後のコミットメント", "No upcoming commitments.": "今後のコミットメントはありません。", "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": @@ -1505,9 +1508,10 @@ const TRANSLATIONS: Partial>> = { "Ranking evidence for {title}": "Bằng chứng xếp hạng cho {title}", "{label} rank {rank}, contribution {contribution}": "{label} hạng {rank}, đóng góp {contribution}", - "CalDAV events": "Sự kiện CalDAV", - "No CalDAV events are available.": "Không có sự kiện CalDAV nào khả dụng.", - "CalDAV is not connected.": "CalDAV chưa được kết nối.", + "Observed calendar events": "Sự kiện lịch đã quan sát", + "No observed calendar events are available.": "Không có sự kiện lịch đã quan sát nào khả dụng.", + "Open this observed occurrence. It is not a LineageWeave commitment.": + "Mở lần xuất hiện đã quan sát này. Đây không phải cam kết LineageWeave.", "Upcoming commitments": "Cam kết sắp tới", "No upcoming commitments.": "Không có cam kết sắp tới.", "No upcoming commitments. Derive one from a post, or create a ticket with a due date.": diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 3bd503126..238421556 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -21,6 +21,15 @@ NaruonCalendarProjectionClient, parse_naruon_calendar_page, ) +from .naruon_calendar_workspace import ( + NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION, + NaruonCalendarWorkspaceEvent, + NaruonCalendarWorkspaceResult, + build_workspace_naruon_client, + default_calendar_window, + load_observed_calendar_events, + occurrence_to_workspace_event, +) from .post_chat import ChatAnswer, cited_post_summaries from .post_summary import PostSummary from .prov_o import ( @@ -42,10 +51,13 @@ "Edge", "NARUON_CALENDAR_MEDIA_TYPE", "NARUON_CALENDAR_SCHEMA_VERSION", + "NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION", "NaruonCalendarContractError", "NaruonCalendarOccurrence", "NaruonCalendarPage", "NaruonCalendarProjectionClient", + "NaruonCalendarWorkspaceEvent", + "NaruonCalendarWorkspaceResult", "OrganizationRelationship", "PROV", "PROV_CLASSES", @@ -60,8 +72,12 @@ "Record", "Tree", "build_affiliate_forest", + "build_workspace_naruon_client", "cited_post_summaries", + "default_calendar_window", "lineage_edge_specs", + "load_observed_calendar_events", + "occurrence_to_workspace_event", "parse_naruon_calendar_page", "random_walk_with_restart", "reconstruct", @@ -70,4 +86,4 @@ "sentence_excerpts", ] -__version__ = "2.15.0" +__version__ = "2.17.0" diff --git a/lineageweave/naruon_calendar_workspace.py b/lineageweave/naruon_calendar_workspace.py new file mode 100644 index 000000000..cecbfac3a --- /dev/null +++ b/lineageweave/naruon_calendar_workspace.py @@ -0,0 +1,137 @@ +"""Fail-closed Buyer Calendar consume of Naruon observed occurrences. + +LineageWeave commitments stay available when this channel is missing or +malformed. Observed events are never promoted into issue tickets and the +end-user bearer token is never forwarded to Naruon. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .http_client import HttpClientError +from .naruon_calendar_projection import ( + NaruonCalendarContractError, + NaruonCalendarOccurrence, + NaruonCalendarProjectionClient, +) + +NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION = ( + "Connect the Naruon calendar projection. Open a commitment below to read that post." +) +_DEFAULT_WINDOW = timedelta(days=31) + + +@dataclass(frozen=True) +class NaruonCalendarWorkspaceEvent: + """Buyer-visible observed occurrence; not a LineageWeave commitment.""" + + occurrence_reference: str + event_reference: str + source_reference: str + display_text: str + starts_at: str + ends_at: str + all_day: bool + time_zone: str + status_code: str + disclosure_code: str + truth_status_code: str + observed_at: str + provider_revision: str + + +@dataclass(frozen=True) +class NaruonCalendarWorkspaceResult: + """Fail-closed observation page for the Buyer Calendar.""" + + available: bool + next_action: str | None + events: tuple[NaruonCalendarWorkspaceEvent, ...] + + +def default_calendar_window(now: datetime) -> tuple[str, str]: + """Return a 31-day UTC RFC 3339 window starting at ``now``. + + Naive timestamps are rejected so a local clock cannot leak into the + Naruon consume contract. + """ + + if now.tzinfo is None or now.utcoffset() is None: + raise ValueError("now must include an offset") + start = now.astimezone(timezone.utc).replace(microsecond=0) + end = start + _DEFAULT_WINDOW + return ( + start.strftime("%Y-%m-%dT%H:%M:%SZ"), + end.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + +def occurrence_to_workspace_event( + occurrence: NaruonCalendarOccurrence, +) -> NaruonCalendarWorkspaceEvent: + """Copy one validated occurrence into the Buyer Calendar payload.""" + + return NaruonCalendarWorkspaceEvent( + occurrence_reference=occurrence.occurrence_reference, + event_reference=occurrence.event_reference, + source_reference=occurrence.source_reference, + display_text=occurrence.display_text, + starts_at=occurrence.starts_at, + ends_at=occurrence.ends_at, + all_day=occurrence.all_day, + time_zone=occurrence.time_zone, + status_code=occurrence.status_code, + disclosure_code=occurrence.disclosure_code, + truth_status_code=occurrence.truth_status_code, + observed_at=occurrence.observed_at, + provider_revision=occurrence.provider_revision, + ) + + +def build_workspace_naruon_client( + base_url: str, + service_access_token: str, +) -> NaruonCalendarProjectionClient | None: + """Return a Naruon client only when both transport settings are usable. + + A missing or malformed audience is unavailable, never a fabricated + event source. The caller must pass the service credential, not an + end-user bearer token. + """ + + if not base_url.strip() or not service_access_token.strip(): + return None + try: + return NaruonCalendarProjectionClient(base_url, service_access_token) + except ValueError: + return None + + +def load_observed_calendar_events( + client: NaruonCalendarProjectionClient | None, + window_start: str, + window_end: str, +) -> NaruonCalendarWorkspaceResult: + """Read one observed page, or fail closed without inventing events.""" + + if client is None: + return NaruonCalendarWorkspaceResult( + available=False, + next_action=NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION, + events=(), + ) + try: + page = client.list_events(window_start, window_end) + except (HttpClientError, OSError, ValueError, NaruonCalendarContractError): + return NaruonCalendarWorkspaceResult( + available=False, + next_action=NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION, + events=(), + ) + return NaruonCalendarWorkspaceResult( + available=True, + next_action=None, + events=tuple(occurrence_to_workspace_event(row) for row in page.events), + ) diff --git a/pyproject.toml b/pyproject.toml index ed728eba7..e77df5f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.15.1" +version = "2.17.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_naruon_calendar_workspace.py b/tests/test_naruon_calendar_workspace.py new file mode 100644 index 000000000..c1d67aeff --- /dev/null +++ b/tests/test_naruon_calendar_workspace.py @@ -0,0 +1,169 @@ +"""Buyer Calendar consume stays fail-closed and never invents events.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from lineageweave.http_client import HttpClientError +from lineageweave.naruon_calendar_projection import NaruonCalendarOccurrence +from lineageweave.naruon_calendar_workspace import ( + NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION, + NaruonCalendarWorkspaceEvent, + build_workspace_naruon_client, + default_calendar_window, + load_observed_calendar_events, + occurrence_to_workspace_event, +) + + +def _occurrence() -> NaruonCalendarOccurrence: + return NaruonCalendarOccurrence( + event_reference="evt_001", + occurrence_reference="occ_001", + source_reference="src_001", + provider_revision='W/"revision-7"', + display_text="Customer review", + starts_at="2026-08-24T09:00:00+09:00", + ends_at="2026-08-24T10:00:00+09:00", + all_day=False, + time_zone="Asia/Seoul", + status_code="confirmed", + disclosure_code="summary_visible", + truth_status_code="observed", + observed_at="2026-08-21T00:00:00Z", + ) + + +def test_default_window_is_thirty_one_utc_days() -> None: + start, end = default_calendar_window(datetime(2026, 8, 25, 8, 6, tzinfo=timezone.utc)) + + assert start == "2026-08-25T08:06:00Z" + assert end == "2026-09-25T08:06:00Z" + + +def test_default_window_rejects_a_naive_clock() -> None: + with pytest.raises(ValueError, match="offset"): + default_calendar_window(datetime(2026, 8, 25, 8, 6)) + + +def test_missing_audience_does_not_build_a_client() -> None: + assert build_workspace_naruon_client("", "service-secret") is None + assert build_workspace_naruon_client("https://naruon.example", "") is None + assert build_workspace_naruon_client(" ", " ") is None + + +def test_malformed_audience_fails_closed_without_a_client() -> None: + assert build_workspace_naruon_client("file:///tmp/events", "service-secret") is None + assert ( + build_workspace_naruon_client( + "https://naruon.example", + "service secret with space", + ) + is None + ) + + +def test_missing_client_keeps_commitments_path_unblocked() -> None: + result = load_observed_calendar_events( + None, + "2026-08-25T00:00:00Z", + "2026-09-25T00:00:00Z", + ) + + assert result.available is False + assert result.events == () + assert result.next_action == NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION + assert "token" not in result.next_action.lower() + assert "secret" not in result.next_action.lower() + + +def test_transport_failure_does_not_invent_events(monkeypatch) -> None: + client = build_workspace_naruon_client( + "https://naruon.example/tenant-projection", + "service-secret", + ) + assert client is not None + + def boom(*_args, **_kwargs): + raise HttpClientError("naruon.example refused the projection") + + monkeypatch.setattr(client, "list_events", boom) + result = load_observed_calendar_events( + client, + "2026-08-25T00:00:00Z", + "2026-09-25T00:00:00Z", + ) + + assert result.available is False + assert result.events == () + assert result.next_action == NARUON_CALENDAR_UNAVAILABLE_NEXT_ACTION + + +def test_contract_failure_does_not_leak_the_body(monkeypatch) -> None: + client = build_workspace_naruon_client( + "https://naruon.example/tenant-projection", + "service-secret", + ) + assert client is not None + + def boom(*_args, **_kwargs): + raise ValueError("calendar_page has unexpected fields: attendees") + + monkeypatch.setattr(client, "list_events", boom) + result = load_observed_calendar_events( + client, + "2026-08-25T00:00:00Z", + "2026-09-25T00:00:00Z", + ) + + assert result.available is False + assert result.events == () + assert "attendees" not in (result.next_action or "") + + +def test_accepted_page_keeps_observed_events_out_of_commitments(monkeypatch) -> None: + client = build_workspace_naruon_client( + "https://naruon.example/tenant-projection", + "service-secret", + ) + assert client is not None + occurrence = _occurrence() + + class _Page: + events = (occurrence,) + + monkeypatch.setattr(client, "list_events", lambda *_args, **_kwargs: _Page()) + result = load_observed_calendar_events( + client, + "2026-08-25T00:00:00Z", + "2026-09-25T00:00:00Z", + ) + + assert result.available is True + assert result.next_action is None + assert result.events == (occurrence_to_workspace_event(occurrence),) + assert result.events[0].truth_status_code == "observed" + assert not hasattr(result.events[0], "issue_ticket_id") + assert not hasattr(result.events[0], "post_id") + + +def test_workspace_event_copies_only_admitted_occurrence_fields() -> None: + event = occurrence_to_workspace_event(_occurrence()) + + assert event == NaruonCalendarWorkspaceEvent( + occurrence_reference="occ_001", + event_reference="evt_001", + source_reference="src_001", + display_text="Customer review", + starts_at="2026-08-24T09:00:00+09:00", + ends_at="2026-08-24T10:00:00+09:00", + all_day=False, + time_zone="Asia/Seoul", + status_code="confirmed", + disclosure_code="summary_visible", + truth_status_code="observed", + observed_at="2026-08-21T00:00:00Z", + provider_revision='W/"revision-7"', + ) diff --git a/uv.lock b/uv.lock index 87668cd4a..c5be3e60c 100644 --- a/uv.lock +++ b/uv.lock @@ -597,7 +597,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.15.1" +version = "2.17.0" source = { editable = "." } dependencies = [ { name = "certifi" },